|
This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.1! |
Session Concepts
Four concepts describe how a conversation is stored and read: the session, its events, the turns they form, and the view of the log the model sees.
Session
A Session is the identity of one continuous conversation.
It is an immutable value that holds only metadata; the events are stored separately and read on demand.
| Field | Description |
|---|---|
|
The session’s identifier: the request’s, or a random UUID. |
|
When the session was created. |
|
When the session expires.
A session the service creates always has one: the request’s |
|
Arbitrary metadata: the owner, the agent attributes, tags, and whatever the application adds. |
A session has no owner field.
Who it belongs to is an attribute of its metadata, in the shape of Spring Session’s principal-name index: every store indexes SessionAttributes.PRINCIPAL_NAME, so findByMetadata(SessionAttributes.PRINCIPAL_NAME, "alice") lists a user’s sessions.
SessionAttributes.AGENT_NAME, AGENT_VERSION and AGENT_MODEL label the agent a session runs, for listings and a UI; SessionAttributes.agentName(session) reads them back.
Session session = sessionService.create(CreateSessionRequest.builder()
.id("session-abc") // optional: a random UUID otherwise
.timeToLive(Duration.ofDays(7)) // optional: the service's default otherwise
.metadata(SessionAttributes.PRINCIPAL_NAME, "alice")
.metadata(SessionAttributes.AGENT_NAME, "support")
.build());
A create for an id that is taken throws SessionAlreadyExistsException, and the existing session is left untouched: two callers that create the same id concurrently get one session and one exception, so the second can read what the first created.
SessionEvent
A SessionEvent is an immutable value with an EventType.
A MESSAGE event wraps a Spring AI Message and adds only what the message intentionally omits: identity, ownership, ordering and framework flags.
Every other event type records runtime state in the same log and carries its data in a payload map instead of a message.
| Field | Description |
|---|---|
|
The event’s identity, a random UUID by default. Event ids are unique across sessions. |
|
The session the event belongs to. |
|
When the event was recorded, |
|
What the event records; |
|
The Spring AI message of a |
|
The JSON-serializable data of an event that is not a message; empty for a message event. |
|
Framework flags such as |
An event is appended once and never changed.
Compaction does not flag or move it: it appends a COMPACTION event that defines the view from then on (see The active view).
// A message event, timestamped now
SessionEvent event = SessionEvent.builder()
.sessionId(sessionId)
.message(new UserMessage("Hello"))
.build();
// With metadata and a deterministic timestamp
SessionEvent custom = SessionEvent.builder()
.sessionId(sessionId)
.timestamp(Instant.parse("2026-06-01T12:00:00Z"))
.message(new AssistantMessage("Response"))
.metadata("model", "gpt-5")
.build();
// An event of the application's own: no message, a payload instead
SessionEvent progress = SessionEvent.builder()
.sessionId(sessionId)
.type(EventType.of("MY_APP.PROGRESS"))
.payload("step", "research")
.build();
Event types
EventType is open: a type is its name.
The well-known types are MESSAGE, a conversation message, and COMPACTION, a compaction boundary whose payload is a CompactionBoundary.
An application defines its own with EventType.of("MY_APP.PROGRESS"): upper case, digits, underscores and dots, at most 64 characters.
Every store keeps the name and a filter by type works for any of them; only MESSAGE events take part in prompt assembly and compaction.
EventType.canCarryMessage() says whether an event of the type holds a message.
SessionService.getMessages() and getActiveMessages() skip the events that do not.
Message types
| Message | Synthetic | Meaning | Compaction |
|---|---|---|---|
|
no |
Real user input; it starts a turn |
Covered with its turn |
|
no |
The agent’s reply, with or without tool calls |
Covered with its turn, never separated from its tool results |
|
no |
A tool’s output |
Covered with its turn |
|
no |
A stored system prompt: configuration, not conversation; storing one is opt-in |
The latest one is pinned first in the view, earlier ones are covered; never summarized. See System Messages |
|
yes |
The shadow prompt that opens a summary turn |
Pinned by a plain window; replaced and covered by the next summary |
|
yes |
The summary that closes a summary turn |
As above; its text is fed to the next summary as the prior summary |
SessionEvent.isSynthetic() is true for the events a compaction wrote; EventFilter.excludeSynthetic(true) hides them from a read.
Turn
A turn is the atomic unit of a conversation: one user message plus everything up to the next user message, that is, the assistant replies, the tool calls and the tool results.
It is the unit chat memory evicts by too, and what one ChatClient call with its tool rounds amounts to; one model call within it is a round.
Turn 1: [USER "What is Spring AI?"] [ASSISTANT "Spring AI is..."] Turn 2: [USER "Can it use tools?"] [ASSISTANT (tool call)] [TOOL result] [ASSISTANT "Yes, ..."] Turn 3: [USER "Show me an example"] [ASSISTANT "Here is..."]
Working with turns rather than message counts keeps a compaction from splitting a tool call and its result, or from covering a reply while keeping the question that prompted it.
Reads follow the same rule: a lastN window is extended back to the start of the turn it lands in (SessionEvent.isTurnStart() marks the user messages), so a prompt built from a window never begins mid-turn either.
CompactionRequest.currentTurnCount() counts the non-synthetic user messages, so the shadow prompt of a summary turn does not inflate the count a TurnCountTrigger sees.
The active view
The log is append-only: an event is appended once and never edited, moved or flagged.
What changes over time is the view, the part of the log that is sent to the model.
A compaction appends a COMPACTION boundary event that names, by id, the events the view starts with: the pinned events (the latest stored system message and, for a plain window, the earlier summary), the summary events that stand in for what was covered, and the preserved recent events.
Everything appended after the boundary is in the view too; everything else before it is covered.
-
In the view: part of the context window.
-
Covered: the newest boundary does not name it: an old turn, a stored system message superseded by a newer one, or a summary that a newer summary replaces. A later boundary only ever names events from the view it compacted, so a covered event stays covered. It stays in the log, where
EventFilter.all()still reads it. -
Gone: only when the whole session is deleted, with
deleteordeleteExpiredSessions. Compaction never deletes.
The view is a projection.
A read with EventFilter.active() returns the events the newest boundary names, in its order, followed by everything appended since; CompactionBoundary.project(log) is the reference implementation.
The view is the conversation: a boundary names message events, so an event of another type logged before it is not in the view, while one logged after it is.
Read runtime state from the log, with eventTypes and without the view.
SessionService
SessionService is the API an application and the advisors use; DefaultSessionService implements it on top of a SessionRepository.
A session from creation to cleanup, driven by any integration, your own loop or the ChatClient advisors:
SessionService sessionService = DefaultSessionService.builder()
.sessionRepository(InMemorySessionRepository.builder().build())
.defaultTimeToLive(Duration.ofDays(30)) // 60 days by default
.allowSystemMessages(false) // the default; see System Messages
.build();
// 1. Create
Session session = sessionService.create(CreateSessionRequest.builder().build());
// 2. Append events; the shorthand wraps a Message in a SessionEvent
sessionService.appendMessage(session.id(), new UserMessage("What is Spring AI?"));
sessionService.appendMessage(session.id(), new AssistantMessage("Spring AI is..."));
// 3. The active view, as messages for a prompt
List<Message> history = sessionService.getActiveMessages(session.id());
// 4. The whole log, as events
List<SessionEvent> log = sessionService.getEvents(session.id(), EventFilter.all());
// 5. Compact when a trigger fires
sessionService.compact(session.id(), new TurnCountTrigger(20),
WindowCompactionStrategy.builder().lastTurns(10).build());
// 6. Delete one session, or every expired one from a scheduler
sessionService.delete(session.id());
int removed = sessionService.deleteExpiredSessions(Instant.now());
| Method | Description |
|---|---|
|
Creates a session with the request’s id, time to live and metadata.
Throws |
|
The session with the request’s id, created from the request when there is none. Safe under concurrent first use: the stored session wins. |
|
The session, or |
|
The sessions whose metadata holds the value under an indexed key, oldest first. |
|
Appends an event to the log.
A |
|
The events a filter selects, in log order; the whole log without one. |
|
The messages of the whole log, or of the active view. |
|
Evaluates the trigger on the active view and, when it fires, runs the strategy and records the result. See Compaction. |
|
Deletes the session and its events. There is no tombstone. |
|
Deletes every session that expired before the instant, with their events, and returns the count. Sessions are not swept automatically: call it from a scheduler. |
Idempotent appends
A retried appendEvent for an id that was already committed is a no-op, not an error or a duplicate, and does not advance the log’s version.
InMemorySessionRepository checks the session’s events for the id; JdbcSessionRepository looks it up before inserting.
In the JDBC store the event id is the primary key of the whole event table, so reusing an id that belongs to another session throws IllegalStateException rather than being treated as a replay.
The default id is a random UUID, so this only matters when a caller supplies its own ids.
Optimistic concurrency
SessionRepository.getEventVersion(sessionId) is a counter that advances on every append of a new event and on every successful appendEvents call.
DefaultSessionService.compact reads it, then the newest boundary, then the view, builds the boundary and the summary events from the strategy’s result, and passes the version to appendEvents(sessionId, events, expectedVersion).
When another writer changed the log in between, the compare-and-swap returns false.
If a newer boundary landed, this result is dropped: it stands on a view that is gone.
If it was an append, the result is rebased: the events appended since the view was read join the preserved tail verbatim, the same summary stands, and the write is retried, twice at most.
So an append does not waste the summary, a compaction never covers what the model was about to see, and only a session that keeps overtaking the write makes it stand down until the next pass.