|
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:
-
SessionMemoryAdvisorworks 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. -
SessionTurnAdvisorworks on eachChatClientcall, 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 |
The session: history, compaction, and the results of a loop that ended without a model round. |
|
The history, without compaction; a |
|
Nothing of the conversation: it stores no messages. |
Both live in spring-ai-session-advisor; the core module has no dependency on ChatClient.
What the advisors do
On every model call the memory advisor:
-
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 ofSESSION_METADATA_CONTEXT_KEY, such as the agent attributes. -
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. -
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.
-
Stores the request’s trailing user message, subject to the
MessageFilter, or its tool results, always. -
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).
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 |
|---|---|---|
|
|
The session a call runs on.
|
|
|
A |
|
|
A per-request |
|
|
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 |
|
|
Set by the advisor on the response: the ids of the events that stored the reply’s assistant messages, a |
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 |
|---|---|
|
Stores every message. |
|
Skips assistant messages with no text, no tool calls and no media; the default. Some models reject an empty assistant message replayed as history. |
|
Stores only the listed message types. |
|
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
lastNwindow 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
WARNnaming the likely causes: amessageTypesfilter hidingTOOLevents, 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
returnDirecttool, or a breached tool call limit. TheSessionTurnAdvisorstores 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.