Skip to content

[Bug]:USER interrupt does not persist AgentState — context lost on next call #2007

Description

@BeginnerCong

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

  1. Configure a ReActAgent with an AgentStateStore (e.g. RedisDistributedStore or JsonStateStore)
  2. Start a streaming chat session (dispatchStream)
  3. While the agent is generating a response (reasoning or tool-calling), call agent.interrupt(userId, sessionId)
  4. The stream ends with a recovery message ("I noticed that you have interrupted me...")
  5. Send a new message on the same session
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions