session

package
v0.0.0-...-fe80ad5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 9 Imported by: 0

README

session — Cross-request conversation lifecycle

session provides the generic, cross-request lifecycle for persisting a conversation history on top of a memory.Memory store. It is the counterpart of the intra-run optimization middleware (contextopt): the middleware compacts the message list within a single runner.Run and persists nothing, while this package owns the durable, listable/deletable history between requests.

It encapsulates:

  • per-session locking — one logical lock per "<userId>:<id>", with ref-counted cleanup so the lock map never leaks entries (fixes the classic "one mutex per session id forever" leak);
  • the turn lifecycleBeginTurn → [Condense] → Window → CommitAssistant;
  • optional non-destructive condensation — an anchored summary driven by a token threshold, reusing an injected Summarizer.

The package is intentionally policy-free: filtering of agent events, SSE wiring and the choice of Summarizer implementation are injected by the caller.

Usage

mem := file.NewFileMemory(file.FileMemoryConfig{
    Dir:             "/var/lib/app/history",
    TokenCounter:    counter,   // share the SAME counter everywhere (see below)
    MaxWindowTokens: 24_000,
})

// Reuse contextopt's LLM summarizer: a single instance feeds both the
// intra-run middleware and this cross-request condenser.
summarizer := contextopt.NewModelSummarizer(chatModel)

sm, err := session.NewSessionManager(session.Config{
    Memory:            mem,
    Summarizer:        summarizer, // nil disables condensation
    CondenseThreshold: 16_000,     // tokens; <= 0 disables
    WindowBudget:      24_000,     // tokens fed to the model
    TokenCounter:      counter,
})
if err != nil {
    return err
}

Per request:

turn, err := sm.BeginTurn(userID, convID, schema.UserMessage(userInput))
if err != nil {
    return err
}
// The user message is held pending (visible to Window/Condense) and persisted
// only by CommitAssistant. Discard persists nothing, so an aborted run never
// leaves a dangling user message and a retry cannot duplicate it.
defer turn.Discard() // releases the lock if CommitAssistant is not reached

if _, err := turn.Condense(ctx); err != nil { // appends an anchored summary at threshold
    return err
}

msgs := turn.Window(0) // [last summary + recent], bounded by WindowBudget
// ... run the agent with msgs, stream to the client, build the assistant reply ...

if err := turn.CommitAssistant(assistantMsg); err != nil { // append + unlock
    return err
}

Management:

ids, _ := sm.ListConversations(userID)
_ = sm.DeleteConversation(userID, convID) // also drops the lock entry

Interop with contextopt

Persisted summaries use the shared marker memory.SummaryMarkerKey (via memory.NewSummaryMessage), so they are recognized natively by contextopt (trimBeforeLastSummary, lastSummaryText) and vice-versa. The Summarizer interface here is structurally identical to contextopt.Summarizer, so one contextopt.NewModelSummarizer(...) instance satisfies both — no import dependency on contextopt is introduced.

Avoiding double summarization cost

When both layers are active, a turn must never pay the LLM summarization cost twice. Guarantee it by enforcing, together:

  1. the same TokenCounter in session.Config, the memory store, and the contextopt middleware;
  2. WindowBudget ≤ the middleware's usable window (MaxInputTokens, else ContextLimit − ReservedTokens);
  3. shared summary markers (already the case).

Then the post-Condense window [summary + tail] cannot overflow on the first model call, so the middleware does not re-summarize the already-condensed history. Any later middleware summarization is genuine intra-run overflow (incremental via previousSummary), not a duplicate.

A runnable, self-contained demonstration (no API key required) lives in ../examples/contextopt_persistent: it wires both layers, instruments the shared Summarizer, and asserts the cost is paid at most once per turn.

API

Type / method Purpose
NewSessionManager(Config) Validate config and build the manager.
SessionManager.BeginTurn(userId, id, userMsg) Lock + load/create; user message held pending → *Turn.
SessionManager.ListConversations(userId) Forward to the store.
SessionManager.DeleteConversation(userId, id) Delete + drop the lock entry.
Turn.Window(budget) [last summary + recent], bounded by budget (0 = WindowBudget).
Turn.Condense(ctx) Append an anchored summary when the window reaches the threshold.
Turn.CommitAssistant(msg) Persist pending user message + assistant message, release lock (double-commit guarded).
Turn.Discard() Release lock without persisting (drops pending user message; idempotent; use with defer).
Turn.Conversation() Access the underlying memory.Conversation.

Documentation

Overview

Package session provides the generic, cross-request lifecycle for persisting a conversation history on top of a memory.Memory store.

It encapsulates:

  • per-session locking (one logical lock per "<userId>:<id>") with ref-counted cleanup so the lock map never leaks entries;
  • the turn lifecycle BeginTurn -> (Condense) -> Window -> CommitAssistant;
  • optional non-destructive condensation (anchored summary) driven by a token threshold, reusing an injected Summarizer.

The package is intentionally policy-free: filtering of agent events, SSE wiring and the choice of Summarizer implementation are injected by the caller. In particular the Summarizer interface is structurally compatible with contextopt.Summarizer, so a single contextopt.NewModelSummarizer(...) instance can be shared between the intra-run optimization middleware and this cross-request condenser without creating an import dependency.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Check

