From a6fd7535a201f2d8a42c4382ed2fcddbd44dc66b Mon Sep 17 00:00:00 2001 From: fanyong Date: Sat, 14 Mar 2026 14:36:25 +0800 Subject: [PATCH 1/2] 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/2] 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'"); } }