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
- Create a ReActAgent (Supervisor) with enablePendingToolRecovery(true) and at least one SubAgent tool
- 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)
- The Supervisor's memory now has an orphaned ToolUseBlock with no corresponding ToolResultBlock
- Send a new user message in the same session
- 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
- 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)
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
userProvidedResultsto 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
- pendingIds is non-empty ✓
- inputMessages is non-empty ✓
- userProvidedResults = true ← false positive, caused by historical ToolResultBlocks in the snapshot
- Recovery is skipped
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
Environment