Skip to content

fix: handle tool execution timeout/error causing IllegalStateExceptio…#956

Open
chensk0601 wants to merge 1 commit intoagentscope-ai:mainfrom
chensk0601:fix/951-react-agent-tool-execution-error-handling
Open

fix: handle tool execution timeout/error causing IllegalStateExceptio…#956
chensk0601 wants to merge 1 commit intoagentscope-ai:mainfrom
chensk0601:fix/951-react-agent-tool-execution-error-handling

Conversation

@chensk0601
Copy link

…n (#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

AgentScope-Java Version

[The version of AgentScope-Java you are working on, e.g. 1.0.9, check your pom.xml dependency version or run mvn dependency:tree | grep agentscope-parent:pom(only mac/linux)]

Description

[Please describe the background, purpose, changes made, and how to test this PR]

Checklist

Please check the following items before code is ready to be reviewed.

  • 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 (e.g. links, examples, etc.)
  • Code is ready for review

@chensk0601 chensk0601 requested a review from a team March 14, 2026 06:38
@cla-assistant
Copy link

cla-assistant bot commented Mar 14, 2026

CLA assistant check
All committers have signed the CLA.

@cla-assistant
Copy link

cla-assistant bot commented Mar 14, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


凡勇 seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@chensk0601 chensk0601 force-pushed the fix/951-react-agent-tool-execution-error-handling branch from f3080ad to 86c49aa Compare March 14, 2026 07:00
@codecov
Copy link

codecov bot commented Mar 14, 2026

Codecov Report

❌ Patch coverage is 61.40351% with 22 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...e/src/main/java/io/agentscope/core/ReActAgent.java 61.40% 19 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@chensk0601 chensk0601 force-pushed the fix/951-react-agent-tool-execution-error-handling branch 2 times, most recently from 0a3e447 to 0684fd6 Compare March 16, 2026 11:51
agentscope-ai#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 agentscope-ai#951
Copy link
Collaborator

@LearningGp LearningGp left a comment

Choose a reason for hiding this comment

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

Handling tool exceptions as ToolResult seems like a solid approach. For pending tool calls where no result is provided, I’m wondering if it might be more appropriate to expose those to the developer for handling instead. Also, perhaps we could consider adding a configurable exception handler mechanism in the future? (Just a thought—this last point definitely doesn't need to block the PR).

+ " failed:"
+ " "
+ error
.getMessage())
Copy link
Collaborator

Choose a reason for hiding this comment

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

error.getMessage() might return null, which could result in an unhelpful error message like [ERROR] Tool execution failed: null. Since ToolExecutor.executeWithInfrastructure() already uses ExceptionUtils.getErrorMessage(e), it might be better to align with that approach here for the sake of consistency.

*
* @param pendingIds The set of pending tool use IDs
*/
private void generateAndAddErrorToolResults(Set<String> pendingIds) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

The logic for manually constructing ToolResultBlock in both generateAndAddErrorToolResults and executeToolCalls.onErrorResume is nearly identical. I’d suggest extracting this into a shared helper method to reduce duplication and ensure a consistent error message format across the board.

.toList());
.toList())
.onErrorResume(
error -> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unexpected errors from the Reactor infrastructure itself (such as OutOfMemoryError) are currently being silently converted into error results. I suggest narrowing the catch scope to exclude Error subclasses, allowing critical JVM failures to propagate as intended.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes ReActAgent resiliency when tool execution fails (timeout/error) by ensuring pending tool-call state is cleared via synthetic error tool results, preventing IllegalStateException on subsequent calls.

Changes:

  • Update ReActAgent#doCall() to detect pending tool calls without user-provided tool results and auto-generate error tool results to clear pending state.
  • Update ReActAgent#executeToolCalls() to convert tool-execution failures into error tool results via onErrorResume instead of propagating exceptions.
  • Update HookStopAgentTest expectations to validate the new auto-recovery behavior (no longer expecting an exception).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java Adds auto-recovery for orphaned pending tool calls and converts tool execution failures into error tool results.
agentscope-core/src/test/java/io/agentscope/core/hook/HookStopAgentTest.java Updates tests to expect auto-recovery rather than IllegalStateException when pending tool calls exist.

e instanceof IllegalStateException
&& e.getMessage().contains("pending tool calls"))
.verify();
assertNotNull(result2, "Agent should auto-recover and return a result");
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This test currently only asserts result2 is non-null. To make the recovery behavior harder to regress, assert that the agent actually cleared the pending tool-call state (e.g., a ToolResultBlock was added to memory for the pending id and/or mockModel.stream(...) is invoked a second time and returns the expected follow-up response).

