fix(core): reconcile dangling tool_use on interrupt before persisting AgentState - #2410
fix(core): reconcile dangling tool_use on interrupt before persisting AgentState#2410Buktal wants to merge 1 commit into
Conversation
… AgentState When an interrupt is signalled between reasoning (which writes the assistant tool_use) and acting (which would produce the tool result), handleInterrupt appended its recovery message without reconciling the pending tool call. The persisted AgentState ended up with a ToolUseBlock that had no matching ToolResultBlock, immediately followed by the recovery message, so the next resumed run inherited an inconsistent context and providers (e.g. MiniMax-M3) returned an empty response. maybePatchPendingToolCalls already existed to synthesize error results, but getPendingToolUseIds only inspects the last assistant message, so once the recovery message became the last assistant message the patch no longer detected the pending call. Fix: in handleInterrupt, synthesize error ToolResultBlocks for pending tool calls before appending the recovery message, reusing the existing getPendingToolUseIds/findLastAssistantMsg/buildErrorToolResult helpers. Unconditional (not gated by enablePendingToolRecovery, which defaults to false) because the dangling tool_use is produced by the framework's own interrupt path and otherwise always corrupts the next resumed run. Closes agentscope-ai#2409
b176524 to
4ef5710
Compare
oss-maintainer
left a comment
There was a problem hiding this comment.
✅ Approved — Excellent diagnosis and fix for #2409.
The root cause analysis is thorough: the interrupt between reasoning and acting leaves a dangling ToolUseBlock, and maybePatchPendingToolCalls can't recover it once the recovery message becomes the last assistant message.
The fix is well-placed — calling synthesizeErrorResultsForPendingToolCalls() before the recovery message is appended ensures the pending tool calls are still detectable via getPendingToolUseIds(). Clean reuse of existing helpers with no new public API surface.
The decision to make this unconditional (not gated by enablePendingToolRecovery) is correct — this is the framework's own interrupt path producing the corruption, not caller-supplied orphans.
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Fixes dangling ToolUseBlock when interrupt is signalled between reasoning and acting phases. Clean, well-targeted fix with comprehensive tests.
Analysis
Problem: When an interrupt arrives after the model emits tool_use blocks (written to context in runPostReasoningPipeline) but before tool execution, the persisted AgentState contains a ToolUseBlock with no matching ToolResultBlock. The next resumed run inherits this inconsistent context and providers may return empty responses.
Fix: synthesizeErrorResultsForPendingToolCalls() is called from handleInterrupt() before the recovery message is appended. It:
- Gets pending tool use IDs via
getPendingToolUseIds() - Finds the last assistant message
- For each pending tool call, synthesizes an error
ToolResultBlockwith an interruption message - Adds the result message to context
Correctness:
- Ordering is critical and correct — must run before recovery message, otherwise
findLastAssistantMsg()won't find the right message - Error message includes tool name for debuggability
- No-op when no pending calls (early return)
Tests: 216-line test class with scripted model, interrupt signalling, and verification of synthesized error results. Covers the exact race condition described in #2409.
CI note: Build was cancelled (not a code failure). May need a re-run.
Verdict
Addresses a real consistency bug in interrupt recovery. Well-tested. Approved.
AgentScope-Java Version
2.0.1-SNAPSHOT (agentscope-core, current dev)
Description
Background
Closes #2409.
When an interrupt is signalled between the reasoning step (the model has just emitted
ToolUseBlocks, which are written into the context atrunPostReasoningPipeline) and the acting step (tool execution, which would produce the matchingToolResultBlocks),handleInterruptappended its recovery message without reconciling the pending tool call.The persisted
AgentStateended up inconsistent:On the next resumed run, the model received an assistant
tool_usewith no matchingtoolresult, immediately followed by the recovery message, and providers (observed on MiniMax-M3) returned an empty response (completion_tokens = 0) — the agent stopped replying.The framework already had
maybePatchPendingToolCalls(indoCallInner) to synthesize error results, butgetPendingToolUseIdsonly inspects the last assistant message. Once the recovery message became the last assistant message, the patch no longer detected the pending call, so the corruption survived into the next run.Changes
ReActAgent#handleInterrupt: before appending the recovery message, call a newCallExecution#synthesizeErrorResultsForPendingToolCalls()to synthesize an errorToolResultBlockfor each pending tool call that has no matching result.getPendingToolUseIds/findLastAssistantMsg/buildErrorToolResult/ToolResultMessageBuilder.buildToolResultMsg— no new dependency, no new public API.After the fix, an interrupt persists a self-consistent context:
so the next resumed run receives a protocol-compliant history and replies normally.
Why unconditional (not gated by
enablePendingToolRecovery)enablePendingToolRecoverydefaults tofalse. The danglingtool_usehere is produced by the framework's own interrupt path (reasoning writes it; the interrupt then skips acting), not by caller-supplied orphaned tool calls. Gating the fix on that flag would leave the default configuration broken, defeating the purpose of the fix.How to test
ReActAgentInterruptToolCallTest:interruptAfterToolUseSynthesizesErrorResultAndKeepsContextConsistent: a middleware interrupts right afterModelCallEndEvent(simulating post-generation quota exhaustion); asserts the persisted context contains a matching ERRORToolResultBlockfor the pendingToolUseBlockand that the last assistant message is the recovery message.resumedRunAfterInterruptRepliesNormally: the second call resumes from the reconciled context and replies with normal text (no empty response).mvn test -pl agentscope-core: 2220 tests, 0 failures, 0 errors (8 pre-existing skipped).Follow-up (out of scope)
getPendingToolUseIdsonly inspecting the last assistant message is a latent blind spot for any path that appends an assistant message after a tool-bearing one. This PR addresses the interrupt path deterministically; a separate issue can generalize pending detection to scan all assistant messages.Checklist
mvn spotless:applymvn test)