Skip to content

Session Concepts

Spring AI Session introduces four closely related concepts that together describe how a conversation is stored, structured, and managed over time.


Session

Session is the identity and lifecycle container for a single, continuous conversation between a user and an agent. It is an immutable value object — it holds only metadata. The event log is stored separately in the repository and fetched on demand.

Field Description
id Unique session identifier
userId Owning user or agent — required, used for isolation
createdAt Creation timestamp
expiresAt Expiry instant; defaults to the configured default time-to-live (60 days) from creation; null means no expiry.
metadata Arbitrary key/value pairs (model info, tags, etc.)

Keeping Session metadata-only means it can be passed across boundaries cheaply, and compaction strategies receive the event list as an explicit parameter rather than extracting it from the session object.

Sessions are created through SessionService, which is the primary API for the entire lifecycle:

SessionService service = DefaultSessionService.builder()
    .sessionRepository(InMemorySessionRepository.builder().build())
    .build();

Session session = service.create(
    CreateSessionRequest.builder()
        .id("my-session-id")           // optional; a UUID is generated when omitted
        .userId("alice")
        .timeToLive(Duration.ofHours(2)) // optional; defaults to 60 days
        .metadata("agentType", "research-assistant")
        .build()
);

SessionEvent

SessionEvent is an immutable record that wraps a Spring AI Message type. It adds only what Message intentionally omits: identity, ownership, ordering, and framework flags.

Field Description
id Unique identity per event (UUID by default)
sessionId Ownership / isolation
timestamp Chronological ordering (Instant.now() by default)
message The Spring AI message — no duplication of content
metadata Framework flags such as METADATA_SYNTHETIC and METADATA_COMPACTION_SOURCE
branch Dot-separated agent path (e.g. "orch.researcher"); null for root-level events

Message types

Message type SessionEvent.isSynthetic() Meaning
UserMessage false Real user input
AssistantMessage (no tool calls) false Agent response
AssistantMessage (with tool calls) false Agent tool invocation
ToolResponseMessage false Tool output
UserMessage true Synthetic shadow prompt opening a summary turn
AssistantMessage true Synthetic summary text closing a summary turn

Building events

// Root event (no branch) — visible to all agents; timestamped at Instant.now()
SessionEvent event = SessionEvent.builder()
    .sessionId(sessionId)
    .message(new UserMessage("Hello"))
    .build();

// Branched event — attributed to a specific sub-agent
SessionEvent branched = SessionEvent.builder()
    .sessionId(sessionId)
    .message(new AssistantMessage("Research result..."))
    .branch("orch.researcher")
    .build();

// With metadata
SessionEvent custom = SessionEvent.builder()
    .sessionId(sessionId)
    .message(new AssistantMessage("Response"))
    .metadata("model", "gpt-4o")
    .metadata("latencyMs", 230)
    .build();

// Deterministic timestamp (useful for tests)
SessionEvent deterministic = SessionEvent.builder()
    .sessionId(sessionId)
    .timestamp(Instant.parse("2025-06-01T12:00:00Z"))
    .message(new UserMessage("Hello"))
    .build();

Builder defaults: id is a random UUID, timestamp is Instant.now(), metadata is empty, branch is null. Only sessionId and message are required.


Turn

A turn is the atomic unit of conversation:

One UserMessage plus all subsequent events (assistant replies, tool calls, tool results) up to the next UserMessage.

Working with turns rather than raw message counts prevents compaction from splitting a tool-call/result pair, or from removing an assistant reply while keeping the user question that prompted it.

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..."]

Turn count is measured via CompactionRequest.currentTurnCount(), which counts only non-synthetic, root-level (branch == null) USER events. Synthetic and sub-agent USER messages on named branches are excluded so that multi-agent sessions do not inflate the count used by TurnCountTrigger.


Synthetic Summary Turn

When RecursiveSummarizationCompactionStrategy compacts a session it replaces archived events with two synthetic events that form a coherent conversation turn:

[USER  / synthetic] "Summarize the conversation we had so far."
[ASST  / synthetic] "The user asked about topic X. The assistant explained..."
[USER  / real     ] "Next actual question"
[ASST  / real     ] "Next actual response"

This mirrors the OpenAI Agents SDK shadow-prompt pattern and ensures that downstream models always see a valid user↔assistant alternation.

All compaction strategies treat synthetic events as opaque — they separate them from real events before processing, preserve them, and place them first in the compacted output.

Build a synthetic summary turn explicitly:

Instant now = Instant.now();
List<SessionEvent> summaryTurn = List.of(
    SessionEvent.builder()
        .sessionId(sessionId)
        .timestamp(now)
        .message(new UserMessage("Summarize the conversation we had so far."))
        .metadata(SessionEvent.METADATA_SYNTHETIC, true)
        .metadata(SessionEvent.METADATA_COMPACTION_SOURCE, "recursive-summarization")
        .build(),
    SessionEvent.builder()
        .sessionId(sessionId)
        .timestamp(now)
        .message(new AssistantMessage("The user asked about X. The assistant..."))
        .metadata(SessionEvent.METADATA_SYNTHETIC, true)
        .metadata(SessionEvent.METADATA_COMPACTION_SOURCE, "recursive-summarization")
        .build()
);