Check performs a health check on the session manager, verifying that BeginTurn, AppendAssistant, and EndTurn operate correctly.

Types

type Config

type Config struct {
	// Memory is the underlying cross-request store. Required.
	Memory memory.Memory `validate:"required" jsonschema:"description=Underlying cross-request memory store"`

	// Summarizer, when non-nil, enables condensation: Condense generates an
	// anchored summary and appends it to the store. When nil, Condense is a no-op.
	Summarizer Summarizer `jsonschema:"description=Summarizer for condensation, nil disables summarization"`

	// CondenseThreshold is the token count of the current window at (or above)
	// which Condense triggers a summarization. <= 0 disables condensation.
	CondenseThreshold int `validate:"gte=0" jsonschema:"description=Token threshold at which Condense triggers summarization, 0 disables"`

	// WindowBudget is the default token budget passed to GetWindow when a turn
	// requests its window (and the budget used to evaluate condensation). 0 means
	// "use the store's own default" (e.g. FileMemoryConfig.MaxWindowTokens).
	WindowBudget int `validate:"gte=0" jsonschema:"description=Default token budget for GetWindow, 0 uses store default"`

	// TokenCounter estimates the token count of a window to decide when to
	// condense. Defaults to memory.DefaultTokenCounter.
	//
	// To guarantee a turn never pays the LLM summarization cost twice, inject the
	// SAME counter here, in the memory store, and in the intra-run optimization
	// middleware (see the plan's anti-double-cost invariant).
	TokenCounter memory.TokenCounter `jsonschema:"description=Token estimator for condensation decisions, defaults to memory.DefaultTokenCounter"`
}

Config configures a SessionManager.

type SessionManager

type SessionManager struct {
	// contains filtered or unexported fields
}

SessionManager owns the per-session locking and turn lifecycle on top of a memory.Memory store.

func NewSessionManager

func NewSessionManager(cfg Config) (*SessionManager, error)

NewSessionManager validates cfg and returns a SessionManager.

func (*SessionManager) BeginTurn

func (sm *SessionManager) BeginTurn(userId, id string, userMsg *schema.Message) (*Turn, error)

BeginTurn acquires the session lock and loads (or creates) the conversation. The user message is NOT persisted yet: it is held on the Turn and only written durably by CommitAssistant. This keeps it visible to Window/Condense during the turn while guaranteeing that an aborted turn (Discard) persists nothing — so a failed run never leaves a dangling user message, and a retry cannot duplicate it.

The returned Turn MUST be released via CommitAssistant or Discard (typically `defer turn.Discard()`), otherwise the session stays locked.

func (*SessionManager) DeleteConversation

func (sm *SessionManager) DeleteConversation(userId, id string) error

DeleteConversation deletes the conversation from the store. It briefly acquires the session lock to avoid racing an in-flight turn; the ref-counted cleanup then removes the now-unused lock entry, preventing a memory leak.

func (*SessionManager) ListConversations

func (sm *SessionManager) ListConversations(userId string) ([]string, error)

ListConversations forwards to the underlying store.

type Summarizer

type Summarizer = summarizer.Summarizer

Summarizer is a type alias for the summarizer.Summarizer interface, enabling pluggable condensation strategies.

type Turn

type Turn struct {
	// contains filtered or unexported fields
}

Turn represents a single locked request/response cycle on one conversation.

Lifecycle: BeginTurn -> [Condense] -> Window -> CommitAssistant (or Discard on failure). The handle is single-use and not safe for concurrent use.

func (*Turn) CommitAssistant

func (t *Turn) CommitAssistant(msg *schema.Message) error

CommitAssistant persists the pending user message (from BeginTurn) followed by the assistant message (when non-nil), then releases the session lock. It guards against a double commit; the lock release is idempotent.

func (*Turn) Condense

func (t *Turn) Condense(ctx context.Context) (bool, error)

Condense generates and persists an anchored summary when the current window reaches the configured token threshold. It is a no-op (returns false) when no Summarizer is configured or the threshold is disabled (<= 0) or not reached.

The threshold check includes the pending user message (full window); the summarization itself covers only the persisted history — the pending user is the new, unprocessed turn that the model will see separately. This prevents the model from receiving the current question twice (once in the summary and once explicitly).

The produced summary is appended non-destructively (the full log is preserved); subsequent windows start from this new summary. Summarization reuses the injected Summarizer and the marker shared with contextopt (memory.SummaryMarkerKey), so persisted summaries interoperate natively with the intra-run optimization middleware.

func (*Turn) Conversation

func (t *Turn) Conversation() memory.Conversation

Conversation exposes the underlying conversation for advanced/read-only use. Note: the current turn's user message is pending and not part of the conversation's persisted messages until CommitAssistant.

func (*Turn) Discard

func (t *Turn) Discard()

Discard releases the session lock without persisting the pending user message or any assistant message. It is safe to call multiple times and after CommitAssistant (no-op), making it ideal for `defer turn.Discard()`.

func (*Turn) Window

func (t *Turn) Window(budget int) []*schema.Message

Window returns the windowed history [last summary + recent messages] to feed to the model, with the current turn's (not-yet-persisted) user message appended. When budget <= 0, the SessionManager's configured WindowBudget is used (which itself may fall back to the store's default).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL