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.

The core session types

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

id

The session’s identifier: the request’s, or a random UUID.

createdAt

When the session was created.

expiresAt

When the session expires. A session the service creates always has one: the request’s timeToLive, or the service’s default of 60 days. A Session built directly has none, so it never expires, unless its builder sets one.

metadata

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

id

The event’s identity, a random UUID by default. Event ids are unique across sessions.

sessionId

The session the event belongs to.

timestamp

When the event was recorded, Instant.now() by default.

type

What the event records; MESSAGE, the default, for a conversation message.

message

The Spring AI message of a MESSAGE event. getMessage() throws for an event without one; test with hasMessage() first.

payload

The JSON-serializable data of an event that is not a message; empty for a message event.

metadata

Framework flags such as METADATA_SYNTHETIC, METADATA_COMPACTION_SOURCE and METADATA_COMPACTION_ID, and whatever the application adds.

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

UserMessage

no

Real user input; it starts a turn

Covered with its turn

AssistantMessage

no

The agent’s reply, with or without tool calls

Covered with its turn, never separated from its tool results

ToolResponseMessage

no

A tool’s output

Covered with its turn

SystemMessage

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

UserMessage

yes

The shadow prompt that opens a summary turn

Pinned by a plain window; replaced and covered by the next summary

AssistantMessage

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.

The lifecycle of an event
  • 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 delete or deleteExpiredSessions. 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:

A session from creation to cleanup
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

create(CreateSessionRequest)

Creates a session with the request’s id, time to live and metadata. Throws SessionAlreadyExistsException for a taken id.

findOrCreate(CreateSessionRequest)

The session with the request’s id, created from the request when there is none. Safe under concurrent first use: the stored session wins.

findById(id)

The session, or null.

findByMetadata(name, value)

The sessions whose metadata holds the value under an indexed key, oldest first.

appendEvent(event), appendMessage(id, message)

Appends an event to the log. A SystemMessage is rejected unless allowSystemMessages(true) is set.

getEvents(id, filter), getEvents(id)

The events a filter selects, in log order; the whole log without one.

getMessages(id), getActiveMessages(id)

The messages of the whole log, or of the active view.

compact(id, trigger, strategy)

Evaluates the trigger on the active view and, when it fires, runs the strategy and records the result. See Compaction.

delete(id)

Deletes the session and its events. There is no tombstone.

deleteExpiredSessions(before)

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.

Ordering

Events are returned in insertion order, not by wall-clock timestamp. The JDBC repository keeps a monotonic seq column for this, so the summary events a compaction writes, stamped with the compaction time, sit where the boundary places them.