This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.1!

ChatClient Integration

SessionAdvisors wires a session into a ChatClient, so application code never loads or appends history by hand. The ChatClient is one harness on the session, stateless by itself: the advisors read the view and append events, the session keeps the state. SessionAdvisors builds two advisors, one for each place a ChatClient call has:

  • SessionMemoryAdvisor works on each model call, inside the tool-calling loop. It loads the active view into the prompt and stores the request’s user message or tool results and the model’s reply.

  • SessionTurnAdvisor works on each ChatClient call, outside the loop, so it sees the call’s final response however the loop ended. A call is one turn of the session: the user message, the tool rounds and the reply. It creates the session on first use, stores the results of a tool loop that ended without a model round, and compacts the session once the call has returned.

What each gives on its own:

Registered You get

Both, through SessionAdvisors

The session: history, compaction, and the results of a loop that ended without a model round.

SessionMemoryAdvisor alone

The history, without compaction; a returnDirect tool’s results are not stored.

SessionTurnAdvisor alone

Nothing of the conversation: it stores no messages.

Both live in spring-ai-session-advisor; the core module has no dependency on ChatClient.

The session advisors around the tool-calling loop

What the advisors do

A ChatClient call through the session advisors

On every model call the memory advisor:

  1. Resolves the session id from SESSION_ID_CONTEXT_KEY, which must be present on every request, and creates the session if it does not exist. A session the advisor creates gets the metadata of SESSION_METADATA_CONTEXT_KEY, such as the agent attributes.

  2. Reads the active view with the configured eventFilter, merged with a per-request one, and always within the view: the events a compaction covered never reach the prompt.

  3. Builds the prompt: the latest stored system message, if any, and the request’s system messages first, exact duplicates once; then the history the request does not carry yet; then the request’s messages. From the second round of a tool loop on, the loop’s prompt carries the turn so far, so only the history before it is prepended.

  4. Stores the request’s trailing user message, subject to the MessageFilter, or its tool results, always.

  5. After the model responds, stores the reply, subject to the MessageFilter, except a reply with tool calls, which is always stored.

On every ChatClient call the turn advisor finds or creates the session; once the call has returned it stores the tool results of a loop that ended without a model round (a returnDirect tool, or a breached tool call limit) and runs the compaction trigger.

Setup

SessionAdvisors builds both advisors at their default order around the tool-calling loop that the ChatClient registers itself: the turn advisor outside it (Ordered.HIGHEST_PRECEDENCE + 200) and the memory advisor inside it (HIGHEST_PRECEDENCE + 1000).

The session advisors
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(SessionAdvisors.builder(sessionService)
        .compaction(new TurnCountTrigger(20), WindowCompactionStrategy.builder().lastTurns(10).build())
        .asyncCompaction(true)                                    // compact after the reply is returned
        .memory(memory -> memory.eventFilter(EventFilter.lastN(50)))  // customize the memory advisor
        .build())                                                 // a List<Advisor>: the turn, then the memory
    .build();

String answer = chatClient.prompt()
    .user("Hello")
    .advisors(SessionAdvisors.session(sessionId))
    .call()
    .content();

To place the advisors yourself, build them one by one:

SessionTurnAdvisor turn = SessionTurnAdvisor.builder(sessionService)
    .compactionTrigger(new TurnCountTrigger(20))
    .compactionStrategy(WindowCompactionStrategy.builder().lastTurns(10).build())
    .asyncCompaction(true)
    .build();

SessionMemoryAdvisor memory = SessionMemoryAdvisor.builder(sessionService)
    .eventFilter(EventFilter.lastN(50))
    .messageFilter(MessageFilter.skipEmptyMessages())
    .build();

ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(turn, memory)
    .build();

The compaction trigger and strategy are set together: setting only one makes build() throw. With asyncCompaction(true) the compaction runs on the advisor’s Scheduler after the reply is returned, so a summarizing strategy adds no model call to the reply’s latency; the caller’s context, the security context for example, goes with it: the thread locals of a blocking caller, or what a reactive caller carries in the Reactor context. One compaction per session runs at a time from one advisor; a compaction that a new turn’s writes overtake keeps them in the view and lands with the same summary, and only another compaction’s boundary, or turns that keep overtaking it, make it stand down until the next turn. Compaction is best-effort: a failure is logged and the reply returned.

