Skip to content

[BUG]: REST /v1/query 续传定位能力缺失:仅 conversation_id 隐式定位,多挂起并行定向续传必须走 A2A #95

Description

一句话

REST /v1/query 不具备 A2A 那种"靠 taskId + toolCallId 精确定位 task 触发续轮"的能力:它只能用 conversation_id 作为 parentTaskId 的回退隐式定位,仅覆盖"单个远端工具挂起"的隐式答复关联;多挂起并行远端委派的定向续传在 DTO、metadata 适配、coordinator 约束三层都缺位,客户端被迫改走 A2A。

背景与影响范围

  • 受影响接口:REST POST /v1/queryPOST /v1/query/reactive(及兼容路径 /query),含流式与非流式。
  • 不受影响:A2A JSON-RPC(SendMessage/SendStreamingMessage,靠 taskId + toolCallId 定向恢复,可靠)。
  • /v1/query 并发能力的关系/v1/query 本身支持并发(与 A2A 共用同一个 A2AEnabledServeOrchestrator 单例与 RemoteInvocationBatchCoordinatormaxConcurrency=16);本缺陷不是"不支持并发",而是"并发产生的多挂起中断无法在 REST 跨请求定向续传"。
  • 严重度:高。REST 客户端遇到多并行远端工具同时 INPUT_REQUIRED 时无法分别答复,会落入 REMOTE_TOOL_INPUT_TARGET_REQUIRED 错误,整条续传链路断开。

问题详述

1. A2A 是怎么做到定向续传的(REST 缺的就是这层)

A2A 入站的 A2AProtocolAdapter.trustedMetadata 做了两件 REST 没做的事——把客户端回带的 taskIdtoolCallId 映射成 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;
}
  • taskIdruntime.parentTaskId → resume 时 parentTaskId(request) 命中 shadow task(key = shadow:{agentId}:{taskId})。
  • 每个 TextPart 的 metadata.toolCallId 聚合成 runtime.remoteToolInputsextractText :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.javaServeRequest.fromQueryRequestServeRequest.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 这条通道——答案是没有。

复现/证据清单

影响

  1. REST 多并行挂起场景断链:并发扇出产生多个远端工具同时 INPUT_REQUIRED 时,REST 客户端无法分别答复,落入 REMOTE_TOOL_INPUT_TARGET_REQUIRED,整条续传链路断开,必须改协议走 A2A。
  2. 协议能力不对称:同一 orchestrator 单例下,A2A 有完整定向续传通道、REST 没有,REST 客户端在多挂起场景成为二等公民。
  3. 隐性约束未在 DTO 暴露QueryRequest 既无字段承载定向输入,也无错误指引,客户端只能靠文档/试错发现"多挂起必须走 A2A"。

修复方向

最小改动是给 REST 补齐"toolCallId 定向输入"通道(runtime.parentTaskId 可继续由 conversation_id 兜底,无需新字段):

  1. DTO:给 QueryRequest 增加可选字段(如 tool_call_inputsMap<toolCallId, 文本> 或与 A2A 对齐的 parts[].metadata.toolCallId 风格)。
  2. Metadata 适配:在 QueryIngressSupport 加一个 REST 版 trustedMetadata,把该字段映射到 runtime.remoteToolInputs,并像 A2A 一样清理客户端伪造的 runtime.* 保留键。
  3. 无需改 coordinatorresumeWaitingBatch 已支持 targetedInputs 非空的多挂起定向续传(A2A 路径已验证),前两层补齐后 REST 自然走通。

batchId/taskId 显式回带非必需conversation_id 作为 parentTaskId 回退已能定位 shadow task,单父单批次约束下足够。若未来要放开"同一会话多活跃 batch"(见姊妹问题),才需引入显式 batchId/taskId 通道。

需评估:新增 QueryRequest 字段属公开 API 变更,需考虑向后兼容(字段可选、缺省时行为不变)。

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions