Most agent demos keep one chat list in memory and hope for the best. Production agents need more than that: short-term history that does not blow the context window, task-local working memory that dies with the task, and a way to pause a run when a tool is too risky or a user hits stop.
Solon AI models those needs with three related pieces:
-
AgentSession+sessionWindowSizefor multi-turn history - A layered memory model (messages vs working memory)
-
session.pending(...)for pause / resume style control
This article stays on official Solon v4.0.3 Agent APIs.
The production problem
Imagine a support agent that:
- continues a ticket across several user turns
- runs a multi-step tool loop for one turn
- must freeze if the model chooses
delete_user - must survive process restart for long team workflows
A single List<ChatMessage> cannot express all of that cleanly. Solon separates:
| Concern | API surface | Lifetime |
|---|---|---|
| Cross-task chat history |
AgentSession messages + sessionWindowSize
|
Across prompts on the same session id |
| In-task working memory | ReAct / Team working messages + trace | One task / one graph run |
| Pause switch | AgentSession.pending(true, reason) |
Until the session is triggered again |
| Durable snapshot |
session.getSnapshot() / updateSnapshot()
|
As durable as your session impl |
1. Open memory with session + window
Official baseline from the Session & Memory guide:
import org.noear.solon.ai.agent.AgentSession;
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.session.InMemoryAgentSession;
// Keep only the last 5 history messages when talking to the model
ReActAgent agent = ReActAgent.of(chatModel)
.name("support_agent")
.role("Customer support agent for order and account questions")
.sessionWindowSize(5)
.maxTurns(8)
.autoRethink(true)
.defaultToolAdd(new SupportTools())
.build();
// Isolate memory by business id (user / ticket / order)
AgentSession session = InMemoryAgentSession.of("ticket_10086");
String answer = agent.prompt("Continue the previous refund discussion")
.session(session)
.call()
.getContent();
What this gives you:
-
History window:
sessionWindowSizetruncates old messages before they hit the model context limit. -
Session isolation: different
sessionIdvalues never share chat state. -
Same call shape on SimpleAgent / ReActAgent / TeamAgent: every built-in agent supports
prompt(...).session(...).call().
Built-in session stores
| Class | Medium | Good for |
|---|---|---|
InMemoryAgentSession |
Local map | Dev, tests, short demos |
FileAgentSession |
Local files | CLI / single-node tools that must survive restart |
RedisAgentSession |
Redis | Multi-node production |
For a cluster, prefer Redis (or a custom AgentSession backed by your DB). Sticky sessions only hide the problem until the next deploy.
2. Memory is layered, not linear
ChatModel history is mostly one flat transcript. Agent memory is intentionally layered:
| Layer | Who uses it | What it stores |
|---|---|---|
| History messages | Simple / ReAct / Team | Full user↔agent turns across tasks |
| Working memory | ReAct / Team | Thought / action / sub-agent details inside one task |
Practical rules that match the official model:
- Put durable user facts you care about later into session messages (or a dedicated long-term talent / store).
- Treat working memory as a task snapshot, not a forever archive.
- If you need every task’s intermediate state, customize the session (or persist the trace / snapshot yourself).
That split is why long ReAct loops do not automatically dump every tool observation into next week’s chat window: working memory is task-scoped; history is cross-task.
3. Pause the graph with pending
Pending is a temporary stop on the session’s execution graph, not a soft “please wait” prompt. Official use cases:
- internal guardrails (HITL-style approval before a dangerous tool)
- external stop (user cancels a stream / async job)
Interceptor-driven pause
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.react.ReActInterceptor;
import org.noear.solon.ai.agent.react.ReActTrace;
import org.noear.solon.ai.agent.react.task.ToolExchanger;
import org.noear.solon.ai.agent.session.InMemoryAgentSession;
ReActAgent agent = ReActAgent.of(chatModel)
.name("account_ops")
.role("Account operations agent")
.defaultToolAdd(new AccountTools())
.defaultInterceptorAdd(new ReActInterceptor() {
@Override
public void onAction(ReActTrace trace, ToolExchanger toolExchanger) {
if ("delete_user".equals(toolExchanger.getToolName())) {
// freeze the graph before the tool runs
trace.getSession().pending(true, "Sensitive op: wait for human approval");
}
}
})
.build();
AgentSession session = InMemoryAgentSession.of("user_1001");
agent.prompt("Delete user id 1001").session(session).call();
if (session.isPending()) {
System.out.println("Paused: " + session.getPendingReason());
}
API surface:
| Method | Meaning |
|---|---|
pending(true, reason) |
Enter pending with a human-readable reason |
isPending() |
Whether the session is currently pending |
getPendingReason() |
Reason for UI / audit |
Important official note: pending is temporary. When the same session is triggered again, the runtime resets pending (isPending → false) so the graph can move forward. Design your approval flow around that resume semantics; do not assume pending sticks forever.
External pause on async / stream
AgentSession session = InMemoryAgentSession.of("report_job_9");
// async (callAsync available since v3.10.2)
agent.prompt("Write a long ops report").session(session).callAsync()
.whenComplete((resp, err) -> { /* final handling */ });
// user clicked stop
session.pending(true, "User cancelled generation");
AgentSession session = InMemoryAgentSession.of("weather_stream_1");
agent.prompt("Check Hangzhou weather and summarize").session(session).stream()
.doOnNext(chunk -> { /* push to UI */ })
.subscribe();
// quota / kill switch
session.pending(true, "Quota exceeded, auto-paused");
This pairs cleanly with production interceptors and stream UIs: the UI shows getPendingReason(), ops can approve / reject, then you re-trigger the same session id.
4. Domain tools stay ordinary
Same production pattern as the after-sales sample: AbsToolProvider + @ToolMapping. No custom implements Tool.
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import org.noear.solon.annotation.Param;
public class AccountTools extends AbsToolProvider {
@ToolMapping(description = "Look up account profile by user id")
public String get_user(@Param(description = "User id") String userId) {
return "{\"userId\":\"" + userId + "\",\"status\":\"ACTIVE\"}";
}
@ToolMapping(description = "Delete a user account (destructive)")
public String delete_user(@Param(description = "User id") String userId) {
return "{\"userId\":\"" + userId + "\",\"deleted\":true}";
}
}
Pending belongs in the interceptor / outer control plane. The tool itself stays a pure function of business side effects.
5. Snapshots for persistence and resume
AgentSession is not only a chat bag. It extends chat session with execution snapshots:
// read / write business variables and agent traces on the snapshot
session.getSnapshot().put("ticket_priority", "P1");
session.updateSnapshot();
// for durable backends, serialize the snapshot (JSON) and restore later
String json = session.getSnapshot().toJson();
Team workflows use the same idea for checkpoint resume: store the team trace under "__" + teamName, restore the session, then call with an empty prompt to continue from the saved route. The official Team persistence sample does exactly that for a trip-planning graph.
Custom stores implement AgentSession and keep two data classes honest:
- Messages — conversational history
- Snapshot — flow variables + traces beyond chat text
6. A small production checklist
| Need | Do this |
|---|---|
| Multi-user isolation | One sessionId per user / ticket / order |
| Token control | Tune sessionWindowSize (often 5–15) |
| Dangerous tools |
onAction → pending(true, reason) or HITL interceptor |
| User cancel | External session.pending(true, ...) on stream / async |
| Multi-node |
RedisAgentSession (or custom DB session) |
| Crash resume | Persist snapshot / team trace; resume with empty prompt when appropriate |
| Don’t confuse layers | History = across tasks; working memory = inside one task |
Closing
Session in Solon is a state machine, not a chat log dump. Use the history window for multi-turn continuity, respect the message / working-memory split, and treat pending as the official pause switch for human approval and external kill switches. Once those three pieces are in place, interceptors, streams, and team checkpoints become much easier to reason about.
Top comments (0)