From a6fd7535a201f2d8a42c4382ed2fcddbd44dc66b Mon Sep 17 00:00:00 2001 From: fanyong Date: Sat, 14 Mar 2026 14:36:25 +0800 Subject: [PATCH 1/4] fix: handle tool execution timeout/error causing IllegalStateException (#951) ReActAgent throws IllegalStateException when tool calls timeout or fail, because no tool result is written to memory, leaving orphaned pending tool call states that crash the agent on subsequent requests. Root cause: - Tool execution timeout/error propagates without writing results to memory - Pending tool call state remains, blocking subsequent doCall() invocations - validateAndAddToolResults() throws when user message has no tool results Changes: - doCall(): detect pending tool calls without user-provided results and auto-generate error results to clear the pending state - executeToolCalls(): add onErrorResume to catch tool execution failures and generate error tool results instead of propagating exceptions - Add generateAndAddErrorToolResults() helper to create error results for orphaned pending tool calls This ensures the agent recovers gracefully from tool failures instead of crashing, and the model receives proper error feedback to continue processing. Closes #951 --- .../java/io/agentscope/core/ReActAgent.java | 109 +++++++++++++++++- .../core/hook/HookStopAgentTest.java | 40 +++---- 2 files changed, 125 insertions(+), 24 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 30d25e545..cb3553d56 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -252,9 +252,72 @@ protected Mono doCall(List msgs) { return executeIteration(0); } - // Has pending tools -> validate and add tool results - validateAndAddToolResults(msgs, pendingIds); - return hasPendingToolUse() ? acting(0) : executeIteration(0); + // Has pending tools but no input -> resume (execute pending tools directly) + if (msgs == null || msgs.isEmpty()) { + return hasPendingToolUse() ? acting(0) : executeIteration(0); + } + + // Has pending tools + input -> check if user provided tool results + List providedResults = + msgs.stream() + .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) + .toList(); + + if (!providedResults.isEmpty()) { + // User provided tool results -> validate and add + validateAndAddToolResults(msgs, pendingIds); + return hasPendingToolUse() ? acting(0) : executeIteration(0); + } + + // User sent a new message without tool results -> auto-recover from orphaned pending state + log.warn( + "Pending tool calls detected without results, auto-generating error results." + + " Pending IDs: {}", + pendingIds); + generateAndAddErrorToolResults(pendingIds); + addToMemory(msgs); + return executeIteration(0); + } + + /** + * Generate error tool results for pending tool calls and add them to memory. + * This is used to recover from situations where tool execution failed without + * properly writing results to memory. + * + * @param pendingIds The set of pending tool use IDs + */ + private void generateAndAddErrorToolResults(Set pendingIds) { + Msg lastAssistant = findLastAssistantMsg(); + if (lastAssistant == null) { + return; + } + + List pendingToolCalls = + lastAssistant.getContentBlocks(ToolUseBlock.class).stream() + .filter(toolUse -> pendingIds.contains(toolUse.getId())) + .toList(); + + for (ToolUseBlock toolCall : pendingToolCalls) { + ToolResultBlock errorResult = + ToolResultBlock.builder() + .id(toolCall.getId()) + .output( + List.of( + TextBlock.builder() + .text( + "[ERROR] Previous tool execution failed" + + " or was interrupted. Tool: " + + toolCall.getName()) + .build())) + .build(); + Msg toolResultMsg = + ToolResultMessageBuilder.buildToolResultMsg(errorResult, toolCall, getName()); + memory.addMessage(toolResultMsg); + log.info( + "Auto-generated error result for pending tool call: {} ({})", + toolCall.getName(), + toolCall.getId()); + } } /** @@ -592,6 +655,10 @@ private Msg buildSuspendedMsg(List> pen /** * Execute tool calls and return paired results. * + *

If tool execution fails (timeout, error, etc.), this method generates error tool results + * for all pending tool calls instead of propagating the error. This ensures the agent can + * continue processing and the model receives proper error feedback. + * * @param toolCalls The list of tool calls (potentially modified by PreActingEvent hooks) * @return Mono containing list of (ToolUseBlock, ToolResultBlock) pairs */ @@ -602,7 +669,41 @@ private Mono>> executeToolCalls( results -> IntStream.range(0, toolCalls.size()) .mapToObj(i -> Map.entry(toolCalls.get(i), results.get(i))) - .toList()); + .toList()) + .onErrorResume( + error -> { + // Generate error tool results for all pending tool calls + log.error( + "Tool execution failed, generating error results for {} tool" + + " calls: {}", + toolCalls.size(), + error.getMessage()); + List> errorResults = + toolCalls.stream() + .map( + toolCall -> { + ToolResultBlock errorResult = + ToolResultBlock.builder() + .id(toolCall.getId()) + .output( + List.of( + TextBlock + .builder() + .text( + "[ERROR]" + + " Tool" + + " execution" + + " failed:" + + " " + + error + .getMessage()) + .build())) + .build(); + return Map.entry(toolCall, errorResult); + }) + .toList(); + return Mono.just(errorResults); + }); } /** diff --git a/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java b/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java index 242057d1d..407f501c9 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java @@ -52,7 +52,6 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; /** * Comprehensive tests for the Hook Stop Agent feature. @@ -345,10 +344,15 @@ void testResumeWithToolResultMsg() { } @Test - @DisplayName("New message with pending tool calls throws error") + @DisplayName("New message with pending tool calls auto-recovers") void testNewMsgWithPendingToolUseContinuesActing() { Msg toolUseMsg = createToolUseMsg("tool1", "test_tool", Map.of()); - setupModelToReturnToolUse(toolUseMsg); + Msg textResponse = + createAssistantTextMsg("Recovered after auto-generated error results"); + + when(mockModel.stream(anyList(), anyList(), any())) + .thenReturn(createFluxFromMsg(toolUseMsg)) + .thenReturn(createFluxFromMsg(textResponse)); Hook stopHook = createPostReasoningStopHook(); @@ -368,15 +372,11 @@ void testNewMsgWithPendingToolUseContinuesActing() { result1.hasContentBlocks(ToolUseBlock.class), "First call should return ToolUse message"); - // Send a new regular message - should throw error due to pending tool calls + // Send a new regular message - should auto-recover by generating error results Msg newMsg = createUserMsg("new message"); + Msg result2 = agent.call(newMsg).block(TEST_TIMEOUT); - StepVerifier.create(agent.call(newMsg)) - .expectErrorMatches( - e -> - e instanceof IllegalStateException - && e.getMessage().contains("pending tool calls")) - .verify(); + assertNotNull(result2, "Agent should auto-recover and return a result"); } } @@ -642,10 +642,14 @@ void testNormalCallAfterCompletion() { } @Test - @DisplayName("Agent throws error when adding regular message with pending tool calls") + @DisplayName("Agent auto-recovers when adding regular message with pending tool calls") void testAgentHandlesPendingToolCallsGracefully() { Msg toolUseMsg = createToolUseMsg("tool1", "test_tool", Map.of()); - setupModelToReturnToolUse(toolUseMsg); + Msg textResponse = createAssistantTextMsg("Recovered"); + + when(mockModel.stream(anyList(), anyList(), any())) + .thenReturn(createFluxFromMsg(toolUseMsg)) + .thenReturn(createFluxFromMsg(textResponse)); Hook stopHook = createPostReasoningStopHook(); @@ -661,14 +665,10 @@ void testAgentHandlesPendingToolCallsGracefully() { agent.call(createUserMsg("test")).block(TEST_TIMEOUT); - // With new design, agent will throw error when adding regular message - // with pending tool calls - StepVerifier.create(agent.call(createUserMsg("new"))) - .expectErrorMatches( - e -> - e instanceof IllegalStateException - && e.getMessage().contains("pending tool calls")) - .verify(); + // With new design, agent will auto-recover by generating error results + // for pending tool calls and continue processing + Msg result = agent.call(createUserMsg("new")).block(TEST_TIMEOUT); + assertNotNull(result, "Agent should auto-recover and return a result"); } } From 8773ac3b05d739adb5a99ecf415f9925d254b23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E5=8B=87?= Date: Fri, 27 Mar 2026 16:01:02 +0800 Subject: [PATCH 2/4] refactor(core): improve tool execution error handling in ReActAgent - Extract shared buildErrorToolResult() helper to deduplicate ToolResultBlock construction - Route generateAndAddErrorToolResults() through PostActingEvent hook pipeline for consistent tool-result lifecycle (StreamingHook TOOL_RESULT emission, hook-based transforms) - Narrow onErrorResume catch scope to Exception.class, letting critical JVM errors (e.g. OutOfMemoryError) propagate - Use ExceptionUtils.getErrorMessage() for non-null error messages and log the exception object itself for full stack traces - Strengthen HookStopAgentTest auto-recovery assertions: verify error ToolResultBlock in memory, model re-invocation, and response content --- .../java/io/agentscope/core/ReActAgent.java | 109 ++++++++++-------- .../core/hook/HookStopAgentTest.java | 40 +++++++ 2 files changed, 101 insertions(+), 48 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 1f6a1f9e6..8f44c4068 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -64,6 +64,7 @@ import io.agentscope.core.tool.ToolExecutionContext; import io.agentscope.core.tool.ToolResultMessageBuilder; import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.util.ExceptionUtils; import io.agentscope.core.util.MessageUtils; import java.util.ArrayList; import java.util.Comparator; @@ -276,22 +277,43 @@ protected Mono doCall(List msgs) { "Pending tool calls detected without results, auto-generating error results." + " Pending IDs: {}", pendingIds); - generateAndAddErrorToolResults(pendingIds); - addToMemory(msgs); - return executeIteration(0); + return generateAndAddErrorToolResults(pendingIds) + .then( + Mono.defer( + () -> { + addToMemory(msgs); + return executeIteration(0); + })); + } + + /** + * Build a {@link ToolResultBlock} representing a tool execution error. + * + * @param toolId the id of the tool call that failed + * @param errorMessage the human-readable error description + * @return a {@link ToolResultBlock} containing the formatted error message + */ + private static ToolResultBlock buildErrorToolResult(String toolId, String errorMessage) { + return ToolResultBlock.builder() + .id(toolId) + .output(List.of(TextBlock.builder().text("[ERROR] " + errorMessage).build())) + .build(); } /** - * Generate error tool results for pending tool calls and add them to memory. - * This is used to recover from situations where tool execution failed without - * properly writing results to memory. + * Generate error tool results for pending tool calls and emit them through the + * {@link PostActingEvent} hook pipeline before adding to memory. This ensures consistent + * tool-result lifecycle behavior (including StreamingHook's TOOL_RESULT emission and any + * hook-based sanitization/transform) for auto-recovered error results. * * @param pendingIds The set of pending tool use IDs + * @return Mono that completes when all error results have been processed through hooks and + * added to memory */ - private void generateAndAddErrorToolResults(Set pendingIds) { + private Mono generateAndAddErrorToolResults(Set pendingIds) { Msg lastAssistant = findLastAssistantMsg(); if (lastAssistant == null) { - return; + return Mono.empty(); } List pendingToolCalls = @@ -299,27 +321,26 @@ private void generateAndAddErrorToolResults(Set pendingIds) { .filter(toolUse -> pendingIds.contains(toolUse.getId())) .toList(); - for (ToolUseBlock toolCall : pendingToolCalls) { - ToolResultBlock errorResult = - ToolResultBlock.builder() - .id(toolCall.getId()) - .output( - List.of( - TextBlock.builder() - .text( - "[ERROR] Previous tool execution failed" - + " or was interrupted. Tool: " - + toolCall.getName()) - .build())) - .build(); - Msg toolResultMsg = - ToolResultMessageBuilder.buildToolResultMsg(errorResult, toolCall, getName()); - memory.addMessage(toolResultMsg); - log.info( - "Auto-generated error result for pending tool call: {} ({})", - toolCall.getName(), - toolCall.getId()); - } + if (pendingToolCalls.isEmpty()) { + return Mono.empty(); + } + + return Flux.fromIterable(pendingToolCalls) + .concatMap( + toolCall -> { + ToolResultBlock errorResult = + buildErrorToolResult( + toolCall.getId(), + "Previous tool execution failed or was interrupted." + + " Tool: " + + toolCall.getName()); + log.info( + "Auto-generated error result for pending tool call: {} ({})", + toolCall.getName(), + toolCall.getId()); + return notifyPostActingHook(Map.entry(toolCall, errorResult)); + }) + .then(); } /** @@ -674,34 +695,26 @@ private Mono>> executeToolCalls( .mapToObj(i -> Map.entry(toolCalls.get(i), results.get(i))) .toList()) .onErrorResume( + Exception.class, error -> { - // Generate error tool results for all pending tool calls + // Generate error tool results for all pending tool calls. + // Only catch Exception subclasses; critical JVM errors + // (e.g. OutOfMemoryError) are left to propagate. + String errorMsg = ExceptionUtils.getErrorMessage(error); log.error( "Tool execution failed, generating error results for {} tool" - + " calls: {}", + + " calls", toolCalls.size(), - error.getMessage()); + error); List> errorResults = toolCalls.stream() .map( toolCall -> { ToolResultBlock errorResult = - ToolResultBlock.builder() - .id(toolCall.getId()) - .output( - List.of( - TextBlock - .builder() - .text( - "[ERROR]" - + " Tool" - + " execution" - + " failed:" - + " " - + error - .getMessage()) - .build())) - .build(); + buildErrorToolResult( + toolCall.getId(), + "Tool execution failed: " + + errorMsg); return Map.entry(toolCall, errorResult); }) .toList(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java b/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java index 098427a44..14b3f114a 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java @@ -378,6 +378,46 @@ void testNewMsgWithPendingToolUseContinuesActing() { Msg result2 = agent.call(newMsg).block(TEST_TIMEOUT); assertNotNull(result2, "Agent should auto-recover and return a result"); + + // Verify the model was invoked a second time (the follow-up reasoning call) + verify(mockModel, times(2)).stream(anyList(), anyList(), any()); + + // Verify the follow-up response content is the expected text + assertTrue( + result2.hasContentBlocks(TextBlock.class), + "Recovery result should contain text content"); + String resultText = + result2.getContentBlocks(TextBlock.class).stream() + .map(TextBlock::getText) + .findFirst() + .orElse(""); + assertEquals( + "Recovered after auto-generated error results", + resultText, + "Recovery result should match the model's follow-up response"); + + // Verify that an error ToolResultBlock was written into memory for the + // pending tool call id, proving the pending state was actually cleared + List memoryMsgs = memory.getMessages(); + boolean hasErrorToolResult = + memoryMsgs.stream() + .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) + .anyMatch( + tr -> + "tool1".equals(tr.getId()) + && tr.getOutput().stream() + .anyMatch( + cb -> + cb instanceof TextBlock + && ((TextBlock) + cb) + .getText() + .contains( + "[ERROR]"))); + assertTrue( + hasErrorToolResult, + "Memory should contain an error ToolResultBlock for the pending tool call" + + " id='tool1'"); } } From 8e6e986a494d8de7b0661b0c0f848eceb60e51ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E5=8B=87?= Date: Mon, 30 Mar 2026 17:14:32 +0800 Subject: [PATCH 3/4] fix(core): propagate InterruptedException in executeToolCalls onErrorResume Avoid swallowing InterruptedException in the onErrorResume handler. In AgentScope, InterruptedException is the cooperative interruption signal used by the agent stop policy. Converting it into an error tool result would silently break the interruption mechanism. Now InterruptedException is re-thrown via Mono.error() so it propagates to AgentBase.createErrorHandler() which routes it to handleInterrupt() as intended. --- .../src/main/java/io/agentscope/core/ReActAgent.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 5d412f9e0..c4e4b37b5 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -698,6 +698,10 @@ private Mono>> executeToolCalls( .onErrorResume( Exception.class, error -> { + // Preserve interruption signal for agent stop policy + if (error instanceof InterruptedException) { + return Mono.error(error); + } // Generate error tool results for all pending tool calls. // Only catch Exception subclasses; critical JVM errors // (e.g. OutOfMemoryError) are left to propagate. From 1f98770f2cff066921397b320ff5dfb82d489e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E5=8B=87?= Date: Tue, 31 Mar 2026 16:21:18 +0800 Subject: [PATCH 4/4] refactor(core): extract pending tool recovery into PendingToolRecoveryHook - Extract auto-recovery logic from ReActAgent.doCall() into standalone PendingToolRecoveryHook that intercepts PreCallEvent - Hook detects orphaned pending tool calls and auto-generates error ToolResultBlocks before agent processing begins - Add enablePendingToolRecovery(boolean) Builder option (default: false) - ReActAgent.doCall() now throws IllegalStateException for unresolved pending state when hook is disabled - Remove generateAndAddErrorToolResults() and findLastAssistantMsg() from ReActAgent (logic moved to hook) - Update tests to explicitly enable hook where auto-recovery is needed --- .../java/io/agentscope/core/ReActAgent.java | 90 +++---- .../core/hook/PendingToolRecoveryHook.java | 224 ++++++++++++++++++ .../core/hook/HookStopAgentTest.java | 2 + 3 files changed, 261 insertions(+), 55 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/hook/PendingToolRecoveryHook.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index c4e4b37b5..0539a7a62 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -20,6 +20,7 @@ import io.agentscope.core.hook.ActingChunkEvent; import io.agentscope.core.hook.Hook; import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PendingToolRecoveryHook; import io.agentscope.core.hook.PostActingEvent; import io.agentscope.core.hook.PostReasoningEvent; import io.agentscope.core.hook.PostSummaryEvent; @@ -273,18 +274,14 @@ protected Mono doCall(List msgs) { return hasPendingToolUse() ? acting(0) : executeIteration(0); } - // User sent a new message without tool results -> auto-recover from orphaned pending state - log.warn( - "Pending tool calls detected without results, auto-generating error results." - + " Pending IDs: {}", - pendingIds); - return generateAndAddErrorToolResults(pendingIds) - .then( - Mono.defer( - () -> { - addToMemory(msgs); - return executeIteration(0); - })); + // If PendingToolRecoveryHook is enabled, pending state should have been + // patched during PreCallEvent. If we still reach here, the hook was disabled + // and the user did not provide tool results — this is an unrecoverable state. + throw new IllegalStateException( + "Pending tool calls exist without results. " + + "Enable PendingToolRecoveryHook or provide tool results. " + + "Pending IDs: " + + pendingIds); } /** @@ -301,49 +298,6 @@ private static ToolResultBlock buildErrorToolResult(String toolId, String errorM .build(); } - /** - * Generate error tool results for pending tool calls and emit them through the - * {@link PostActingEvent} hook pipeline before adding to memory. This ensures consistent - * tool-result lifecycle behavior (including StreamingHook's TOOL_RESULT emission and any - * hook-based sanitization/transform) for auto-recovered error results. - * - * @param pendingIds The set of pending tool use IDs - * @return Mono that completes when all error results have been processed through hooks and - * added to memory - */ - private Mono generateAndAddErrorToolResults(Set pendingIds) { - Msg lastAssistant = findLastAssistantMsg(); - if (lastAssistant == null) { - return Mono.empty(); - } - - List pendingToolCalls = - lastAssistant.getContentBlocks(ToolUseBlock.class).stream() - .filter(toolUse -> pendingIds.contains(toolUse.getId())) - .toList(); - - if (pendingToolCalls.isEmpty()) { - return Mono.empty(); - } - - return Flux.fromIterable(pendingToolCalls) - .concatMap( - toolCall -> { - ToolResultBlock errorResult = - buildErrorToolResult( - toolCall.getId(), - "Previous tool execution failed or was interrupted." - + " Tool: " - + toolCall.getName()); - log.info( - "Auto-generated error result for pending tool call: {} ({})", - toolCall.getName(), - toolCall.getId()); - return notifyPostActingHook(Map.entry(toolCall, errorResult)); - }) - .then(); - } - /** * Find the last assistant message in memory. * @@ -1134,6 +1088,7 @@ public static class Builder { private PlanNotebook planNotebook; private SkillBox skillBox; private ToolExecutionContext toolExecutionContext; + private boolean enablePendingToolRecovery = false; // Long-term memory configuration private LongTermMemory longTermMemory; @@ -1272,6 +1227,26 @@ public Builder enableMetaTool(boolean enableMetaTool) { return this; } + /** + * Enables or disables automatic recovery from orphaned pending tool calls. + * + *

When enabled , a {@link PendingToolRecoveryHook} is automatically + * registered to detect and patch orphaned pending tool calls with synthetic error + * results before agent processing begins. This prevents {@link IllegalStateException} + * when tool execution fails, times out, or is interrupted. + * + *

Disable this if you prefer to handle pending tool calls manually, for example + * through HITL (Human-in-the-loop) mechanisms or custom error handling strategies. + * + * @param enable true to enable auto-recovery, false to disable + * @return This builder instance for method chaining + * @see PendingToolRecoveryHook + */ + public Builder enablePendingToolRecovery(boolean enable) { + this.enablePendingToolRecovery = enable; + return this; + } + /** * Sets the execution configuration for model API calls. * @@ -1540,6 +1515,11 @@ public ReActAgent build() { agentToolkit.registerMetaTool(); } + // Register PendingToolRecoveryHook if enabled + if (enablePendingToolRecovery) { + hooks.add(new PendingToolRecoveryHook()); + } + // Configure long-term memory if provided if (longTermMemory != null) { configureLongTermMemory(agentToolkit); diff --git a/agentscope-core/src/main/java/io/agentscope/core/hook/PendingToolRecoveryHook.java b/agentscope-core/src/main/java/io/agentscope/core/hook/PendingToolRecoveryHook.java new file mode 100644 index 000000000..32db968eb --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/hook/PendingToolRecoveryHook.java @@ -0,0 +1,224 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.hook; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.tool.ToolResultMessageBuilder; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook that automatically recovers from orphaned pending tool calls by generating error + * {@link ToolResultBlock}s before the agent processes new input. + * + *

When tool execution fails, times out, or is interrupted, tool call states may remain in + * memory without corresponding results. This hook detects such orphaned pending tool calls at + * {@link PreCallEvent} time and patches them with synthetic error results, allowing the agent + * to continue processing instead of crashing with {@link IllegalStateException}. + * + *

This hook is registered by default in {@link ReActAgent.Builder}. Users can disable it + * via {@link ReActAgent.Builder#enablePendingToolRecovery(boolean)} if they prefer to handle + * pending tool calls manually (e.g., through HITL mechanisms). + * + *

Behavior: + *

    + *
  • Only activates when the agent is a {@link ReActAgent}
  • + *
  • Only patches when pending tool calls exist AND user input does not contain + * {@link ToolResultBlock}s (i.e., user is not providing results themselves)
  • + *
  • Generated error results are added to memory as TOOL-role messages
  • + *
+ * + * @see ReActAgent + * @see PreCallEvent + */ +public class PendingToolRecoveryHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(PendingToolRecoveryHook.class); + + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent preCallEvent) { + @SuppressWarnings("unchecked") + Mono result = (Mono) handlePreCall(preCallEvent); + return result; + } + return Mono.just(event); + } + + @Override + public int priority() { + // High priority — must run before other hooks that depend on memory state + return 10; + } + + /** + * Detect and patch orphaned pending tool calls before agent processing begins. + * + * @param event the PreCallEvent containing agent and input messages + * @return Mono containing the unmodified event after patching is complete + */ + private Mono handlePreCall(PreCallEvent event) { + Agent agent = event.getAgent(); + if (!(agent instanceof ReActAgent reactAgent)) { + return Mono.just(event); + } + + Memory memory = reactAgent.getMemory(); + if (memory == null) { + return Mono.just(event); + } + + // Find pending tool call IDs (tool calls without corresponding results) + Set pendingIds = findPendingToolUseIds(memory); + if (pendingIds.isEmpty()) { + return Mono.just(event); + } + + // Check if user already provided tool results in the input + List inputMessages = event.getInputMessages(); + + // If input is empty/null, the user is resuming (wants to continue acting). + // Do NOT patch — let ReActAgent's doCall handle the resume flow. + if (inputMessages == null || inputMessages.isEmpty()) { + return Mono.just(event); + } + + boolean userProvidedResults = + inputMessages.stream().anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class)); + if (userProvidedResults) { + return Mono.just(event); + } + + // Auto-patch: generate error tool results for orphaned pending tool calls + log.warn( + "Pending tool calls detected without results, auto-generating error results." + + " Pending IDs: {}", + pendingIds); + + patchPendingToolCalls(reactAgent, memory, pendingIds); + return Mono.just(event); + } + + /** + * Find tool call IDs from the last assistant message that have no corresponding + * {@link ToolResultBlock} in memory. + * + * @param memory the agent's memory + * @return set of pending tool use IDs, empty if none + */ + private Set findPendingToolUseIds(Memory memory) { + List messages = memory.getMessages(); + + // Find last assistant message + Msg lastAssistant = null; + for (int i = messages.size() - 1; i >= 0; i--) { + if (messages.get(i).getRole() == MsgRole.ASSISTANT) { + lastAssistant = messages.get(i); + break; + } + } + + if (lastAssistant == null || !lastAssistant.hasContentBlocks(ToolUseBlock.class)) { + return Set.of(); + } + + // Collect all existing tool result IDs in memory + Set existingResultIds = + messages.stream() + .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) + .map(ToolResultBlock::getId) + .collect(Collectors.toSet()); + + // Return tool call IDs that have no result yet + return lastAssistant.getContentBlocks(ToolUseBlock.class).stream() + .map(ToolUseBlock::getId) + .filter(id -> !existingResultIds.contains(id)) + .collect(Collectors.toSet()); + } + + /** + * Generate error {@link ToolResultBlock}s for each pending tool call and add them + * to memory as TOOL-role messages. + * + * @param agent the ReActAgent instance + * @param memory the agent's memory + * @param pendingIds the set of pending tool use IDs to patch + */ + private void patchPendingToolCalls(ReActAgent agent, Memory memory, Set pendingIds) { + List messages = memory.getMessages(); + + // Find last assistant message to get ToolUseBlock details + Msg lastAssistant = null; + for (int i = messages.size() - 1; i >= 0; i--) { + if (messages.get(i).getRole() == MsgRole.ASSISTANT) { + lastAssistant = messages.get(i); + break; + } + } + if (lastAssistant == null) { + return; + } + + List pendingToolCalls = + lastAssistant.getContentBlocks(ToolUseBlock.class).stream() + .filter(toolUse -> pendingIds.contains(toolUse.getId())) + .toList(); + + for (ToolUseBlock toolCall : pendingToolCalls) { + ToolResultBlock errorResult = buildErrorToolResult(toolCall); + Msg toolResultMsg = + ToolResultMessageBuilder.buildToolResultMsg( + errorResult, toolCall, agent.getName()); + memory.addMessage(toolResultMsg); + + log.info( + "Auto-generated error result for pending tool call: {} ({})", + toolCall.getName(), + toolCall.getId()); + } + } + + /** + * Build an error {@link ToolResultBlock} for a failed or orphaned tool call. + * + * @param toolCall the tool call that has no result + * @return a ToolResultBlock containing a formatted error message + */ + private static ToolResultBlock buildErrorToolResult(ToolUseBlock toolCall) { + return ToolResultBlock.builder() + .id(toolCall.getId()) + .output( + List.of( + TextBlock.builder() + .text( + "[ERROR] Previous tool execution failed or was" + + " interrupted. Tool: " + + toolCall.getName()) + .build())) + .build(); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java b/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java index 14b3f114a..d153f98a8 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java @@ -365,6 +365,7 @@ void testNewMsgWithPendingToolUseContinuesActing() { .memory(memory) .checkRunning(false) .hook(stopHook) + .enablePendingToolRecovery(true) .build(); // First call - gets stopped @@ -702,6 +703,7 @@ void testAgentHandlesPendingToolCallsGracefully() { .memory(memory) .checkRunning(false) .hook(stopHook) + .enablePendingToolRecovery(true) .build(); agent.call(createUserMsg("test")).block(TEST_TIMEOUT);