Skip to main content

Stateless Agent Engine

ReActAgent (and HarnessAgent that wraps it) is designed as a stateless engine: the agent instance itself holds only immutable configuration — system prompt, model, tools, middleware chain — while all per-session mutable data lives in AgentState, indexed by (userId, sessionId). A single agent instance can concurrently serve many users and sessions; the caller simply passes a different RuntimeContext on each call().

What this means for you

  • No agent-per-user registry. One HarnessAgent instance can serve all your users — just vary RuntimeContext.userId and RuntimeContext.sessionId per request.
  • Concurrency is built in. Different (userId, sessionId) pairs run fully in parallel; the same pair is automatically serialised to preserve conversation consistency.
  • State is fully internal. The agent loads AgentState from the store at call entry and saves it at call exit — the caller never manages state objects directly.
  • Per-call isolation. Each call() works on its own AgentState snapshot. Middleware and tools access the call-scoped state via RuntimeContext.getAgentState() (injected by the framework at call entry), so concurrent calls never see each other’s state.

AgentState

An AgentStateStore persists an AgentState (io.agentscope.core.state.AgentState) — a complete snapshot of everything that makes the agent restartable: AgentState also carries a transient, non-serialised InterruptControl for per-session interrupt signalling — see Per-session interrupt below. At the end of each call(), the framework writes the entire AgentState to the state store under the key agent_state, addressed by the call’s (userId, sessionId). The next call() with the same (userId, sessionId) loads it back automatically. Provided the state store is distributed (e.g. Redis), agent instances on different processes — even different physical machines — see identical state.

The auto-persistence and recovery flow

This wiring lives in ReActAgent itself; HarnessAgent inherits it for free. The agent instance holds no fixed session — each call reads / writes the slot named by its RuntimeContext (falling back to the builder-time defaultSessionId).
Mid-call() state changes happen against the in-memory AgentState. The state store is written once per call (and on shutdown), not on every message — so the throughput pressure on your store stays low.

Built-in and extension implementations

Anything implementing io.agentscope.core.state.AgentStateStore works. Pick by deployment shape: Switching is one call at builder time:
The built-in JsonFileAgentStateStore / InMemoryAgentStateStore are single-host only. If you’ve already chosen filesystem(SandboxFilesystemSpec) or filesystem(RemoteFilesystemSpec) (distributed workspace), HarnessAgent rejects a local state store at build time with IllegalStateException — sandbox state must be shared across replicas. Configure a distributed store via .distributedStore(...) (e.g. RedisDistributedStore) or .stateStore(...).

Real-time resume across processes and machines

Once the state store is distributed (e.g. Redis), cross-machine resume is automatic:
This buys you:
  • Failover: a crashed node — conversations migrate to a healthy one, user notices nothing.
  • Rolling deploys: old pods save on shutdown, new pods load on first call — conversations never break across releases.
  • Cross-surface continuity: a user starts in the Web UI, switches to the CLI — same (userId, sessionId), all memory present.
The (userId, sessionId) pair defines the namespacing: sessionId alone is enough for most cases; add userId when you need per-user partitioning.

Multi-user isolation

sessionId and userId solve different problems:
  • sessionId — which conversation this is; independent AgentState snapshot.
  • userId — which user owns this conversation; also drives which user’s namespace files land in, see Filesystem.
Two users — separate state, separate filesystem paths, no crosstalk. For AgentState-level user isolation in production, set userId on the RuntimeContext: the store addresses each slot by (userId, sessionId) (with RedisAgentStateStore the userId becomes part of the Redis key) rather than relying on filesystem path bucketing.

Reading and writing AgentState directly

When you need to bypass the agent loop (admin console, audit, batch migration):

Clearing a session’s conversation context

To let a user start a fresh topic without creating a new session, call clearContext. It keeps the same (userId, sessionId) and preserves non-conversation state such as permissions, tools, tasks, and Plan Mode. It clears the model-visible message buffer and compaction summary, then immediately persists the result when the agent has an AgentStateStore.
Call it after the session’s current request has completed. It does not cancel an in-flight call; the next call starts with the cleared conversation context.
The 1.0 Memory interface (InMemoryMemory / LongTermMemory, etc.) is @Deprecated(forRemoval = true) in 2.0. New code should use AgentState.getContext() + an AgentStateStore; Memory remains only as a source-compat shim.

Per-session interrupt

Each AgentState carries a transient InterruptControl (io.agentscope.core.interruption.InterruptControl) — a per-session interrupt signal that is never serialised to the state store (marked @JsonIgnore transient on AgentState). This allows targeted interruption of a single session’s in-flight call without affecting other concurrent calls on the same agent instance.
The reasoning loop checks state.interruptControl().isInterrupted() before each iteration. When triggered, the loop enters the handleInterrupt path, which saves state and returns the partial result. The legacy no-arg interrupt() still works for single-session scenarios — it routes to the currently active session’s InterruptControl.
InterruptControl is a runtime-only signal; it is never persisted. If a session resumes on a different node after failover, the interrupt flag starts cleared. The separate AgentState.shutdownInterrupted flag (which is persisted) records whether the session was interrupted by graceful shutdown — the agent can detect and recover from that on next load.

Concurrent usage

Because the agent is a stateless engine, a single instance handles concurrent requests naturally:
Concurrency rules:
  • Different (userId, sessionId) → fully parallel, each call works on its own AgentState.
  • Same (userId, sessionId) → per-session async gate serialises calls in FIFO order — state consistency guaranteed without external locking.
  • interrupt(userId, sessionId) → targets exactly one session, other in-flight calls unaffected.
The in-memory state cache grows with the number of distinct sessions a single agent instance has served. For most deployments (hundreds of sessions) this is negligible. For very large-scale scenarios (millions of sessions per process), consider an agent factory pattern with bounded instance pools — but this is rarely needed since AgentState objects are lightweight.

RuntimeContext — per-call metadata

RuntimeContext (in io.agentscope.core.agent) is a lightweight per-call carrier passed to agent.call(msgs, ctx); hooks and tools share it for the duration of one call. Its free-form / typed attributes are not persisted; its sessionId / userId fields select which AgentState slot the state store loads and saves for this call. At call entry, the framework injects the call-scoped AgentState onto the RuntimeContext so that middleware, tools, and hooks can access the correct per-call state via ctx.getAgentState().
Available accessors:
The AgentStateStore is bound at builder time and cannot be switched per call via RuntimeContext. What does vary per call is the (userId, sessionId) slot it addresses — set userId for per-user isolation (or a custom keyPrefix on the store); do not try to hand each call a different state store instance.
Accessing AgentState from middleware and tools: Always use RuntimeContext.resolveAgentState(ctx, agent) rather than agent.getAgentState() during call execution. Under concurrency, agent.getAgentState() returns the last-active session’s state (an arbitrary choice when multiple calls are in flight), while ctx.getAgentState() returns the state for this call’s session — which is what you almost always want.

  • Agent — full ReActAgent API and builder fields
  • Context Compaction — conversation summarization, tool-result eviction, overflow recovery (builds on top of the AgentState foundation described here)
  • Memory — long-term memory, background maintenance
  • Permissions — persistence of permission rules