Describe the bug
When a user triggers an interrupt (e.g. clicking "stop generating" in a chat UI), the ReActAgent.handleInterrupt() method adds a recovery message to the in-memory AgentState.context but never persists the state to AgentStateStore. On the next call() for the same session, the agent loads the stale state from the store — the recovery message and any context accumulated during the interrupted turn are lost, causing the agent to lose conversation context.
The documentation states:
对话上下文(AgentState)自动保存——下次对同一 session 发起 call() 时从中断点恢复
This auto-save only works for SYSTEM interrupts (graceful shutdown), not USER interrupts.
Root cause analysis
1. handleInterrupt only saves for SYSTEM source
File: agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java (~line 3376)
@Override
protected Mono<Msg> handleInterrupt(InterruptContext context, Msg... originalArgs) {
return Mono.deferContextual(cv -> {
CallExecution scope = scopeFrom(cv);
InterruptSource source = scope.state.interruptControl().getSource();
if (source == InterruptSource.SYSTEM) {
shutdownManager.saveOnInterruptObserved(requestId); // ✅ STATE SAVED
return Mono.error(new AgentShuttingDownException());
}
// USER interrupt falls through here:
Msg recoveryMsg = AssistantMessage.builder()
.name(getName())
.content(TextBlock.builder().text(recoveryText).build())
.build();
scope.state.contextMutable().add(recoveryMsg); // IN-MEMORY ONLY, NO PERSIST
return Mono.just(recoveryMsg);
});
}
For InterruptSource.USER, the method only mutates the in-memory context. No call to saveStateToSession().
2. saveStateToSession is skipped in the error path
File: ReActAgent.java (~line 1002)
return scope.doCallInner(msgs)
.flatMap(result -> saveStateToSession(scope).thenReturn(result));
saveStateToSession is chained via flatMap, which only executes on the success path. When doCallInner throws InterruptedException, the error propagates to onErrorResume in runLifecycle:
File: AgentBase.java (~line 495)
private Function<Throwable, Mono<Msg>> createErrorHandler(Msg... originalArgs) {
return error -> {
if (error instanceof InterruptedException
|| (error.getCause() instanceof InterruptedException)) {
return handleInterrupt(createInterruptContext(), originalArgs);
// ❌ No saveStateToSession call after handleInterrupt
}
return notifyError(error).then(Mono.error(error));
};
}
The Mono.just(recoveryMsg) returned by handleInterrupt becomes the final result of the onErrorResume chain. It does not flow back through doCall's flatMap(saveStateToSession). In Reactor, once onErrorResume catches an error and emits a new value, upstream operators are bypassed.
3. saveStateToSession is private
The method is private, so subclasses cannot call it either. There is a public saveAgentState(userId, sessionId) method, but it is not invoked anywhere in the interrupt path.
To Reproduce
- Configure a
ReActAgent with an AgentStateStore (e.g. RedisDistributedStore or JsonStateStore)
- Start a streaming chat session (
dispatchStream)
- While the agent is generating a response (reasoning or tool-calling), call
agent.interrupt(userId, sessionId)
- The stream ends with a recovery message ("I noticed that you have interrupted me...")
- Send a new message on the same session
- Observe: The agent loads state from the store — the recovery message and any partial context from the interrupted turn are missing. The agent has no memory of the previous conversation.
Expected behavior
After a USER interrupt, the AgentState (including the recovery message and all context accumulated up to the interrupt point) should be persisted to AgentStateStore. The next call() on the same session should restore the full context, as documented.
Actual behavior
- In-memory
AgentState.context is modified (recovery message added)
AgentStateStore still holds the pre-interrupt state
- Next
call() loads stale state → conversation context is lost
Suggested fix
In AgentBase.createErrorHandler(), after handleInterrupt() returns successfully, call saveStateToSession():
private Function<Throwable, Mono<Msg>> createErrorHandler(Msg... originalArgs) {
return error -> {
if (error instanceof InterruptedException
|| (error.getCause() instanceof InterruptedException)) {
return handleInterrupt(createInterruptContext(), originalArgs)
.flatMap(result -> saveStateToSession(/* scope */).thenReturn(result));
}
return notifyError(error).then(Mono.error(error));
};
}
This requires making saveStateToSession accessible from AgentBase (currently it's a private method on ReActAgent), or moving the save logic to a protected method on AgentBase.
Environment
- AgentScope-Java Version: 2.0.0-SNAPSHOT
- Java Version: 17
- OS: Windows
Additional context
The SYSTEM interrupt path (graceful shutdown) works correctly — it calls shutdownManager.saveOnInterruptObserved() which persists state via ActiveRequestContext.saveState(). The bug is specific to USER-triggered interrupts.
Describe the bug
When a user triggers an interrupt (e.g. clicking "stop generating" in a chat UI), the
ReActAgent.handleInterrupt()method adds a recovery message to the in-memoryAgentState.contextbut never persists the state toAgentStateStore. On the nextcall()for the same session, the agent loads the stale state from the store — the recovery message and any context accumulated during the interrupted turn are lost, causing the agent to lose conversation context.The documentation states:
This auto-save only works for SYSTEM interrupts (graceful shutdown), not USER interrupts.
Root cause analysis
1.
handleInterruptonly saves for SYSTEM sourceFile:
agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java(~line 3376)For
InterruptSource.USER, the method only mutates the in-memory context. No call tosaveStateToSession().2.
saveStateToSessionis skipped in the error pathFile:
ReActAgent.java(~line 1002)saveStateToSessionis chained viaflatMap, which only executes on the success path. WhendoCallInnerthrowsInterruptedException, the error propagates toonErrorResumeinrunLifecycle:File:
AgentBase.java(~line 495)The
Mono.just(recoveryMsg)returned byhandleInterruptbecomes the final result of theonErrorResumechain. It does not flow back throughdoCall'sflatMap(saveStateToSession). In Reactor, onceonErrorResumecatches an error and emits a new value, upstream operators are bypassed.3.
saveStateToSessionis privateThe method is
private, so subclasses cannot call it either. There is a publicsaveAgentState(userId, sessionId)method, but it is not invoked anywhere in the interrupt path.To Reproduce
ReActAgentwith anAgentStateStore(e.g.RedisDistributedStoreorJsonStateStore)dispatchStream)agent.interrupt(userId, sessionId)Expected behavior
After a USER interrupt, the
AgentState(including the recovery message and all context accumulated up to the interrupt point) should be persisted toAgentStateStore. The nextcall()on the same session should restore the full context, as documented.Actual behavior
AgentState.contextis modified (recovery message added)AgentStateStorestill holds the pre-interrupt statecall()loads stale state → conversation context is lostSuggested fix
In
AgentBase.createErrorHandler(), afterhandleInterrupt()returns successfully, callsaveStateToSession():This requires making
saveStateToSessionaccessible fromAgentBase(currently it's aprivatemethod onReActAgent), or moving the save logic to a protected method onAgentBase.Environment
Additional context
The SYSTEM interrupt path (graceful shutdown) works correctly — it calls
shutdownManager.saveOnInterruptObserved()which persists state viaActiveRequestContext.saveState(). The bug is specific to USER-triggered interrupts.