Skip to content

【BUG】远端 A2A 调用进度投影帧的出口错配:状态语义数据走产物/业务通道(artifactUpdate/REST SSE data 帧) #42

Description

远端 A2A 调用进度投影帧的出口错配问题(黑盒分析)

本文以 versatile-orchestration-demo 的 expense-review 链路为观察对象,从黑盒角度说明
remote_agent_invocation 进度投影帧的语义来源、在两种协议出口(/v1/query REST SSE 与
/a2a/ A2A 流式)上的实际表现,以及由此暴露的出口错配问题,用于后续向
agent-runtime(agent-service-app)提 issue。

分析基于源码:third_party/agent-runtime-java/service/agent-service-app
RemoteInvocationBatchCoordinator / A2AEnabledServeOrchestrator / A2AAgentExecutor /
ChunkMapper / QuerySseSupport)。


1. 核心结论(TL;DR)

RUNNING / INPUT_REQUIRED 这类 remote_agent_invocation 投影帧,承载的是远端 A2A 任务的
状态迁移信息
(语义上等价于 A2A 协议的 statusUpdate 事件),但框架在输出时把它当作
业务数据 chunk 处理:

  • /v1/query REST SSE 出口:投影以普通 data: 业务帧的形式直接混入用户数据流,
    没有任何类型标识(QueryChunk.type="remote_agent_progress" 在序列化时被丢弃);
  • /a2a/ A2A 流式出口:投影被包装成 artifactUpdate(产物帧)而非 statusUpdate
    (状态帧)——状态语义的数据出现在了产物通道里。

即:同一内部进度事件,在两个出口都被放进了"数据/产物"通道,而不是它本该属于的
"状态"通道;且在 REST 出口连区分它与业务数据的标识都没有。


2. 观察到的完整帧序列