Suggested change
assertNotNull(result2, "Agent should auto-recover and return a result");
assertNotNull(result2, "Agent should auto-recover and return a result");
assertTrue(
result2.hasContentBlocks(TextBlock.class),
"Recovered result should be a normal text response");
// Ensure the model was invoked twice: once for the initial call and once after recovery
verify(mockModel, times(2)).stream(anyList(), anyList(), any());

Copilot uses AI. Check for mistakes.
// 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");
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Asserting only that the second call returns non-null can miss regressions. Add assertions that pending tool calls were cleared (e.g., error ToolResultBlock stored for the pending id) and that the next model response is actually reached (verify second mockModel.stream(...) invocation / expected response content).

Suggested change
assertNotNull(result, "Agent should auto-recover and return a result");
assertNotNull(result, "Agent should auto-recover and return a result");
// Ensure we reached a normal assistant response and pending tool calls were cleared
assertTrue(
result.hasContentBlocks(TextBlock.class),
"Recovered response should contain assistant text content");
assertFalse(
result.hasContentBlocks(ToolUseBlock.class),
"No pending tool calls should remain in the recovered response");
// Verify that the second model response was actually requested
verify(mockModel, times(2)).stream(anyList(), anyList(), any());

Copilot uses AI. Check for mistakes.
Comment on lines +301 to 323
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());
}
}

/**
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

generateAndAddErrorToolResults() writes synthetic tool-result messages straight into memory, which bypasses PostActingEvent hook processing (including StreamingHook’s TOOL_RESULT emission and any hook-based sanitization/transform). Consider emitting these via the same PostActingEvent notification path used for normal tool execution to keep tool-result lifecycle behavior consistent.

Suggested change
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());
}
}
/**
emitSyntheticErrorToolResult(toolCall);
}
}
/**
* Emit a synthetic error tool result for a pending tool call.
* <p>
* This method is intended to follow the same message lifecycle as normal
* tool execution results, including hook-based processing. Any integration
* with {@link PostActingEvent} and {@link Hook} dispatching for synthetic
* tool results should be implemented here to avoid bypassing hooks.
*
* @param toolCall the pending {@link ToolUseBlock} for which to generate an error result
*/
private void emitSyntheticErrorToolResult(ToolUseBlock toolCall) {
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());
// NOTE: To keep synthetic tool results consistent with normal tool execution,
// this is the place to integrate PostActingEvent-based hook notification,
// reusing the same path that is used for non-synthetic tool results.
memory.addMessage(toolResultMsg);
log.info(
"Auto-generated error result for pending tool call: {} ({})",
toolCall.getName(),
toolCall.getId());
}
/**

Copilot uses AI. Check for mistakes.
Comment on lines +676 to +680
log.error(
"Tool execution failed, generating error results for {} tool"
+ " calls: {}",
toolCalls.size(),
error.getMessage());
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

In executeToolCalls()'s onErrorResume, the log statement drops the throwable (only logs error.getMessage()), which makes diagnosing timeouts/failures much harder in production. Log the exception itself (to include stack trace) and prefer the existing ExceptionUtils.getErrorMessage(...) helper for a non-null, informative message.

Copilot uses AI. Check for mistakes.
Comment on lines +690 to +694
TextBlock
.builder()
.text(
"[ERROR]"
+ " Tool"
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This onErrorResume path manually constructs a TextBlock with a custom "[ERROR]" prefix and error.getMessage(), which can be null and is inconsistent with existing error result conventions (many call sites use ToolResultBlock.error(...) plus ExceptionUtils.getErrorMessage(...)). Consider using those helpers here for consistent formatting and null-safe messages.

Copilot uses AI. Check for mistakes.
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]: java.lang.IllegalStateException: Cannot add messages without tool results when pending tool calls exist. Pending IDs: [call_xxx]

3 participants