The memory advisor’s default order puts it inside the ToolCallingAdvisor’s loop, so it runs once per round. That is deliberate: each round’s prompt is rebuilt from the session, and only each round’s trailing message and the model’s reply are stored. `SessionMemoryAdvisor is a MemoryAdvisor, so the loop the ChatClient registers itself runs with its own history off and sends only the system prompt and the last tool result; a loop configured with its history on sends the turn so far, which the loaded history ends with, and only the history before the overlap is prepended. Messages are compared by content, not by identity, so the check also holds when the repository does not keep message metadata. A blank system message, the loop’s stand-in for a request without one, is not sent.

Context keys

Key Value Purpose

SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY

"chat_memory_conversation_id", equal to MemoryAdvisor.CONVERSATION_ID

The session a call runs on. SessionAdvisors.session(id) sets it. Omitting it throws IllegalStateException: a shared default session would silently merge the history of different users.

SessionMemoryAdvisor.SESSION_METADATA_CONTEXT_KEY

"chat_memory_session_metadata"

A Map<String, Object> of metadata for a session the advisor creates on first use; ignored for an existing session. Set the agent attributes once with .defaultAdvisors(a → a.param(SESSION_METADATA_CONTEXT_KEY, Map.of(SessionAttributes.AGENT_NAME, "support"))); a request that passes its own map replaces that default as a whole.

SessionMemoryAdvisor.EVENT_FILTER_CONTEXT_KEY

"chat_memory_event_filter"

A per-request EventFilter merged over the advisor’s.

SessionMemoryAdvisor.REQUEST_EVENT_ID_CONTEXT_KEY

"chat_memory_request_event_id"

Set by the advisor: the id of the event that stored the request’s trailing user or tool message; absent when none was stored. Read it from the forwarded request or from call().chatClientResponse().context().

SessionMemoryAdvisor.RESPONSE_EVENT_IDS_CONTEXT_KEY

"chat_memory_response_event_ids"

Set by the advisor on the response: the ids of the events that stored the reply’s assistant messages, a List<String>, empty when none was stored.

Per-request filter

Pass an EventFilter with EVENT_FILTER_CONTEXT_KEY to narrow the history of one call without reconfiguring the advisor:

String answer = chatClient.prompt()
    .user("Quick summary please")
    .advisors(a -> a
        .param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId)
        .param(SessionMemoryAdvisor.EVENT_FILTER_CONTEXT_KEY, EventFilter.lastN(5)))
    .call()
    .content();

The request’s filter is merged over the advisor’s with EventFilter.merge: its types and window win, the flags are OR-ed, and the view is always merged in. See Event Filtering.

What is stored

While EventFilter controls which stored events are loaded into the prompt, MessageFilter controls which messages get stored at all. A message the filter rejects is never persisted; the outgoing prompt is unaffected.

EventFilter (read side) MessageFilter (write side)

Applies when

Loading the history before the model call

Appending the request’s message and the reply

Operates on

Stored `SessionEvent`s

`Message`s about to be stored

Rejected items

Stay in the log, hidden from the prompt

Never written

Tool messages

A tool call or result left without its counterpart is dropped from the prompt

Always stored, whatever the filter says

SessionMemoryAdvisor.builder(sessionService)
    // Never store messages containing "confidential", still skipping empty replies
    .messageFilter(MessageFilter.containsText("confidential").negate().and(MessageFilter.skipEmptyMessages()))
    .build();
Factory Behaviour

MessageFilter.all()

Stores every message.

MessageFilter.skipEmptyMessages()

Skips assistant messages with no text, no tool calls and no media; the default. Some models reject an empty assistant message replayed as history.

MessageFilter.byMessageType(types…​)

Stores only the listed message types.

MessageFilter.containsText(keyword)

Stores only the messages whose text contains the keyword, case-insensitively.

MessageFilter is a functional interface, and filters compose with and(), or() and negate(). A custom filter replaces the default, so compose it with skipEmptyMessages() to keep that protection.

Tool calls and tool results are always stored, whatever the filter says: either half alone makes a prompt every provider rejects. To keep tool traffic out of the prompt, hide it on read instead; the advisor then drops a tool call left without its results:

SessionMemoryAdvisor.builder(sessionService)
    .eventFilter(EventFilter.builder().messageTypes(Set.of(MessageType.USER, MessageType.ASSISTANT)).build())
    .build();

Tool-call integrity

Every provider rejects a prompt in which an assistant message with tool calls is not followed by the tool messages answering it. The advisors keep that from happening at both ends, without touching the log:

  • Windows keep turns whole. A lastN window is extended by the repository to the start of the turn it lands in, so a tool call is never read without its results.

  • The read guard. After loading the history, an assistant tool call whose calls are not all answered by the tool messages that directly follow it is dropped from the prompt, and so is a tool message that answers no preceding call. The advisor logs a WARN naming the likely causes: a messageTypes filter hiding TOOL events, or a loop that failed mid-way.

  • The write side. Tool calls and tool results are always stored. A loop that ends without a model round after the calls stores no results of its own: a returnDirect tool, or a breached tool call limit. The SessionTurnAdvisor stores them then: the results that came back directly, or, for a breach, the results of the calls that ran, the breach for the call that broke the limit, and a note for the calls after it.

Streaming and threads

Both advisors run their session reads and writes on a Scheduler, BaseAdvisor.DEFAULT_SCHEDULER by default, for a call() and for a stream(). In a stream the aggregation of the reply and the compaction are published back onto the scheduler, so nothing runs on the model’s streaming thread. Streamed chunks do not carry RESPONSE_EVENT_IDS_CONTEXT_KEY.