一句话
REST /v1/query 不具备 A2A 那种"靠 taskId + toolCallId 精确定位 task 触发续轮"的能力:它只能用 conversation_id 作为 parentTaskId 的回退隐式定位,仅覆盖"单个远端工具挂起"的隐式答复关联;多挂起并行远端委派的定向续传在 DTO、metadata 适配、coordinator 约束三层都缺位,客户端被迫改走 A2A。
背景与影响范围
- 受影响接口:REST
POST /v1/query、POST /v1/query/reactive(及兼容路径 /query),含流式与非流式。
- 不受影响:A2A JSON-RPC(
SendMessage/SendStreamingMessage,靠 taskId + toolCallId 定向恢复,可靠)。
- 与
/v1/query 并发能力的关系:/v1/query 本身支持并发(与 A2A 共用同一个 A2AEnabledServeOrchestrator 单例与 RemoteInvocationBatchCoordinator,maxConcurrency=16);本缺陷不是"不支持并发",而是"并发产生的多挂起中断无法在 REST 跨请求定向续传"。
- 严重度:高。REST 客户端遇到多并行远端工具同时
INPUT_REQUIRED 时无法分别答复,会落入 REMOTE_TOOL_INPUT_TARGET_REQUIRED 错误,整条续传链路断开。
问题详述
1. A2A 是怎么做到定向续传的(REST 缺的就是这层)
A2A 入站的 A2AProtocolAdapter.trustedMetadata 做了两件 REST 没做的事——把客户端回带的 taskId 和 toolCallId 映射成 coordinator 能消费的 metadata:
// service/agent-service-app/.../controller/a2a/A2AProtocolAdapter.java:112-125
private static Map<String, Object> trustedMetadata(A2AMessageContext ctx, Map<String, String> targetedInputs) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (ctx.getMetadata() != null) {
metadata.putAll(ctx.getMetadata());
}
RESERVED_METADATA_KEYS.forEach(metadata::remove); // :117 先剥离客户端伪造
if (ctx.getTaskId() != null && !ctx.getTaskId().isBlank()) {
metadata.put(PARENT_TASK_ID, ctx.getTaskId()); // :119 taskId → runtime.parentTaskId
}
if (!targetedInputs.isEmpty()) {
metadata.put(REMOTE_TOOL_INPUTS, targetedInputs); // :122 toolCallId→文本 → runtime.remoteToolInputs
}
return metadata;
}
taskId → runtime.parentTaskId → resume 时 parentTaskId(request) 命中 shadow task(key = shadow:{agentId}:{taskId})。
- 每个 TextPart 的
metadata.toolCallId 聚合成 runtime.remoteToolInputs(extractText :94-108 按 toolCallId 分组拼接),让 resumeWaitingBatch 知道哪段文本给哪个工具。
2. REST 的三层缺口
| 层 |
缺口 |
证据 |
| DTO |
QueryRequest 字段集合只有 conversation_id/messages/message/user_id/space_id/tenant_id/stream,无 taskId/batchId/tool_call_id/interactiveInput/resume 等任何续传定位字段 |
QueryRequest.java;ServeRequest.fromQueryRequest(ServeRequest.java:42-52)不写任何 metadata |
| Metadata 适配 |
REST 无 trustedMetadata 等价物。QueryIngressSupport.buildMetadata 只塞 headers/query/path/body,从不写 runtime.parentTaskId/runtime.remoteToolInputs。即便客户端在 body 塞了 runtime.*,也只落到 metadata.body[...],不会被提升,orchestrator 端 parentTaskId(request) 永远拿不到 |
QueryIngressSupport.java:100-117;全仓 runtime.parentTaskId/runtime.remoteToolInputs 写入点只有 A2AProtocolAdapter(入站)+ RemoteInvocationBatchCoordinator/buildBatchResumeRequest(内部),controller 层零写入 |
| Coordinator 约束 |
resumeWaitingBatch 在"多挂起 + 空 targetedInputs"时硬抛 REMOTE_TOOL_INPUT_TARGET_REQUIRED,即使前两层补齐了 DTO,也必须把 toolCallId 传到 runtime.remoteToolInputs 才能放行 |
RemoteInvocationBatchCoordinator.java:268-271 |
注:tryResumePending 本身不是缺口——它对 REST 入站请求照样执行,能命中 shadow 任务。问题是 REST 供不出定向输入。
3. REST 实际的续传边界
REST 续传请求确实经过 tryResumePending(stream)/ syncResumePending(query),因为 /v1/query 与 A2A 共用同一个 orchestrator 单例。但 parentTaskId(request) 拿不到 runtime.parentTaskId,退化为 conversationId:
// RemoteInvocationBatchCoordinator.java:595-601
private static String parentTaskId(ServeRequest request) {
Object value = request.getMetadata().get(PARENT_TASK_ID);
if (value instanceof String parentTaskId && !parentTaskId.isBlank()) {
return parentTaskId;
}
return request.getConversationId(); // ← REST 落到这里
}
shadow key 变成 shadow:{agentId}:{conversationId},只要复用同一 conversation_id 就能找回。但 targetedInputs(request) 对 REST 永远返回空(runtime.remoteToolInputs 缺失)。由此续传能力分场景:
| 场景 |
REST 是否可续轮 |
机制 |
| 单挂起远端工具 |
✅ 可以 |
复用 conversation_id 命中 shadow,用 ServeRequest.lastUserQuery() 当该工具输入(RemoteInvocationBatchCoordinator.java:272) |
| READY_TO_RESUME(远端已完成、等核心 resume) |
✅ 可以 |
orchestrator 内部驱动,对 REST 透明,客户端无需做任何事 |
| 多挂起并行远端定向续传 |
❌ 不行 |
落 REMOTE_TOOL_INPUT_TARGET_REQUIRED,必须走 A2A taskId + toolCallId |
| 客户端显式指定 batchId/taskId 触发某批次 |
❌ 不行 |
无该通道 |
4. 文档佐证
接口契约文档已明文承认这一边界:
REST QueryRequest 没有公开的 taskId 字段。Runtime 使用 conversation_id 和保存的会话状态恢复 REST 对话。对于只有一个待输入工具的远端委派,普通答复即可关联;需要分别回答多个并行远端工具时,应使用下文的 A2A taskId + toolCallId 定向恢复格式。
—— 对话接口输入与输出.md:170
客户端检查表:
REST 多轮请求始终复用 conversation_id;不要向 REST Query 请求添加 A2A taskId。
—— 对话接口输入与输出.md:546
5. 澄清:batchId 不是客户端回带项
A2A 续传时客户端实际回带的是 taskId(多工具时再加 toolCallId),batchId 并非客户端回带——它是服务端内部标识,落定时存进 shadow task,resume 时按 parentTaskId(=taskId)找回并重建。所以本缺陷的本质是:REST 有没有 taskId / toolCallId 这条通道——答案是没有。
复现/证据清单
影响
- REST 多并行挂起场景断链:并发扇出产生多个远端工具同时
INPUT_REQUIRED 时,REST 客户端无法分别答复,落入 REMOTE_TOOL_INPUT_TARGET_REQUIRED,整条续传链路断开,必须改协议走 A2A。
- 协议能力不对称:同一 orchestrator 单例下,A2A 有完整定向续传通道、REST 没有,REST 客户端在多挂起场景成为二等公民。
- 隐性约束未在 DTO 暴露:
QueryRequest 既无字段承载定向输入,也无错误指引,客户端只能靠文档/试错发现"多挂起必须走 A2A"。
修复方向
最小改动是给 REST 补齐"toolCallId 定向输入"通道(runtime.parentTaskId 可继续由 conversation_id 兜底,无需新字段):
- DTO:给
QueryRequest 增加可选字段(如 tool_call_inputs:Map<toolCallId, 文本> 或与 A2A 对齐的 parts[].metadata.toolCallId 风格)。
- Metadata 适配:在
QueryIngressSupport 加一个 REST 版 trustedMetadata,把该字段映射到 runtime.remoteToolInputs,并像 A2A 一样清理客户端伪造的 runtime.* 保留键。
- 无需改 coordinator:
resumeWaitingBatch 已支持 targetedInputs 非空的多挂起定向续传(A2A 路径已验证),前两层补齐后 REST 自然走通。
batchId/taskId 显式回带非必需:conversation_id 作为 parentTaskId 回退已能定位 shadow task,单父单批次约束下足够。若未来要放开"同一会话多活跃 batch"(见姊妹问题),才需引入显式 batchId/taskId 通道。
需评估:新增 QueryRequest 字段属公开 API 变更,需考虑向后兼容(字段可选、缺省时行为不变)。
一句话
REST
/v1/query不具备 A2A 那种"靠taskId+toolCallId精确定位 task 触发续轮"的能力:它只能用conversation_id作为parentTaskId的回退隐式定位,仅覆盖"单个远端工具挂起"的隐式答复关联;多挂起并行远端委派的定向续传在 DTO、metadata 适配、coordinator 约束三层都缺位,客户端被迫改走 A2A。背景与影响范围
POST /v1/query、POST /v1/query/reactive(及兼容路径/query),含流式与非流式。SendMessage/SendStreamingMessage,靠taskId+toolCallId定向恢复,可靠)。/v1/query并发能力的关系:/v1/query本身支持并发(与 A2A 共用同一个A2AEnabledServeOrchestrator单例与RemoteInvocationBatchCoordinator,maxConcurrency=16);本缺陷不是"不支持并发",而是"并发产生的多挂起中断无法在 REST 跨请求定向续传"。INPUT_REQUIRED时无法分别答复,会落入REMOTE_TOOL_INPUT_TARGET_REQUIRED错误,整条续传链路断开。问题详述
1. A2A 是怎么做到定向续传的(REST 缺的就是这层)
A2A 入站的
A2AProtocolAdapter.trustedMetadata做了两件 REST 没做的事——把客户端回带的taskId和toolCallId映射成 coordinator 能消费的 metadata:taskId→runtime.parentTaskId→ resume 时parentTaskId(request)命中 shadow task(key =shadow:{agentId}:{taskId})。metadata.toolCallId聚合成runtime.remoteToolInputs(extractText:94-108 按 toolCallId 分组拼接),让resumeWaitingBatch知道哪段文本给哪个工具。2. REST 的三层缺口
QueryRequest字段集合只有conversation_id/messages/message/user_id/space_id/tenant_id/stream,无taskId/batchId/tool_call_id/interactiveInput/resume等任何续传定位字段ServeRequest.fromQueryRequest(ServeRequest.java:42-52)不写任何 metadatatrustedMetadata等价物。QueryIngressSupport.buildMetadata只塞headers/query/path/body,从不写runtime.parentTaskId/runtime.remoteToolInputs。即便客户端在 body 塞了runtime.*,也只落到metadata.body[...],不会被提升,orchestrator 端parentTaskId(request)永远拿不到runtime.parentTaskId/runtime.remoteToolInputs写入点只有A2AProtocolAdapter(入站)+RemoteInvocationBatchCoordinator/buildBatchResumeRequest(内部),controller 层零写入resumeWaitingBatch在"多挂起 + 空 targetedInputs"时硬抛REMOTE_TOOL_INPUT_TARGET_REQUIRED,即使前两层补齐了 DTO,也必须把 toolCallId 传到runtime.remoteToolInputs才能放行3. REST 实际的续传边界
REST 续传请求确实经过
tryResumePending(stream)/syncResumePending(query),因为/v1/query与 A2A 共用同一个 orchestrator 单例。但parentTaskId(request)拿不到runtime.parentTaskId,退化为conversationId:shadow key 变成
shadow:{agentId}:{conversationId},只要复用同一conversation_id就能找回。但targetedInputs(request)对 REST 永远返回空(runtime.remoteToolInputs缺失)。由此续传能力分场景:ServeRequest.lastUserQuery()当该工具输入(RemoteInvocationBatchCoordinator.java:272)REMOTE_TOOL_INPUT_TARGET_REQUIRED,必须走 A2AtaskId + toolCallId4. 文档佐证
接口契约文档已明文承认这一边界:
客户端检查表:
5. 澄清:batchId 不是客户端回带项
A2A 续传时客户端实际回带的是
taskId(多工具时再加toolCallId),batchId并非客户端回带——它是服务端内部标识,落定时存进 shadow task,resume 时按parentTaskId(=taskId)找回并重建。所以本缺陷的本质是:REST 有没有taskId/toolCallId这条通道——答案是没有。复现/证据清单
fromQueryRequest不写 metadata);ServeRequest.java:59-61(lastUserQuery作为 REST 唯一续传输入载体)。trustedMetadata)。resume);:268-271(多挂起硬抛REMOTE_TOOL_INPUT_TARGET_REQUIRED);:595-601(parentTaskId退化为 conversationId);:591-593(shadow key);:603-611(targetedInputs对 REST 永空)。tryResumePending,REST 可达);:366-382(syncResumePending);:506-521(buildBatchResumeRequest,内部写runtime.remoteBatchId/runtime.remoteToolResults)。影响
INPUT_REQUIRED时,REST 客户端无法分别答复,落入REMOTE_TOOL_INPUT_TARGET_REQUIRED,整条续传链路断开,必须改协议走 A2A。QueryRequest既无字段承载定向输入,也无错误指引,客户端只能靠文档/试错发现"多挂起必须走 A2A"。修复方向
最小改动是给 REST 补齐"toolCallId 定向输入"通道(
runtime.parentTaskId可继续由 conversation_id 兜底,无需新字段):QueryRequest增加可选字段(如tool_call_inputs:Map<toolCallId, 文本>或与 A2A 对齐的parts[].metadata.toolCallId风格)。QueryIngressSupport加一个 REST 版trustedMetadata,把该字段映射到runtime.remoteToolInputs,并像 A2A 一样清理客户端伪造的runtime.*保留键。resumeWaitingBatch已支持targetedInputs非空的多挂起定向续传(A2A 路径已验证),前两层补齐后 REST 自然走通。需评估:新增
QueryRequest字段属公开 API 变更,需考虑向后兼容(字段可选、缺省时行为不变)。