Skip to content

[Bug]: FAILED Task 缺少稳定错误码,Core 结构化错误在 Runtime/A2A 映射中丢失 #90

Description

Checklist

  • 我已经搜索过相关问题,但没有得到预期的帮助。
  • 最新版本中该错误尚未修复。
  • 请注意,如果您提交的Bug描述缺少相应的环境信息和最小可复现的demo,我们将很难复现和解决该问题,从而降低收到反馈的可能性,甚至该问题将被关闭。

🐞 问题详细描述

FAILED Task 缺少稳定错误码,Core 结构化错误在 Runtime/A2A 映射中丢失

结论摘要

当前 Runtime 能在部分执行失败路径中形成 TASK_STATE_FAILED,并把失败文本放入:

Task.status.message.parts[0].text

但该文本主要来自 Throwable#getMessage()QueryChunk 业务文本或固定兜底文案,属于面向人的描述,不是稳定的程序化契约。调用方 Runtime 收到 FAILED Task 后,仅根据 TaskState 统一推导出 REMOTE_BUSINESS_FAILURE,无法区分模型超时、模型限流、配置错误、工具失败、业务拒绝或 Runtime 内部异常。

Core 已有 BaseErrorStatusCode、数值 code、状态名和结构化 toMap()RunnerImpl 也会在异常链中保留 BaseError。但是 Runtime A2AAgentExecutor.failAndDrain() 构造 A2A TaskStatus.message 时只读取 error.getMessage(),Core 的稳定 code/status 在 Core -> Runtime -> A2A 边界丢失。

服务入口能力范围设计 已明确要求 FAILED callback 返回“错误码和失败原因”、failed Task 携带可供客户端程序化判断的信息,并在可形成 Task 的异常路径携带结构化错误 payload。当前代码只满足“失败状态 + 可读文本”,未满足稳定错误码和结构化错误契约。

本 Issue 与 FAILED Task callback 可能丢失 是两个独立问题:

  • callback 丢失 Issue:解决 FAILED 通知是否发出。
  • 本 Issue:解决通知已经发出时,上游能否稳定识别具体失败原因。

1. 场景

1.1 特性与模块

  • 特性:服务入口与 Push Notification
  • Core:
    • BaseError
    • StatusCode
    • RunnerImpl
  • Runtime:
    • A2AAgentExecutor
    • HttpPushNotificationSender
    • A2ARemoteAgentClient
    • RemoteInvocationBatchMapper
  • 使用方:multi-deep-research-demo
    • Deep Research Runtime 调用 Search Runtime
    • Search Task FAILED 后通过 streaming、blocking 或 callback 返回失败
    • Deep Research Runtime 需要决定重试、降级、向父 Agent 返回工具错误或终止任务

1.2 设计要求来源

服务入口能力范围设计

  • 第 2 章 callback 触发范围:FAILED 返回异常状态、错误码和失败原因。
  • 第 5.1.4 节:FAILED 必须通知异常状态、错误码和失败原因。
  • 第 5.1.7 节:failed Task 必须携带可供客户端程序化判断的错误信息。
  • 第 5.1.8 节:可形成 Task 的 handler/runtime exception 应携带结构化错误 payload。

详细设计 当前列出了 JSON-RPC request error code,但没有定义异步 FAILED Task 中稳定业务/执行错误码的具体字段、命名空间、兼容和透传规则。

1.3 验证基线

验证日期:2026-08-08

agent-core-java:    730     @ e0482cd0224b76c6422189a3eb1cf13fe7b52601
agent-runtime-java: develop @ acd12ce7259f66e424a8ee3e9348a6a8fb081004
agent-solution:     common  @ 55226a1d8d4ecd9c76d22e28bf241b7e04c033d6

2. 问题概述

2.1 FAILED text 不是稳定错误契约

当前 FAILED text 可能来自:

路径 text 来源 稳定性问题
非流式执行抛异常 Throwable#getMessage() 文案受异常包装、依赖版本和实现细节影响
Streaming onError Throwable#getMessage() 同上,且可能暴露底层连接信息
QueryChunk.TYPE_ERROR business text、data["error"]、字符串或固定兜底 结构任意,生产者之间没有统一 code 契约
Core 普通 RuntimeException Runner 包装成 Failed to invoke agent / Failed to stream agent 底层具体原因可能只保留在 cause 和日志中
Core BaseError BaseError#getMessage() code/status 仍存在于异常对象,但 Runtime 未写入 A2A Task
SDK InternalError -> FAILED 当前 Task snapshot 可能只有 FAILED state 还叠加 callback 丢失及失败详情丢失问题,见关联 Issue