Both events share the same Instant so the user/assistant pair is treated as an atomic unit.


Architecture Overview

Spring AI Session API Classes

Why Session carries no events

Storing the event log inside Session would force every consumer that needs to mutate the list (compaction, archiving) to hold a mutable reference inside what is meant to be an immutable value object. By keeping Session as pure metadata, all event mutations go through dedicated repository methods (appendEvent, compactEvents) and SessionService operates on the event list as an explicit parameter.

Archiving instead of deleting

Compaction never deletes the events it removes from the active context window. Instead it marks them archived (SessionEvent.isArchived()) via compactEvents, leaving the full verbatim history in the log. The active context window — what SessionMemoryAdvisor injects into the prompt — is the EventFilter.active() view (excludeArchived = true), while Recall Storage searches (EventFilter.keywordSearch(...)) deliberately span the whole log, archived events included. This is what makes the MemGPT recall pattern work: the agent can surface any prior exchange even after it has been summarized out of context. In the JDBC repository, events newly marked archived are updated in place (their row is never rewritten); only the active window is replaced with the new retained events on each compaction pass, so the growing archived history is never re-read or re-written.

Optimistic concurrency

SessionRepository exposes getEventVersion(sessionId) — a monotonically increasing counter incremented on every appendEvent and compactEvents call. Callers read this version before fetching events, then pass it to compactEvents(sessionId, archivedEvents, retainedEvents, expectedVersion). If another writer mutated the log in the interval, the CAS returns false — the caller treats this as a no-op rather than retrying. Durable implementations (JDBC, Redis) should map this to a database-level optimistic-lock column or a Redis WATCH.

Event ordering

Events are returned in insertion (logical conversation) order, not by wall-clock timestamp. The JDBC implementation persists a monotonic seq column for this purpose so that a synthetic compaction summary — stamped with the compaction time — stays correctly positioned ahead of the older active-window events it precedes.


Session Lifecycle

SessionService service = DefaultSessionService.builder()
    .sessionRepository(InMemorySessionRepository.builder().build())
    .build();

// 1. Create
Session session = service.create(
    CreateSessionRequest.builder()
        .userId("alice")
        .build()
);

// 2. Append events (shorthand wraps a Message in a SessionEvent automatically)
service.appendMessage(session.id(), new UserMessage("What is Spring AI?"));
service.appendMessage(session.id(), new AssistantMessage("Spring AI is..."));

// 3. Retrieve as Message list (for passing directly to an LLM)
List<Message> history = service.getMessages(session.id());

// 4. Retrieve as SessionEvent list (for filtering, inspection, compaction)
List<SessionEvent> events = service.getEvents(session.id());

// 5. Delete a single session
service.delete(session.id());

// 6. Delete all expired sessions (call from a scheduler)
int removed = service.deleteExpiredSessions(Instant.now());

Deleted sessions are removed from the repository entirely — there is no tombstone state.

Expiry cleanup

Sessions are not automatically swept — deleteExpiredSessions(Instant) must be called explicitly. Wire it to a scheduler in your application:

@Scheduled(fixedRate = 3_600_000) // every hour
void sweepExpiredSessions() {
    int removed = sessionService.deleteExpiredSessions(Instant.now());
    log.info("Swept {} expired sessions", removed);
}

deleteExpiredSessions finds all sessions whose expiresAt is before the supplied instant and deletes them one by one. It returns the count of sessions removed.


Package Structure

org.springframework.ai.session          (Java package — unchanged from upstream)
├── Session.java                        – immutable metadata-only value object
├── SessionEvent.java                   – immutable wrapper around a Spring AI Message
├── SessionService.java                 – primary lifecycle + compaction API
├── SessionRepository.java              – persistence SPI
├── CreateSessionRequest.java           – builder for session creation parameters
├── EventFilter.java                    – composable criteria for event retrieval
├── MessageFilter.java                  – composable predicate for what gets persisted
├── DefaultSessionService.java          – default SessionService implementation
├── InMemorySessionRepository.java      – ConcurrentHashMap-backed repository
├── advisor/
│   └── SessionMemoryAdvisor.java       – ChatClient advisor with auto-compaction
├── compaction/
│   ├── CompactionRequest.java          – (session, events, eventCount, turnCount)
│   ├── CompactionResult.java           – compacted events + archived events + metrics
│   ├── CompactionStrategy.java         – strategy SPI
│   ├── CompactionTrigger.java          – trigger SPI
│   ├── CompactionUtils.java            – shared event formatter + turn-boundary snapping
│   ├── CompositeCompactionTrigger.java – OR-composite of triggers
│   ├── TurnCountTrigger.java
│   ├── TokenCountTrigger.java
│   ├── SlidingWindowCompactionStrategy.java
│   ├── TurnWindowCompactionStrategy.java
│   ├── TokenCountCompactionStrategy.java
│   └── RecursiveSummarizationCompactionStrategy.java
└── tool/
    └── SessionEventTools.java          – @Tool conversation_search (Recall Storage)