2.1 /v1/query REST SSE 出口(expense-review-main, :18097

请求:

curl -N -X POST http://localhost:18097/v1/query \
  -H "Content-Type: application/json" \
  -d '{"conversation_id":"6d72bcf8-...","message":"帮我审核这笔报销:机票5000,酒店3晚每晚800共2400,客户晚餐800","stream":true}'

实际收到三帧:

data: {"content":"RUNNING","projection":{"kind":"remote_agent_invocation","batchId":"333a9f5f-...","toolCallId":"call_00_JIhiVhnvjabn7DCjgY2D1744","sequence":1,"target":"expense-review","phase":"RUNNING","latencyMs":0}}

data: {"content":"INPUT_REQUIRED","projection":{"kind":"remote_agent_invocation","batchId":"333a9f5f-...","toolCallId":"call_00_JIhiVhnvjabn7DCjgY2D1744","sequence":2,"target":"expense-review","phase":"INPUT_REQUIRED","resultCategory":"INPUT_REQUIRED","latencyMs":11900}}

data: {"message":"费用报销审核需要您的审批。请审核后输入 'approved' 通过,或说明拒绝理由。","items":[{"toolCallId":"call_00_JIhiVhnvjabn7DCjgY2D1744","toolName":"expense-review","message":"费用报销审核需要您的审批。请审核后输入 'approved' 通过,或说明拒绝理由。"}]}

帧构成分析:

真实类型(内部) 序列化后表现 问题
1、2 QueryChunk{type="remote_agent_progress"} 进度投影 与普通业务 data: 帧完全同构,typeQuerySseSupport.payload() 丢弃 客户端无法区分投影与业务内容;且 content 是裸状态字符串
3 QueryChunk{type="interrupt"} HITL 追问(A2AEnabledServeOrchestrator.streamBatchResolution() 发出,内容 = publicInterrupt() 同样无 type 标识 追问帧与投影帧、业务帧三者只能靠 JSON 形状猜测区分

注意:第三帧证明续传所需的审批文案与 toolCallId 其实已经在 REST 流里给出了
(此前版本判断"信息未到达客户端"不准确,特此修正);问题集中在第 1、2 帧的出口错配。

2.2 /a2a/ A2A 流式出口(SendStreamingMessage)

同一链路经 A2A 协议调用时,收到的事件序列:

event: jsonrpc
data: {"jsonrpc":"2.0","id":"req-001","result":{"artifactUpdate":{"taskId":"3836f437-...","artifact":{"artifactId":"620157e7-...","parts":[{"text":"RUNNING","metadata":{"_remote_invocation":{"kind":"remote_agent_invocation","batchId":"d1625ae4-...","toolCallId":"call_00_32BxucUtBW0bBQZE9mz32638","sequence":1,"target":"expense-review","phase":"RUNNING","latencyMs":0}}}]},"contextId":"0af383b5-..."}}}

event: jsonrpc
data: {"jsonrpc":"2.0","id":"req-001","result":{"artifactUpdate":{"taskId":"3836f437-...","artifact":{"artifactId":"20c478f2-...","parts":[{"text":"INPUT_REQUIRED","metadata":{"_remote_invocation":{"kind":"remote_agent_invocation","batchId":"d1625ae4-...","toolCallId":"call_00_32BxucUtBW0bBQZE9mz32638","sequence":2,"target":"expense-review","phase":"INPUT_REQUIRED","resultCategory":"INPUT_REQUIRED","latencyMs":12058}}}]},"contextId":"0af383b5-..."}}}

event: jsonrpc
data: {"jsonrpc":"2.0","id":"req-001","result":{"statusUpdate":{"taskId":"3836f437-...","status":{"state":"TASK_STATE_INPUT_REQUIRED","message":{"role":"ROLE_AGENT","parts":[{"text":"费用报销审核需要您的审批。请审核后输入 'approved' 通过,或说明拒绝理由。"}],"messageId":"4e8f72fd-...","metadata":{"_interrupt":{"message":"...","items":[{"toolCallId":"call_00_32BxucUtBW0bBQZE9mz32638","toolName":"expense-review","message":"..."}]}}},"timestamp":"2026-07-29T09:07:15.542073509Z"},"contextId":"0af383b5-..."}}}

对照可以清楚看到错配:

  • 前两条投影(状态迁移信息)出现在 artifactUpdate(产物事件)里——每次投影生成一个
    独立的 artifact,状态字符串成了 artifact 的 text,真正的状态语义藏在 part 的
    _remote_invocation metadata 里;
  • 第三条(真正的任务状态)出现在 statusUpdate 里——这才是 A2A 语义正确的位置。

状态语义的数据走了产物通道,产物通道里混着状态数据——下游消费者若按 A2A 规范
statusUpdate 跟踪任务状态、以 artifactUpdate 收集业务产物,会被这两条伪 artifact 污染。


3. 错配的代码路径

3.1 投影帧的产生(协议无关,main 进程内)

RemoteInvocationBatchCoordinator.project() 在 member 状态机每次迁移时生成:

member.state: QUEUED → RUNNING → COMPLETED / INPUT_REQUIRED / FAILED / TIMED_OUT
                              每次迁移 → QueryChunk{type="remote_agent_progress",
                                                   data={content: "<PHASE>", projection: {...}}}

该 chunk 是纯内部事件QueryChunk 注释自述 "Runtime-internal remote member progress
used for parent Task projection"),后续如何落地完全取决于出口适配层。

3.2 /v1/query REST 出口:type 丢弃,混入业务流

QueryWebFluxController.streamQuery()
  └─ QuerySseSupport.payload(chunk)     // 只取 chunk.getData(),type 字段被丢弃
       └─ SSE data: {"content":"RUNNING","projection":{...}}

QuerySseSupport.payload() 对所有 chunk 一视同仁地只序列化 data,因此
remote_agent_progress / interrupt / chunk 三种类型在 REST 流里全部失去类型标识
投影帧与业务数据帧在 wire 上无法区分。

3.3 /a2a/ 出口:统一塞入 artifactUpdate

A2AAgentExecutor.handleStreamingChunk()
  ├─ type == "interrupt"  → emitter.requiresInput(...)   // → statusUpdate(正确)
  └─ 其他所有 chunk        → emitter.addArtifact(toParts(chunk))  // → artifactUpdate
        └─ ChunkMapper.toParts(): remote_agent_progress 特判,
           把 projection 塞进 part.metadata["_remote_invocation"],
           content("RUNNING") 作为 TextPart 文本

关键在 A2AAgentExecutor.handleStreamingChunk() 的分支逻辑:只有 interrupt 类型走
状态通道(requiresInputstatusUpdate),其余 chunk(包括内部进度投影)一律走
产物通道(addArtifactartifactUpdate
ChunkMapper 虽然识别了
remote_agent_progress 并加了 _remote_invocation metadata,但仍然返回 Part,
最终依旧落成 artifact。


4. 语义对照:投影帧 vs A2A 事件模型

投影帧字段 语义 A2A 协议中对应的载体
phase(RUNNING/INPUT_REQUIRED/...) 任务/调用状态迁移 statusUpdate.status.state
latencyMs / sequence 状态元数据 statusUpdate.status.message.metadata 或事件 metadata
resultCategory 终态分类 statusUpdate.status.state + metadata
batchId / toolCallId / target 调用关联信息 metadata
content(裸状态字符串) 无业务价值的状态文本 不应作为 artifact 内容

即投影帧的全部信息在 A2A 模型里都属于 statusUpdate 的范畴,没有任何一个字段
属于"业务产物"(artifact)范畴。当前的落地方式(REST 业务帧 / A2A artifactUpdate)
与其语义完全错位。


5. 影响与可提 issue 的问题点

  1. 状态数据污染产物/业务通道
    • /a2a/ 出口:每次远端调用产生 2+ 个伪 artifact(text="RUNNING" 等),
      下游按 artifact 收集业务结果的消费者会收到垃圾数据;
    • /v1/query 出口:投影帧与业务 chunk 同构混杂,客户端必须靠 JSON 形状启发式区分,
      content 字段被状态字符串占用,极易被误当作 agent 输出文本展示给用户。
  2. 内部事件泄露且无契约remote_agent_progress 注释自述 runtime-internal,
    但两个出口都对外暴露;字段无文档、无版本化承诺。
  3. REST 出口丢失 chunk 类型remote_agent_progress / interrupt / chunk
    /v1/query SSE 中无类型标识(QuerySseSupport.payload() 只取 data),
    是错配在 REST 侧被放大的根因。
  4. 建议方向(供讨论):
    • /a2a/ 出口:投影帧改走 statusUpdatestatus.state 映射 phase,projection 放
      message/event metadata),或增加开关默认不下发;
    • /v1/query 出口:SSE 序列化保留 type(如 data: {"type":"remote_agent_progress",...}),
      或提供 openjiuwen.service.query.remote-progress-enabled 之类的开关;
    • 统一语义:内部进度事件只服务于父任务投影(task store),对外出口默认过滤。

6. 附:关键源码位置

行为 位置(third_party/agent-runtime-java/service/agent-service-app
投影帧生成(每次状态迁移) orchestrator/RemoteInvocationBatchCoordinator.project()
RUNNING 帧触发 orchestrator/RemoteInvocationBatchCoordinator.submit()
终态帧触发 + resultCategory 映射 orchestrator/RemoteInvocationBatchCoordinator.applyOutcome() / resultCategory()
HITL 追问帧(REST 第三帧 / A2A 第三事件) orchestrator/A2AEnabledServeOrchestrator.streamBatchResolution()TYPE_INTERRUPT chunk,内容来自 RemoteInvocationBatchCoordinator.publicInterrupt()
REST 出口丢弃 chunk type controller/query/QuerySseSupport.payload() + QueryWebFluxController.streamQuery()
A2A 出口"非 interrupt 一律 artifactUpdate" controller/a2a/A2AAgentExecutor.handleStreamingChunk()
投影 → _remote_invocation part metadata 转写 controller/a2a/ChunkMapper.toParts()
interrupt → statusUpdate(A2A 侧唯一走对通道的) controller/a2a/A2AAgentExecutor.statusMessage() + emitter.requiresInput()

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions