Skip to content

fix(core): reconcile dangling tool_use on interrupt before persisting AgentState - #2410

Open
Buktal wants to merge 1 commit into
agentscope-ai:mainfrom
Buktal:fix/handle-interrupt-dangling-tool-use
Open

fix(core): reconcile dangling tool_use on interrupt before persisting AgentState#2410
Buktal wants to merge 1 commit into
agentscope-ai:mainfrom
Buktal:fix/handle-interrupt-dangling-tool-use

Conversation

@Buktal

@Buktal Buktal commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 at runPostReasoningPipeline) and the acting step (tool execution, which would produce the matching ToolResultBlocks), handleInterrupt appended its recovery message without reconciling the pending tool call.

The persisted AgentState ended up inconsistent:

[..., user, assistant(ToolUseBlock), assistant("I noticed that you have interrupted me...")]
                  ^^^ no matching ToolResultBlock ^^^

On the next resumed run, the model received an assistant tool_use with no matching tool result, 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 (in doCallInner) to synthesize error results, but getPendingToolUseIds only 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 new CallExecution#synthesizeErrorResultsForPendingToolCalls() to synthesize an error ToolResultBlock for each pending tool call that has no matching result.
  • The new helper reuses the existing getPendingToolUseIds / findLastAssistantMsg / buildErrorToolResult / ToolResultMessageBuilder.buildToolResultMsg — no new dependency, no new public API.

After the fix, an interrupt persists a self-consistent context:

[..., user, assistant(ToolUseBlock), tool_result(ERROR), assistant("I noticed...")]

so the next resumed run receives a protocol-compliant history and replies normally.

Why unconditional (not gated by enablePendingToolRecovery)

enablePendingToolRecovery defaults to false. The dangling tool_use here 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

  • New unit test ReActAgentInterruptToolCallTest:
    • interruptAfterToolUseSynthesizesErrorResultAndKeepsContextConsistent: a middleware interrupts right after ModelCallEndEvent (simulating post-generation quota exhaustion); asserts the persisted context contains a matching ERROR ToolResultBlock for the pending ToolUseBlock and 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)

getPendingToolUseIds only 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

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (N/A — internal fix, no public API change)
  • Code is ready for review

… 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
@Buktal
Buktal force-pushed the fix/handle-interrupt-dangling-tool-use branch from b176524 to 4ef5710 Compare July 27, 2026 09:34

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Gets pending tool use IDs via getPendingToolUseIds()
  2. Finds the last assistant message
  3. For each pending tool call, synthesizes an error ToolResultBlock with an interruption message
  4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Mid-cycle interrupt leaves a dangling tool_use in AgentState, breaking the next resumed run

2 participants