远端 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: 帧完全同构,type 被 QuerySseSupport.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 类型走
状态通道(requiresInput → statusUpdate),其余 chunk(包括内部进度投影)一律走
产物通道(addArtifact → artifactUpdate)。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 的问题点
- 状态数据污染产物/业务通道:
/a2a/ 出口:每次远端调用产生 2+ 个伪 artifact(text="RUNNING" 等),
下游按 artifact 收集业务结果的消费者会收到垃圾数据;
/v1/query 出口:投影帧与业务 chunk 同构混杂,客户端必须靠 JSON 形状启发式区分,
且 content 字段被状态字符串占用,极易被误当作 agent 输出文本展示给用户。
- 内部事件泄露且无契约:
remote_agent_progress 注释自述 runtime-internal,
但两个出口都对外暴露;字段无文档、无版本化承诺。
- REST 出口丢失 chunk 类型:
remote_agent_progress / interrupt / chunk 在
/v1/query SSE 中无类型标识(QuerySseSupport.payload() 只取 data),
是错配在 REST 侧被放大的根因。
- 建议方向(供讨论):
/a2a/ 出口:投影帧改走 statusUpdate(status.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() |
远端 A2A 调用进度投影帧的出口错配问题(黑盒分析)
1. 核心结论(TL;DR)
RUNNING/INPUT_REQUIRED这类remote_agent_invocation投影帧,承载的是远端 A2A 任务的状态迁移信息(语义上等价于 A2A 协议的
statusUpdate事件),但框架在输出时把它当作业务数据 chunk 处理:
/v1/queryREST SSE 出口:投影以普通data:业务帧的形式直接混入用户数据流,没有任何类型标识(
QueryChunk.type="remote_agent_progress"在序列化时被丢弃);/a2a/A2A 流式出口:投影被包装成artifactUpdate(产物帧)而非statusUpdate(状态帧)——状态语义的数据出现在了产物通道里。
即:同一内部进度事件,在两个出口都被放进了"数据/产物"通道,而不是它本该属于的
"状态"通道;且在 REST 出口连区分它与业务数据的标识都没有。
2. 观察到的完整帧序列
2.1
/v1/queryREST SSE 出口(expense-review-main,:18097)请求:
实际收到三帧:
帧构成分析:
QueryChunk{type="remote_agent_progress"}进度投影data:帧完全同构,type被QuerySseSupport.payload()丢弃content是裸状态字符串QueryChunk{type="interrupt"}HITL 追问(A2AEnabledServeOrchestrator.streamBatchResolution()发出,内容 =publicInterrupt())type标识注意:第三帧证明续传所需的审批文案与 toolCallId 其实已经在 REST 流里给出了
(此前版本判断"信息未到达客户端"不准确,特此修正);问题集中在第 1、2 帧的出口错配。
2.2
/a2a/A2A 流式出口(SendStreamingMessage)同一链路经 A2A 协议调用时,收到的事件序列:
对照可以清楚看到错配:
artifactUpdate(产物事件)里——每次投影生成一个独立的 artifact,状态字符串成了 artifact 的 text,真正的状态语义藏在 part 的
_remote_invocationmetadata 里;statusUpdate里——这才是 A2A 语义正确的位置。状态语义的数据走了产物通道,产物通道里混着状态数据——下游消费者若按 A2A 规范
以
statusUpdate跟踪任务状态、以artifactUpdate收集业务产物,会被这两条伪 artifact 污染。3. 错配的代码路径
3.1 投影帧的产生(协议无关,main 进程内)
RemoteInvocationBatchCoordinator.project()在 member 状态机每次迁移时生成:该 chunk 是纯内部事件(
QueryChunk注释自述 "Runtime-internal remote member progressused for parent Task projection"),后续如何落地完全取决于出口适配层。
3.2
/v1/queryREST 出口:type 丢弃,混入业务流QuerySseSupport.payload()对所有 chunk 一视同仁地只序列化data,因此remote_agent_progress/interrupt/chunk三种类型在 REST 流里全部失去类型标识,投影帧与业务数据帧在 wire 上无法区分。
3.3
/a2a/出口:统一塞入 artifactUpdate关键在
A2AAgentExecutor.handleStreamingChunk()的分支逻辑:只有interrupt类型走状态通道(
requiresInput→statusUpdate),其余 chunk(包括内部进度投影)一律走产物通道(
addArtifact→artifactUpdate)。ChunkMapper虽然识别了remote_agent_progress并加了_remote_invocationmetadata,但仍然返回 Part,最终依旧落成 artifact。
4. 语义对照:投影帧 vs A2A 事件模型
phase(RUNNING/INPUT_REQUIRED/...)statusUpdate.status.statelatencyMs/sequencestatusUpdate.status.message.metadata或事件 metadataresultCategorystatusUpdate.status.state+ metadatabatchId/toolCallId/targetcontent(裸状态字符串)即投影帧的全部信息在 A2A 模型里都属于
statusUpdate的范畴,没有任何一个字段属于"业务产物"(artifact)范畴。当前的落地方式(REST 业务帧 / A2A artifactUpdate)
与其语义完全错位。
5. 影响与可提 issue 的问题点
/a2a/出口:每次远端调用产生 2+ 个伪 artifact(text="RUNNING" 等),下游按 artifact 收集业务结果的消费者会收到垃圾数据;
/v1/query出口:投影帧与业务 chunk 同构混杂,客户端必须靠 JSON 形状启发式区分,且
content字段被状态字符串占用,极易被误当作 agent 输出文本展示给用户。remote_agent_progress注释自述 runtime-internal,但两个出口都对外暴露;字段无文档、无版本化承诺。
remote_agent_progress/interrupt/chunk在/v1/querySSE 中无类型标识(QuerySseSupport.payload()只取 data),是错配在 REST 侧被放大的根因。
/a2a/出口:投影帧改走statusUpdate(status.state映射 phase,projection 放message/event metadata),或增加开关默认不下发;
/v1/query出口:SSE 序列化保留type(如data: {"type":"remote_agent_progress",...}),或提供
openjiuwen.service.query.remote-progress-enabled之类的开关;6. 附:关键源码位置
third_party/agent-runtime-java/service/agent-service-app)orchestrator/RemoteInvocationBatchCoordinator.project()orchestrator/RemoteInvocationBatchCoordinator.submit()orchestrator/RemoteInvocationBatchCoordinator.applyOutcome()/resultCategory()orchestrator/A2AEnabledServeOrchestrator.streamBatchResolution()(TYPE_INTERRUPTchunk,内容来自RemoteInvocationBatchCoordinator.publicInterrupt())controller/query/QuerySseSupport.payload()+QueryWebFluxController.streamQuery()controller/a2a/A2AAgentExecutor.handleStreamingChunk()_remote_invocationpart metadata 转写controller/a2a/ChunkMapper.toParts()statusUpdate(A2A 侧唯一走对通道的)controller/a2a/A2AAgentExecutor.statusMessage()+emitter.requiresInput()