fix(core): 异常终止时保存 ReActAgent 状态 - #2713
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR adds best-effort state persistence when ReActAgent terminates abnormally — via unhandled errors or Reactor subscription cancellation. The refactoring of saveStateToSession into a reusable persistState() is clean, and the new dropUncommittedToolCallsFromCurrentCall() correctly bounds its work to the current call's context window (using loadedContextSize). The InterruptedException exclusion is correct since the existing interrupt path already handles state repair. Tests cover the key scenarios well.
One notable symmetry gap exists: the structured-output call paths (doNativeStructuredCall, doFallbackStructuredCall) also invoke scope.doCallInner(msgs) but were not updated with the same doOnCancel / onErrorResume operators, leaving them vulnerable to the same state-loss problem this PR fixes.
| } | ||
| return scope.doCallInner(msgs) | ||
| .doOnCancel(() -> checkpointAbnormalState(scope)) | ||
| .onErrorResume(error -> saveStateAfterError(scope, error)) |
There was a problem hiding this comment.
[major] Symmetry gap: The error/cancellation state-persistence operators (doOnCancel + onErrorResume) are only applied in this doCall(List<Msg>) path. The structured-output paths — doNativeStructuredCall (~line 1310) and doFallbackStructuredCall (~line 1350) — also call scope.doCallInner(msgs) but lack equivalent protection. If a structured-output call errors or gets cancelled, state will be lost — the exact same problem this PR fixes.
Consider applying the same operators to both structured-output paths, e.g.:
return scope.doCallInner(msgs)
.doOnCancel(() -> checkpointAbnormalState(scope))
.onErrorResume(error -> saveStateAfterError(scope, error))
.flatMap(result -> { /* existing logic */ });There was a problem hiding this comment.
✅ Verified as addressed in 1770366: doStructuredCall now wraps both native and fallback execution paths with checkpointOnAbnormalTermination (line 1282), applying doOnCancel + onErrorResume symmetrically.
| private Mono<Msg> saveStateAfterError(CallExecution scope, Throwable error) { | ||
| if (error instanceof InterruptedException | ||
| || error.getCause() instanceof InterruptedException) { | ||
| return Mono.error(error); |
There was a problem hiding this comment.
[nit] Minor: when error.getCause() is null, null instanceof InterruptedException safely evaluates to false, so this is correct as-is. However, for clarity, an explicit null-check could make the intent more obvious to readers:
if (error instanceof InterruptedException
|| (error.getCause() != null && error.getCause() instanceof InterruptedException)) {This is purely a readability nit — no functional issue.
|
|
||
| private void checkpointAbnormalState(CallExecution scope) { | ||
| try { | ||
| scope.dropUncommittedToolCallsFromCurrentCall(); |
There was a problem hiding this comment.
[praise] Good defensive design: wrapping both dropUncommittedToolCallsFromCurrentCall() and persistState() in separate try-catch blocks ensures that a failure in state repair doesn't prevent the best-effort save, and a save failure is properly logged without masking the original error.
There was a problem hiding this comment.
✅ Verified as addressed in 1770366: checkpointAbnormalState (lines 519-531) still uses two separate try-catch blocks for repair and save as praised.
| } | ||
| } | ||
|
|
||
| /** Remove current-call PENDING tool calls that never produced results. */ |
There was a problem hiding this comment.
[praise] Clean implementation: iterating backward from the end of context and bounding by loadedContextSize ensures only current-call PENDING tool calls are dropped, preserving historical context integrity. The early return when retained.size() == message.getContent().size() is a nice short-circuit optimization.
There was a problem hiding this comment.
✅ Verified as addressed in 1770366: dropUncommittedToolCallsFromCurrentCall (lines 2125-2158) retains the praised backward iteration, loadedContextSize bound, and early-return optimization.
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR adds best-effort state persistence when ReActAgent terminates abnormally — via unhandled errors or Reactor subscription cancellation. The refactoring of saveStateToSession into a reusable persistState() is clean, and the new dropUncommittedToolCallsFromCurrentCall() correctly bounds its work to the current call's context window (using loadedContextSize). The InterruptedException exclusion is correct since the existing interrupt path already handles state repair. Tests cover the key scenarios well.
One notable symmetry gap exists: the structured-output call paths (doNativeStructuredCall, doFallbackStructuredCall) also invoke scope.doCallInner(msgs) but were not updated with the same doOnCancel / onErrorResume operators, leaving them vulnerable to the same state-loss problem this PR fixes.
| } | ||
| return scope.doCallInner(msgs) | ||
| .doOnCancel(() -> checkpointAbnormalState(scope)) | ||
| .onErrorResume(error -> saveStateAfterError(scope, error)) |
There was a problem hiding this comment.
[major] Symmetry gap: The error/cancellation state-persistence operators (doOnCancel + onErrorResume) are only applied in this doCall(List<Msg>) path. The structured-output paths — doNativeStructuredCall (~line 1310) and doFallbackStructuredCall (~line 1350) — also call scope.doCallInner(msgs) but lack equivalent protection. If a structured-output call errors or gets cancelled, state will be lost — the exact same problem this PR fixes.
Consider applying the same operators to both structured-output paths, e.g.:
return scope.doCallInner(msgs)
.doOnCancel(() -> checkpointAbnormalState(scope))
.onErrorResume(error -> saveStateAfterError(scope, error))
.flatMap(result -> { /* existing logic */ });There was a problem hiding this comment.
✅ Verified as addressed in 1770366: doStructuredCall now wraps both native and fallback execution paths with checkpointOnAbnormalTermination (line 1282), applying doOnCancel + onErrorResume symmetrically.
| private Mono<Msg> saveStateAfterError(CallExecution scope, Throwable error) { | ||
| if (error instanceof InterruptedException | ||
| || error.getCause() instanceof InterruptedException) { | ||
| return Mono.error(error); |
There was a problem hiding this comment.
[nit] Minor: when error.getCause() is null, null instanceof InterruptedException safely evaluates to false, so this is correct as-is. However, for clarity, an explicit null-check could make the intent more obvious to readers:
if (error instanceof InterruptedException
|| (error.getCause() != null && error.getCause() instanceof InterruptedException)) {This is purely a readability nit — no functional issue.
|
|
||
| private void checkpointAbnormalState(CallExecution scope) { | ||
| try { | ||
| scope.dropUncommittedToolCallsFromCurrentCall(); |
There was a problem hiding this comment.
[praise] Good defensive design: wrapping both dropUncommittedToolCallsFromCurrentCall() and persistState() in separate try-catch blocks ensures that a failure in state repair doesn't prevent the best-effort save, and a save failure is properly logged without masking the original error.
There was a problem hiding this comment.
✅ Verified as addressed in 1770366: checkpointAbnormalState (lines 519-531) still uses two separate try-catch blocks for repair and save as praised.
| } | ||
| } | ||
|
|
||
| /** Remove current-call PENDING tool calls that never produced results. */ |
There was a problem hiding this comment.
[praise] Clean implementation: iterating backward from the end of context and bounding by loadedContextSize ensures only current-call PENDING tool calls are dropped, preserving historical context integrity. The early return when retained.size() == message.getContent().size() is a nice short-circuit optimization.
There was a problem hiding this comment.
✅ Verified as addressed in 1770366: dropUncommittedToolCallsFromCurrentCall (lines 2125-2158) retains the praised backward iteration, loadedContextSize bound, and early-return optimization.
a804d47 to
1770366
Compare
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
All blocking review comments have been addressed. 2 non-blocking suggestion(s) (minor/nit) remain open and may be addressed in a follow-up.
1770366 to
2dcf24d
Compare
AgentScope-Java Version
2.0.3-SNAPSHOT
Description
背景
ReActAgent目前只在正常结束和用户 interrupt 后保存状态。模型、工具或 middleware 报错,以及流被取消时,本轮已经写入内存的用户消息和已完成上下文不会保存到AgentStateStore,Agent 重建后会回退到旧状态。另外,如果 reasoning 已经产生 tool call,但在 acting 写入结果前异常结束,直接保存会留下没有对应结果的
PENDINGtool call,影响下一轮恢复。应用层因此需要额外实现异常保存和状态修复逻辑。改动
AgentState,且不覆盖原始异常。PENDINGtool call,保留文本、thinking、ALLOWED和ASKING状态。测试
mvn -pl agentscope-core test2298 tests passed,8 skipped。
Checklist
mvn spotless:applymvn test)