DEV Community

Solon Framework
Solon Framework

Posted on

Session Pending and Memory Layers in Solon Agents: Pause Runs, Isolate History, Resume Work

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:

  1. AgentSession + sessionWindowSize for multi-turn history
  2. A layered memory model (messages vs working memory)
  3. 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();
Enter fullscreen mode Exit fullscreen mode

What this gives you:

  • History window: sessionWindowSize truncates old messages before they hit the model context limit.
  • Session isolation: different sessionId values 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:

  1. Put durable user facts you care about later into session messages (or a dedicated long-term talent / store).
  2. Treat working memory as a task snapshot, not a forever archive.
  3. 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());
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode
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");
Enter fullscreen mode Exit fullscreen mode

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}";
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:

  1. Messages — conversational history
  2. 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 onActionpending(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.

References (official)

Top comments (0)