因此,上游不能依赖 text 做如下判断:

  • 是否可重试;
  • 是否需要换模型或降级;
  • 是否是用户输入/业务拒绝;
  • 是否是限流、超时、服务不可达;
  • 是否应终止父 Task;
  • 是否需要告警到模型、工具、Runtime 或业务 Agent 的不同责任方。

2.2 上游只有粗分类

A2ARemoteAgentClient.resultCategory(TaskState)RemoteInvocationBatchMapper.resultCategory(TaskState) 当前固定映射:

TASK_STATE_FAILED -> REMOTE_BUSINESS_FAILURE

这个分类只说明“远端 Task 失败”,不说明“为什么失败”。例如以下失败都会得到同一个结果:

MODEL_CALL_FAILED
MODEL_SERVICE_CONFIG_ERROR
AGENT_TOOL_EXECUTION_ERROR
REMOTE_AGENT_EXECUTION_TIMEOUT
业务校验拒绝
未知 Runtime 异常

2.3 当前 callback 示例

通过生产 HttpPushNotificationSender.callbackBody() 序列化的 FAILED Task 结构如下:

{
  "jsonrpc": "2.0",
  "notificationId": "notification-failed-example",
  "result": {
    "task": {
      "id": "task-failed-example",
      "contextId": "ctx-failed-example",
      "status": {
        "state": "TASK_STATE_FAILED",
        "message": {
          "role": "ROLE_AGENT",
          "parts": [
            {
              "text": "Agent streaming execution failed"
            }
          ],
          "messageId": "<generated-message-id>"
        },
        "timestamp": "<timestamp>"
      },
      "artifacts": [],
      "history": []
    }
  }
}

payload 中没有独立稳定错误码、错误域、可重试标志或来源错误码。

3. 问题分析

3.1 集成验证与现有测试执行结果

执行现有 Runtime 测试:

mvn -pl service/agent-service-app `
  '-Dtest=A2AAgentExecutorTest#failurePathEmitsFailedStatusWithBusinessError,RemoteAgentAnswerExtractorTest#remoteStatesMapToStableResultCategories' `
  test

结果:

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

测试一 failurePathEmitsFailedStatusWithBusinessError

IllegalStateException("remote boom")
  -> TASK_STATE_FAILED
  -> status.message.parts[0].text == "remote boom"

断言只检查 text,没有 code/metadata 断言。

测试二 remoteStatesMapToStableResultCategories

TASK_STATE_FAILED -> REMOTE_BUSINESS_FAILURE

该测试确认所有远端 FAILED 当前只映射到同一粗分类。

3.2 对照组分析

对照组 生产端已有信息 A2A FAILED Task 上游最终可见信息
Core BaseError(MODEL_CALL_FAILED) status name + numeric code + message + details 只有 message text REMOTE_BUSINESS_FAILURE + text
普通 RuntimeException exception type + message + cause 只有包装后的 message text REMOTE_BUSINESS_FAILURE + text
Runtime 本地调用超时 Runtime 可识别 timeout 不一定形成远端 FAILED callback 本地 mapper 可生成 REMOTE_TIMEOUT
已收到远端 FAILED callback Task state + status text 没有具体 code 固定 REMOTE_BUSINESS_FAILURE

这说明 Runtime 对“本地观察到的 transport/queue 异常”已有一定分类能力,例如 REMOTE_TIMEOUTREMOTE_OVERLOADEDREMOTE_RATE_LIMITEDREMOTE_PROTOCOL_ERRORREMOTE_UNAVAILABLE;但“远端 Agent 自己报告的 FAILED”只剩 REMOTE_BUSINESS_FAILURE,没有远端具体错误码。

3.3 为什么不能解析 text 作为补偿

不应通过包含关系或正则解析 text,原因包括:

  1. Core、Runtime、模型 SDK 和业务 Agent 都可能修改异常文案。
  2. 同类错误可能出现不同语言、不同 provider 文案或不同 cause 包装。
  3. Failed to invoke agent 等泛化文案不包含实际原因。
  4. 底层异常 message 可能包含 URL、文件路径、请求信息或其他敏感内容。
  5. 文案面向人,错误码面向程序,两者生命周期和兼容要求不同。

