agentscope-ai / agentscope-ai/agentscope-java

[Bug]: ToolExecutor异常逃逸与PendingToolRecoveryHook失效问题

未關閉
#1,556 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
area/core/agent area/core/tool bug
主要語言
Java
星號
5.6k
分支
1.3k
平均合併
4 天 12 小時
30 天內合併 PR
77

描述

一、问题现象

用户在多轮对话中,某次 SubAgent 工具调用超时后,后续所有请求均报错,会话永久不可用:

// 第 N 轮:工具调用超时
WARN ToolExecutor : Tool call failed: search_agent
java.lang.RuntimeException: Tool execution timeout after PT30M

// 第 N+1 轮及之后:每次新消息必报
ERROR AiAgent : Flux 流异常
java.lang.IllegalStateException: Pending tool calls exist without results.
Pending IDs: [call_pp47mdun8f7kkz4aeka9zoe4]
at io.agentscope.core.ReActAgent.doCall(ReActAgent.java:393)

---
二、根本原因

这是两个框架 Bug 的复合故障,缺一不会触发该现象。

---
Bug 1 — ToolExecutor.executeAll() 超时异常逃逸 onErrorResume

文件:io.agentscope.core.tool.ToolExecutor

预期行为:executeWithInfrastructure() 内部的 onErrorResume 负责将所有工具执行异常(含超时)转为 ToolResultBlock.error(...),确保 Supervisor memory 始终有完整的工具结果。

实际行为:超时异常穿透 onErrorResume,在 executeAll() 的 .toList() 处以原始 Java 异常的形式抛出,导致 Supervisor acting 阶段整体失败,memory 中留下一个没有对应 Result 的孤立 ToolUseBlock。

关键代码(ToolExecutor.java:296-302):

// executeAll() 里用 Stream.toList() 组装 Mono 列表
List> monos =
toolCalls.stream()
.map(toolCall -> executeWithInfrastructure( // ← 若此处同步抛出
toolCall, executionConfig, agent, agentContext))
.toList(); // ← 异常在这里作为原始 Java 异常逃逸,绕过下游 onErrorResume

applyTimeout 在特定 Scheduler 上下文下可能在 Mono 组装期(而非订阅期)同步抛出异常,此时 Reactive 链尚未激活,onErrorResume 完全无效。

后果:Supervisor memory 中产生孤立的 ToolUseBlock(有调用、无结果),会话陷入不一致状态。

---
Bug 2 — PendingToolRecoveryHook 误判跳过修复

文件:io.agentscope.core.hook.PendingToolRecoveryHook

这是 Bug 1 的兜底机制,本应在下一轮请求时检测到孤立 ToolUseBlock 并自动修复。但由于自身存在判断逻辑缺陷,修复从未生效。

问题代码(PendingToolRecoveryHook.java:110-113):

// ❌ BUG:inputMessages = memory 历史快照 + 用户新输入的混合视图
// Supervisor 历史里必然有大量历史 ToolResultBlock,导致 userProvidedResults 永远为 true
boolean userProvidedResults =
inputMessages.stream().anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class));
if (userProvidedResults) {
return Mono.just(event); // ← 永远走这里,patchPendingToolCalls() 从未被调用
}

原因:AgentBase.notifyPreCall() 在构建 PreCallEvent 时,将完整的 memory 历史快照 + 用户新消息合并为一个列表传入:

// AgentBase.java:672-678
List fullInput = new ArrayList<>(snapshot); // 包含所有历史消息(含历史 ToolResultBlock)
if (callArgs != null) {
fullInput.addAll(callArgs);
}
PreCallEvent event = new PreCallEvent(this, fullInput); // 传给所有 Hook

Hook 的本意是判断当前用户输入(callArgs)是否携带了 HITL 手动补充的 tool result。但实际扫描的是混合列表,只要历史里有任何一条 ToolResultBlock(多轮 Supervisor 对话必然存在),userProvidedResults 就永远为 true,修复逻辑被永久短路。

后果:enablePendingToolRecovery(true) 在多轮会话场景下完全失效,ReActAgent.doCall() 必然抛出 IllegalStateException,会话永久不可用。

---
三、故障链路

用户发起请求

Supervisor 调用 SubAgent 工具(search_agent)

【Bug 1】工具执行超时(PT30M)
→ 超时异常逃逸 onErrorResume
→ ToolResultBlock.error() 未写入 memory
→ memory 中留下孤立 ToolUseBlock(无对应 Result)

用户发起下一轮请求

AgentBase.notifyPreCall() 触发 PendingToolRecoveryHook

【Bug 2】Hook 扫描混合 inputMessages(含历史 ToolResultBlock)
→ userProvidedResults 误判为 true
→ patchPendingToolCalls() 跳过,孤立 ToolUseBlock 未修复

ReActAgent.doCall() 检测到 pendingIds 非空

抛出 IllegalStateException
会话永久不可用(直到手动清除 session)

---
四、修复建议

Bug 1 修复

在 executeAll() 的 Mono 组装阶段捕获同步异常,保证任何情况下都能返回合法的 ToolResultBlock:

// ToolExecutor.java:296-302 — 修改后
List> monos =
toolCalls.stream()
.map(toolCall -> {
try {
return executeWithInfrastructure(toolCall, executionConfig, agent, agentContext);
} catch (Exception e) {
logger.warn("Tool call assembly failed: {}", toolCall.getName(), e);
return Mono.just(
ToolResultBlock.error("Tool execution failed: " + e.getMessage())
.withIdAndName(toolCall.getId(), toolCall.getName()));
}
})
.toList();

同时建议排查 applyTimeout 是否存在在 Mono 组装期同步抛出的路径,确保超时错误始终在 Reactive 链内传播。

Bug 2 修复

只检查用户新输入(callArgs 部分),排除 memory 历史快照中的 ASSISTANT/TOOL 消息:

// PendingToolRecoveryHook.java:110-113 — 修改后
// 只有当前轮次用户新输入中携带 ToolResultBlock(HITL 场景),才跳过自动修复
// ASSISTANT / TOOL 角色消息来自 memory 历史,不属于当前轮次用户输入
boolean userProvidedResults = inputMessages.stream()
.filter(m -> m.getRole() != MsgRole.ASSISTANT && m.getRole() != MsgRole.TOOL)
.anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class));

更优雅的方案是在 PreCallEvent 上暴露 snapshotSize 或 getCallArgs() 方法,让 Hook 能精确获取 callArgs 边界,避免依赖角色过滤这一间接手段。

---

貢獻指南

開啟貢獻指南

評估

這個 Issue 還沒有評估資料。

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。