Skip to content

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

Description

@JarvisFans

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/core/agentAgent runtime, pipeline, hooks, planbugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions