工具调用详情流协议
!!! note "状态" 定稿(2026-09-09,随 TFRS-401 D4 落定)。归属 TFRS-401 L1 实时过程,实施见 TFRS-420 + TFRF-142。姊妹文档:A2A 结构化进展信封。
一次工具调用期间,服务端将过程增量块持续投递到 Socket.IO,前端在该工具调用 Event 的详情区 按 tail -f 语义逐块追加渲染;对话流本体保持单节点,不逐块展开。
目标形态与边界
形态(体验原型)
- 对话流中:工具调用仍只有一个节点(起始终态快照,同现状)。
- 详情区(Event 详情/展开面板):随调用进行持续丰富/追加——过程块按序长成;
thinking折叠;tool_call一行头。 - 终值区:A2A
completed的最终回复作为权威终态显示;过程流区标记「已结束」。
与相近需求的边界
| 需求 | 维度 | 本协议与它 |
|---|---|---|
| TFRS-339 / TFRF-116(Trajectory 链树 DTO 透出) | 图谱:链树结构、可见性、LLM 形态字段 | 本协议是单工具节点内部的时间线追加流。互补:详情流是链树里一个节点「肚子里的内容」 |
现有 chat_event(dtos/v1/chat/event.py) |
离散快照:eventId/status/eventScene/conversationId | 本协议是增量追加流(新事件名,与快照并存,不合并) |
TFRS-401 L2(Task.history / ListTasks) |
持久化任务记录(跨进程、跨会话可查) | 本协议是当次调用的实时过程流,不持久化(见 T3);L2 是粗粒度回放通道 |
| TFRS-401 L3(异步调用) | 调用模式(不阻塞) | v1 不覆盖:异步调用的工具节点在返回 handle 时即终态,过程流不在该节点上展开(见 T4) |
为什么归属到工具调用(而非对话流)
A2A 主叫侧一次调用 = 一次工具调用:ask_colleague BUILT_IN 工具
(robot/extensions/employee.py signal → colleague_provider.ask_colleague →
A2APeerClient.send_text)。工具场景事件即 ToolEventTransitionDto
(顶层 eventId,content.toolCall.{toolId, functionCall} / toolReturn)。对端 Agent
的过程消息本质是这个工具调用的执行过程,天然归属它的详情——不在对话流中追加与展开
(那也是为什么区别于 TFRS-339 的模型)。
投递面(主叫侧 → 浏览器)
通道
- 命名空间:
/chat(复用现有鉴权/S3 握手); - 事件名:
tool_event_stream(新增;与chat_event快照并存); - 房间:
conversation_id(复用on_join_conversation现有房间机制); - 生产者:主叫 worker 进程(工具执行中)经 loopback socketio client 发射——与现有
chat_event的 loopback 机制同构(socket_namespaces/chat.py),Web 进程只做广播, worker 已持有 A2A SSE 消费句柄,天然是增量块的产生地。
载荷(主叫侧归属封装 + 信封原样透传)
{
"v": 1,
"callId": "caller-tool-event-id",
"taskId": "a2a-task-id",
"state": "running",
"ts": 1789123456789,
"envelope": { "v": 1, "kind": "tool_call", "streamKey": "…", "seq": 42, "status": "running", "payload": {…} }
}
| 字段 | 类型 | 说明 |
|---|---|---|
v |
int | 本协议版本 |
callId |
str | 归属键 = 主叫侧工具调用事件的 eventId(BaseEventTransitionDto.eventId,即 trajectory span_id);详情区按它聚合。获取方式见 T2 |
taskId |
str | A2A task_id(区分多轮/多次调用;同 callId 下多次 task 属不同过程) |
state |
enum | running(过程块)∣ done(A2A completed 终态,唯一一次,payload.text 附终值)∣ aborted(failed/canceled 终态,payload.message 附失败摘要) |
ts |
int | 主叫侧发射时间戳(毫秒) |
envelope |
object | 姊妹文档的信封原样透传;无信封的第三方对端 → 主叫降级构造伪信封 {kind:"text", payload:{text:"<working 文本>"}} |
次序:服务端保证「工具事件快照 chat_event 先发,流块后发」(worker 内工具事件落点先于消费
SSE 增量)——前端以 callId 关联即可,无需依赖到达时序。
生命周期
- 工具调用事件快照(现有
chat_event,tool scene)→ 详情区就位(状态「调用中…」); tool_event_stream过程块持续到达(running);- 终态:唯一一条
state=done(或aborted)——前端收束详情(终值 + 过程结束标记); - 无流块到达(对端未启用信封/纯静默):frontend 超时兜底 → 仅有快照 + 终态(终值仍可从
toolReturn/A2A 终态文本取得),流区显示「无过程详情」。
渲染语义(前端)
- 按
callId聚合同一详情区;区内按envelope.seq排序渲染(乱序容忍、同 seq 幂等); tool_output_append:追加渲染(等宽/终端样式,tail -f)——v1 不会被发出(信封文档 E3), 前端可先行支持;thinking:折叠段落(单行「思考中…」可展开);tool_call:一行(工具名 + 开始标记);- 终态后:终值区固定在详情区顶部(A2A completed 文本),过程流区标记结束,不再追加。
- 详情区字段留
mode: "live" | "replay"占位——本期仅live。
决策记录
| # | 决策点 | 定稿 | 理由 |
|---|---|---|---|
| T1 | 事件名 | tool_event_stream |
chat_event 语义已被快照线占用;不泛化为 event_stream,避免两线混淆 |
| T2 | callId 获取方式 |
v1 近似匹配(见下),内核暴露为收敛路径 | 内核当前不向工具暴露「进行中调用的事件 id」 |
| T3 | 回放/持久化 | 方案 A:过程流不持久化 | 零存储成本;刷新后详情 = 轨迹落库快照(toolReturn 终值可见)。粗粒度回放由 L2 Task.history 承担,B(流聚合落库)视真实产品需要再立 Story |
| T4 | 限速/节流 | worker 消费侧合并节流 ≤10 块/秒,超限丢过程块、不断终态 | 参照 TFRS-269 有界丢新先例;防前端高频重排 |
| T5 | 异步调用(L3) | v1 不覆盖 | 异步调用的工具节点在返回 handle 时即终态;异步进度走 L3 的状态工具与上下文注入 |
T2 细则:callId 的获取
v1(近似匹配):主叫 worker 在 send_text 消费点,从该 conversation 的 trajectory tap 中取
「最近一条未终态、且 tool_name = ask_colleague 的工具事件」的 eventId。
- 歧义兜底:若同时存在多条符合条件的未终态工具事件 → 不带
callId,前端仅显示终值,不展开过程流。 - 已知局限:并发同名工具调用下可能取错;工具返回前前端拿不到稳定关联 id。
收敛路径(backlog,内核通用能力):请内核向工具运行时暴露「当前进行中工具调用的事件 id」。
现状 current_tool_call_id 注入的是 LLM 的 tool_call id(drive/base.py:323,需
merge_context=True),与 trajectory span_id 不同源,且工具返回前前端拿不到(仅在
ToolReturnDto.meta.callId 里随工具返回出现)。落地后替换近似匹配,兜底逻辑保留。
关联
TFRS-401(Epic · L1 决策 T1-T5)· TFRS-420(信封生产端)· TFRF-142(本协议实施)· TFRS-246(A2A 双端)· TFRS-269(trajectory tap)· TFRS-274(ask_colleague)· TFRS-339(链树 DTO——边界见上表)· TFRF-116(前端链树渲染)· TFRF-122(聊天流渲染)。