agentscope-ai / agentscope-ai/agentscope-java

[Bug]: PendingToolRecoveryHook always skips recovery for multi-turn ReActAgent (Supervisor) due to false positive userProvidedResults check

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

描述

---
Summary

PendingToolRecoveryHook is supposed to auto-patch orphaned pending tool calls before ReActAgent.doCall() runs. However, in a Supervisor + SubAgent multi-turn scenario, it never actually patches anything — because its guard condition incorrectly treats
historical ToolResultBlocks in memory as "user-provided results", and skips the recovery entirely. This causes ReActAgent.doCall() to always throw IllegalStateException when the Supervisor has orphaned pending tool calls.

---
Exception

java.lang.IllegalStateException: Pending tool calls exist without results.
Enable PendingToolRecoveryHook or provide tool results.
Pending IDs: [call_pp47mdun8f7kkz4aeka9zoe4]
at io.agentscope.core.ReActAgent.doCall(ReActAgent.java:393)

---
Root Cause

File: agentscope-core/src/main/java/io/agentscope/core/hook/PendingToolRecoveryHook.java
Lines: 110–113

// ❌ BUG: inputMessages = memory snapshot (full history) + callArgs (new user input)
// For a Supervisor agent, the memory snapshot always contains historical ToolResultBlocks
// from previous successful sub-agent calls, causing `userProvidedResults` to be always true.
boolean userProvidedResults =
inputMessages.stream().anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class));
if (userProvidedResults) {
return Mono.just(event); // ← always skips recovery — never reaches patchPendingToolCalls()
}

Why inputMessages contains historical messages:

In AgentBase.notifyPreCall() (lines 672–678), the PreCallEvent is built with a merged view of the full memory snapshot + the new callArgs:

// AgentBase.java:672-678
List fullInput = new ArrayList<>(snapshot); // full conversation history
if (callArgs != null) {
fullInput.addAll(callArgs);
}
PreCallEvent event = new PreCallEvent(this, fullInput); // passed to all hooks

The intent of the guard in PendingToolRecoveryHook is to detect whether the current user input contains ToolResultBlocks (HITL scenario — user manually provides tool results). However, the check scans the entire inputMessages list, which includes the
memory snapshot. Any prior successful tool call in history contains a ToolResultBlock, so userProvidedResults is always true for any Supervisor with conversation history, and the recovery is always skipped.

---
Reproduction Scenario

1. Create a ReActAgent (Supervisor) with enablePendingToolRecovery(true) and at least one SubAgent tool
2. Trigger a request where the Supervisor calls a SubAgent tool, but the SubAgent execution is interrupted or throws an exception (e.g. timeout, network error, stream disconnect)
3. The Supervisor's memory now has an orphaned ToolUseBlock with no corresponding ToolResultBlock
4. Send a new user message in the same session
5. PendingToolRecoveryHook.handlePreCall() is invoked:
- pendingIds is non-empty ✓
- inputMessages is non-empty ✓
- userProvidedResults = true ← false positive, caused by historical ToolResultBlocks in the snapshot
- Recovery is skipped
6. ReActAgent.doCall() detects pendingIds is still non-empty → throws IllegalStateException

---
Fix

The check should only inspect the new user input (i.e., messages beyond the memory snapshot boundary), not the full merged inputMessages. There are two clean approaches:

Option A — Filter by message role (exclude ASSISTANT/TOOL history):

// PendingToolRecoveryHook.java:110-113
// ✅ Only check messages that are genuinely new user input, not historical assistant/tool messages
boolean userProvidedResults = inputMessages.stream()
.filter(m -> m.getRole() != MsgRole.ASSISTANT && m.getRole() != MsgRole.TOOL)
.anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class));

Option B — Expose snapshotSize on PreCallEvent and only check the tail:

// PreCallEvent: add getSnapshotSize() or getCallArgs()
// PendingToolRecoveryHook:
int snapshotSize = event.getSnapshotSize();
List callArgs = inputMessages.subList(snapshotSize, inputMessages.size());
boolean userProvidedResults = callArgs.stream()
.anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class));

Option A is the minimal, non-breaking fix. Option B is cleaner and makes the intent more explicit.

---
Impact

- Severity: High — PendingToolRecoveryHook is silently non-functional for all ReActAgent instances with conversation history
- Affected scenarios: Any multi-turn Supervisor + SubAgent setup where a SubAgent call is interrupted (timeout, exception, stream disconnect)
- enablePendingToolRecovery(true) has no effect in practice for these cases — the agent always crashes on the next user message

---
Environment

- agentscope-core (agentscope-java)
- Affected classes:
- io.agentscope.core.hook.PendingToolRecoveryHook — line 110
- io.agentscope.core.agent.AgentBase#notifyPreCall — line 672 (context)

貢獻指南

開啟貢獻指南

評估

這個 Issue 還沒有評估資料。

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

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