4. 复现条件

4.1 最小源码级复现

A2AAgentExecutorTest 中让 ServeOrchestrator.query() 抛出:

new IllegalStateException("remote boom")

观察生成的 TaskStatusUpdateEvent

state = TASK_STATE_FAILED
message.parts[0].text = remote boom
message.metadata = null / 无错误结构

随后将该 Task 交给:

RemoteInvocationBatchMapper.callbackOutcome(task)

结果:

remoteState = TASK_STATE_FAILED
resultCategory = REMOTE_BUSINESS_FAILURE
result = remote boom

4.2 Core BaseError 对照

Core 已支持:

BaseError error = ...;
error.getCode();
error.getStatus().name();
error.toMap();

RunnerImpl.wrapRunnerRuntime() 会遍历 cause 链并返回原始 BaseError。将该异常传到 Runtime 后,A2AAgentExecutor.failAndDrain() 仍只调用 getMessage(),最终 callback 中看不到 getCode()getStatus()

4.3 Demo 观察方式

  1. 让 Search Agent 的模型调用产生一个有明确类别的失败,例如模型连接失败、超时或受控 BaseError
  2. Deep Research Runtime 以 callback 模式调用 Search Runtime。
  3. 确认 Search Task 进入 TASK_STATE_FAILED 并成功发送 callback。
  4. 检查 callback body:当前只有 status.message.parts[].text
  5. 检查 Deep Research Runtime 恢复结果:当前 code 为 REMOTE_BUSINESS_FAILURE,无法获得 Search 侧具体错误码。

注意:若进入 InternalError -> FAILED callback 丢失路径,应先按关联 Issue 处理通知丢失,否则本 Issue 的 payload 检查无法进行。

5. 问题代码分析

5.1 Core 已有稳定错误结构

文件:

agent-core-java/src/main/java/com/openjiuwen/core/common/exception/BaseError.java

BaseError 保存:

StatusCode status
int code
Map<String, Object> params
Object details
boolean recoverable
boolean fatal

toMap() 输出:

map.put("code", code);
map.put("status", status.name());
map.put("message", templateMessage);
map.put("params", params);
map.put("raw_message", message);
map.put("details", details);

现有 StatusCode 示例:

REMOTE_AGENT_EXECUTION_TIMEOUT = 110100
REMOTE_AGENT_EXECUTION_ERROR   = 110101
AGENT_TOOL_EXECUTION_ERROR     = 120001
MODEL_CALL_FAILED              = 181001

5.2 Runner 会保留 BaseError

文件:

agent-core-java/src/main/java/com/openjiuwen/core/runner/RunnerImpl.java:935-958
private RuntimeException wrapRunnerRuntime(String message, RuntimeException error) {
    BaseError baseError = findBaseError(error);
    if (baseError != null) {
        return baseError;
    }
    return new RuntimeException(message, error);
}

因此 Core -> Runner 边界没有必然丢失 BaseError code;主要丢失发生在 Runtime -> A2A Task 映射。

5.3 Runtime 只保留 Throwable#getMessage()

文件:

agent-runtime-java/service/agent-service-app/src/main/java/
  com/openjiuwen/service/app/controller/a2a/A2AAgentExecutor.java:316-320
String errorMessage = error.getMessage() == null ? "Agent execution failed" : error.getMessage();
Message message = Message.builder()
    .role(Message.Role.ROLE_AGENT)
    .parts(List.of(new TextPart(errorMessage)))
    .build();
emitter.fail(message);

这里没有读取:

  • BaseError#getCode()
  • BaseError#getStatus()
  • BaseError#isRecoverable()
  • 其他 Runtime 可识别的异常类别

5.4 TYPE_ERROR 仍只有非结构化文本

streamChunkFailure() 按 business text、map["error"]、字符串和固定兜底选择 message,最后包装成 IllegalStateException。即使 QueryChunk.data 原来带有结构化错误字段,当前也只提取文本。

5.5 Sender 和 receiver 没有主动丢字段

HttpPushNotificationSender.callbackBody() 直接序列化完整 TaskA2aPushNotificationCallbackController 也会把 result.task 反序列化为 SDK Task

因此,如果错误结构放在 SDK 支持的 Message.metadata 或其他约定字段中,sender/receiver 原则上可以随 Task 透传。当前问题主要是生产端没有写入、消费端没有读取。

5.6 上游消费端再次降级为粗分类

A2ARemoteAgentClient

status.message.parts -> statusText
TASK_STATE_FAILED    -> REMOTE_BUSINESS_FAILURE

RemoteInvocationBatchMapper.callbackOutcome() 同样只提取 status text,并根据 TaskState 计算 resultCategory

最后父 Agent 得到的工具错误类似:

{
  "ok": false,
  "code": "REMOTE_BUSINESS_FAILURE",
  "message": "remote boom",
  "remoteAgentId": "search-agent"
}

没有远端具体错误码。

6. 问题影响

  1. Deep Research Agent 无法按具体失败类型决定重试、降级、切换模型或终止。
  2. 不同责任域的故障全部表现为 REMOTE_BUSINESS_FAILURE,告警和问题定位不准确。
  3. 调用方只能解析不稳定 text,形成隐式且脆弱的跨 Agent 契约。
  4. Core 已建立的错误码体系无法贯穿 Runtime 和 A2A callback,跨仓能力未真正集成。
  5. 直接返回底层 exception message 存在暴露内部实现、URL、路径或敏感上下文的风险。
  6. 即使修复 FAILED callback 丢失,上游仍无法满足异步失败契约的“程序化判断”要求。

7. 建议修复方案

方案 A:在 TaskStatus.message.metadata 定义 namespaced 错误结构(推荐)

不增加 callback 顶层私有字段,保持 callback 继续承载标准 A2A Task;在 FAILED status message 的 metadata 中增加版本化扩展:

{
  "state": "TASK_STATE_FAILED",
  "message": {
    "role": "ROLE_AGENT",
    "parts": [
      {
        "text": "Model invocation failed"
      }
    ],
    "metadata": {
      "openjiuwen.error": {
        "schemaVersion": "1",
        "code": "MODEL_CALL_FAILED",
        "numericCode": 181001,
        "category": "MODEL",
        "retryable": false,
        "origin": "CORE"
      }
    }
  }
}

约束:

  • text:安全、可读、允许调整的描述。
  • code:稳定的程序化错误码,版本升级不得随文案变化。
  • numericCode:复用 Core code,可选但建议保留。
  • category:错误域,例如 MODELTOOLAGENTRUNTIMEREMOTE
  • retryable:由明确策略生成,不能只按异常类名猜测。
  • origin:错误最初产生层,用于责任定位。
  • 不得把堆栈、密钥、完整请求、内部 URL 等敏感内容放入 callback。

优点:

  • 使用 A2A Message 已有 metadata 扩展点。
  • sender 和 receiver 可以继续原样序列化 Task。
  • 旧客户端忽略 metadata 仍能读取 text。
  • 新客户端可读取稳定结构。

方案 B:定义统一 A2aFailureMapper / FailureDescriptor,避免散落映射

在 Runtime 定义统一映射组件:

Throwable / QueryChunk error
  -> FailureDescriptor
       code
       numericCode
       category
       publicMessage
       retryable
       origin
  -> TaskStatus.message

默认映射至少覆盖:

  1. Core BaseError:保留 status name 和 numeric code。
  2. Runtime 明确异常:映射为 Runtime 自有稳定 code。
  3. 业务 Agent 主动失败:允许业务通过受控接口提供 code 和 public message。
  4. 未识别异常:回退到 AGENT_EXECUTION_FAILED 或等价通用 code,不暴露原始底层 message。
  5. QueryChunk.TYPE_ERROR:优先读取结构化 failure descriptor,旧格式仅作为 text fallback。

该方案应与方案 A 配合,方案 A 定义传输结构,方案 B 统一生产规则。

方案 C:消费端保留“粗分类 + 远端具体错误”两级语义

为了兼容现有 resultCategory,不建议直接删除 REMOTE_BUSINESS_FAILURE。父 Agent 工具结果可调整为:

{
  "ok": false,
  "code": "REMOTE_BUSINESS_FAILURE",
  "message": "Model invocation failed",
  "remoteAgentId": "search-agent",
  "remoteError": {
    "code": "MODEL_CALL_FAILED",
    "numericCode": 181001,
    "category": "MODEL",
    "retryable": false
  }
}

其中:

  • code=REMOTE_BUSINESS_FAILURE 保留现有调用方粗分类兼容性。
  • remoteError.code 保留下游具体失败原因。
  • 本地 transport timeout 等仍使用 REMOTE_TIMEOUTREMOTE_UNAVAILABLE 等本地分类,不伪装成下游业务错误。

A2ARemoteAgentClientRemoteInvocationBatchMapper.callbackOutcome() 应复用同一个错误提取器,保证 streaming、blocking、callback 三种入口得到一致结果。

方案 D:在 详细设计 中补齐错误契约和兼容规则

详细设计 至少需要明确:

  1. FAILED Task 稳定错误结构的 JSON path。
  2. code 命名、数值 code 与错误域的关系。
  3. Core、Runtime、业务 Agent 分别负责生成哪些 code。
  4. text/public message 的脱敏规则。
  5. 未携带新 metadata 的旧 Agent 如何 fallback。
  6. callback、streaming status event、blocking Task 和 GetTask 必须返回一致错误结构。
  7. 上游是否重试必须基于显式字段,而不是解析 text。

8. 建议验收标准

  1. Core BaseError(MODEL_CALL_FAILED) 经 Runtime 转换后,FAILED Task 保留稳定 code/status。
  2. FAILED callback body 同时包含 state、可读 text 和结构化 error metadata。
  3. GetTask、blocking、streaming 和 callback 对同一失败返回一致 error descriptor。
  4. Deep Research Runtime 收到 Search FAILED callback 后,能够同时获得:
    • REMOTE_BUSINESS_FAILURE 粗分类;
    • Search 侧具体 remoteError.code
  5. 未携带新 metadata 的旧 Agent 继续回退为 REMOTE_BUSINESS_FAILURE + text,不破坏兼容性。
  6. 不同 text 但相同 code 的错误被上游识别为同一类型。
  7. 相同 text 但不同 code 的错误不会被误判为同一类型。
  8. callback payload 不包含堆栈、密钥、完整内部 URL 或其他敏感 details。
  9. InternalError -> FAILED 路径修复后也必须使用同一 error descriptor,不得再次形成无 message/code 的 FAILED Task。

9. 建议自动化回归覆盖

建议在正式自动化测试中覆盖:

  1. Search 抛 Core BaseError(MODEL_CALL_FAILED),Deep 收到具体 code。
  2. Search 模型超时,Deep 能读取 retryable/timeout 类 code。
  3. Search 业务主动拒绝,code 与模型/transport 错误不同。
  4. Search 抛未知异常,callback 使用脱敏通用 code,不暴露内部 cause。
  5. 同一错误分别通过 streaming、blocking、callback、GetTask 返回,结构一致。
  6. 老版本 Search 只返回 text 时,Deep 正常 fallback。
  7. 多级 callback:Search 失败 -> Deep 恢复 -> Deep 最终 FAILED callback,错误链不丢失且不重复覆盖。

10. 关联 Issue

  • failed-task-push-notification-may-be-lost.md
    • 解决 InternalError 和 callback config 绑定竞态导致的 FAILED callback 丢失。
    • 其中“保留 FAILED 错误码和失败原因”建议由本 Issue 进一步定义具体契约。
  • callback-url-trust-documentation-mismatch.md
    • 处理 callback URL 信任策略的文档与代码差异,与本 Issue 无直接实现依赖。

11. 证据索引

agent-core-java/src/main/java/com/openjiuwen/core/common/exception/
  BaseError.java:30,57,172,203,213
  StatusCode.java:183,221,223,560

agent-core-java/src/main/java/com/openjiuwen/core/runner/
  RunnerImpl.java:855,879,935-958

agent-runtime-java/service/agent-service-app/src/main/java/com/openjiuwen/service/app/
  controller/a2a/A2AAgentExecutor.java:110-112,143-168,193-205,316-320
  controller/a2a/HttpPushNotificationSender.java:145-150
  controller/a2a/client/A2ARemoteAgentClient.java:352-399,453-463
  orchestrator/RemoteInvocationBatchMapper.java:109-164,262-287

agent-runtime-java/service/agent-service-app/src/test/java/com/openjiuwen/service/app/
  controller/a2a/A2AAgentExecutorTest.java:190-249
  controller/a2a/client/RemoteAgentAnswerExtractorTest.java:114-122,240-256
  orchestrator/RemoteInvocationBatchMapperTest.java:69-82,102-118,169-172

服务入口能力范围设计:
  相关章节:45,119,143,153

详细的环境信息描述

agent-runtime-java: develop

其他辅助信息

版本信息

感谢您的贡献 🎉!

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions