Newer
Older
navi-1 / docs / sessions.md

Sessions

Session management, dual-buffer design, and context compression.

Session model (navi/core/session.py)

class Session(BaseModel):
    id: str                          # UUID
    profile_id: str                  # active profile
    user_id: str | None              # owner (null for legacy sessions)
    messages: list[Message]          # full display history — never compressed
    context: list[Message]           # LLM context — may be replaced with summary
    context_token_count: int         # accumulated tokens; reset to 0 after compression
    pinned: bool                     # pinned sessions appear first in sidebar
    name: str | None                 # auto-generated display name (set after first exchange)
    created_at: datetime
    last_active: datetime
    planning_logs: list[dict]        # raw planning phase outputs per turn (debug)

Message flags

Messages in session.messages carry optional flags beyond role/content:

Flag Default Purpose
is_context: bool True Whether the message is part of session.context. Dropped messages are marked False rather than deleted — session.context is rebuilt from messages on load ([m for m in all if m.is_context]).
is_display: bool True Whether the message is shown in the UI. Summary messages added by compression are False.
is_plan: bool False Message is a planning phase output (shown as plan card in UI, not text)
is_compression: bool False Marker message injected when context compression ran (carries the summary text, is_context=False)
is_summary: bool False A summary message replacing compressed history in session.context (role=user)
is_compression_critical: bool False Tool result kept verbatim (up to 4000 chars) by the summary formatter instead of capped
is_recall: bool False Message was generated by a scheduled recall (styled differently in UI)
`thinking: str \ None` None LLM reasoning captured during a tool-calling turn
metadata: dict {} Tool result metadata (e.g. is_image, base64, step_text)
sequence_number, elapsed_seconds, tool_call_count, token_count, tool_calls, tool_call_id, name, files, images Per-message bookkeeping (ordering, metrics, tool-call payloads, attachments)

Dual-buffer design

Two separate message lists serve different purposes:

Buffer Purpose Modified by compression?
session.messages Full display history shown in the UI Never
session.context What the LLM sees on each call Yes — old turns replaced with a summary

Tool results, image injections, and assistant messages are appended to both buffers. When compression runs, only session.context is modified.

Note: System messages are not stored in either buffer. They are injected fresh from the current profile on every LLM call via _build_context(). This makes profile switches take effect immediately.

Session store

InMemorySessionStore

Simple dict-backed store for testing.

PgSessionStore (navi/core/pg_session_store.py)

Production store backed by PostgreSQL via asyncpg.

  • create(profile_id, user_id=None) → new Session
  • get(session_id)Session | None
  • save(session) — serializes with model_dump(mode='json') (required for datetime serialization)
  • list_all(user_id=None, is_admin=False) → if is_admin: all sessions; else: sessions for user_id or legacy (user_id IS NULL) sessions
  • count_all(user_id=None, is_admin=False, search=None) → total matching sessions
  • search_list(limit, offset, user_id=None, is_admin=False, search=None, sort_by="last_active", sort_order="desc") → paginated, filtered, sorted sessions
  • delete(session_id)bool
  • list_page(user_id=None, is_admin=False, limit=50, offset=0, profile_id=None) → paginated list with has_more flag; profile_id filters to one profile
  • set_pinned(session_id, pinned)bool
  • set_name(session_id, name)bool
  • archive_old_messages(session_id, threshold) → moves messages with sequence_number < threshold out of the hot table (called by compress_and_save_session when session_messages_window is exceeded)
  • get_archived_messages(session_id, ...) → read archived messages back

Requires DATABASE_URL env variable (e.g. postgresql://user:pass@localhost/navi).

Ownership: Legacy sessions (user_id IS NULL) are accessible only to admins. New sessions created by authenticated users carry user_id. The list_all() method respects the is_admin flag to filter appropriately.

When NAVI_AUTH_ENABLED=false, every session is created with user_id = 'anonymous' and all access checks are bypassed. Querying sessions.user_id = 'anonymous' is a reliable way to identify sessions created in no-auth mode.


Context compression (navi/core/compressor.py)

Keeps the LLM context within the token budget by summarizing old conversation turns.

When it triggers

Three trigger points:

  1. Pre-turn (in run_stream()_compression_events_preturn): before the first LLM call of a turn, estimates tokens via estimate_context_tokens(session.context) (not the stored context_token_count) and compresses when tokens >= num_ctx * threshold. Guarded by would_compress() so CompressionStarted is only emitted when the partition can actually shrink the stored context.
  2. Mid-turn (in run_stream()_compression_events_midturn, every iteration > 0): estimates tokens via real_baseline_estimate(session.context, preflight_ctx) — real prompt_tokens from the previous call (bulk) + a heuristic delta for messages appended since — and compresses with keep_recent_messages=max(12, context_keep_recent*2). This is what keeps long autonomous loops (one user message + many tool iterations = one turn) from exhausting the window.
  3. Post-turn (via CompressionWorker): after StreamEnd, the worker re-checks (using the real context_tokens from the last call) and compresses if needed, mirroring the mid-turn keep_recent_messages.

A fourth, on-demand path is forced /compact (compact_stream()): the client sends {"type":"compact"}, bypassing the threshold entirely; emits CompressionStarted + ContextCompressed; raises NothingToCompactError when there is nothing to compress.

Config values (settings):

  • context_compression_enabled: bool = True
  • context_compression_threshold: float = 0.70 — trigger at 70% of ollama_num_ctx
  • context_keep_recent: int = 8 — keep last N conversational turns verbatim
  • context_summary_temperature: float = 0.3
  • context_summary_max_tokens: int = 4000 — max output tokens for the summary LLM call
  • output_reserve_tokens: int = 2048 — headroom reserved for the response in check_context_size
  • context_message_token_budget: int = 0 — per-message view truncation budget (0 = auto, ollama_num_ctx // 6)

Per-profile overrides (AgentProfile): compression_keep_recent, compression_max_tokens, compression_prompt_file — applied inside compress_context / compress_session / the summary system prompt. navi_code uses compression_keep_recent=12.

Context size guard

Before every LLM call, check_context_size(built_ctx, session_context=session.context) raises ContextTooLargeError when the estimated input exceeds ollama_num_ctx - output_reserve_tokens. The total uses real_baseline_estimate() when a baseline is available (real bulk + heuristic delta), falling back to the chars // 3 + imgs*500 estimate. The error is surfaced to the user as a synthesized assistant response + StreamEnd rather than a raw system error.

Compression algorithm

compress_context(context, llm, model, temperature, keep_recent, *, max_tokens=None, keep_recent_messages=None, profile=None):

  1. Resolve effective keep_recent/max_tokens from profile.compression_* overrides. Partition messages into to_summarize (old turns) and to_keep (recent keep_recent turns).
    • A "turn" = one user message + all following assistant/tool messages up to the next user message.
    • Tool call groups (assistant + results) are never split across the partition.
    • Adaptive partitioning: _turn_importance scores each turn; an important old turn can be swapped into the kept set in place of a filler-recent one.
    • Intra-turn fallback (partition_current_turn_messages, when keep_recent_messages is set): for a single long turn, keeps the current request + newest N messages verbatim and summarizes older messages from the same turn.
  2. Meta-summary: if to_summarize contains multiple existing summaries totaling > _META_SUMMARY_THRESHOLD = 8000 chars, consolidate them into one via _meta_summarize first so old summaries don't crowd the summarizer input.
  3. Format to_summarize as plain text. Tool calls are shown as compact previews (max 120 chars for args). Critical tool results (is_compression_critical=True or critical tool names) survive verbatim up to 4000 chars; others are capped at 300 chars. Base64 images are collected for vision models.
  4. Truncate formatted input to _MAX_SUMMARY_INPUT_CHARS = 24_000 chars.
  5. Call llm.complete() with think=False to produce a bullet-point summary.
  6. Replace to_summarize with a single summary message (role=user, is_summary=True).
  7. Return system_msgs + [summary_msg] + to_keep.

compress_session wraps compress_context with retry + a token-budget hard-truncate fallback (_hard_truncate): if the LLM summarization fails twice, it drops oldest turns (keeping system + newest whole turns) until under _HARD_TRUNCATE_TOKEN_FRAC = 0.5 of the window — a last resort with no LLM call.

compress_and_save_session then mutates the session: replaces session.context, marks dropped messages is_context=False, appends the summary (is_display=False) and an is_compression=True system marker to session.messages, resets context_token_count, archives old messages when session_messages_window is exceeded, and persists. It also clears the real-token baseline (the context just shrank).

If compression fails, the exception propagates to CompressionWorker, which logs a warning and continues — compression failure is non-fatal.

What is never compressed

  • session.messages — full history is always intact.
  • The last context_keep_recent conversational turns (or the intra-turn recent window).
  • System messages (never stored in context anyway).

Events

  • CompressionStarted — status signal (context_tokens, max_context_tokens).
  • ContextCompressed — result (summary, messages_before, messages_after, context_tokens, max_context_tokens). The TUI renders summary as a Context compressed: N → M messages card.

Session file uploads

Files uploaded via POST /sessions/{id}/files are stored in session_files/{session_id}/.

  • Max size: session_files_max_size_mb (default: 200 MB)
  • A background cleanup_loop (started on FastAPI startup) deletes orphaned session directories after their DB session no longer exists.
  • Executable files (.sh, .py, .exe, etc.) are rejected.
  • Duplicate filenames get a numeric suffix.

When files are uploaded via the UI, their paths are appended to the user message content:

[Uploaded files on disk:
- filename.pdf → session_files/{id}/filename.pdf]

This lets the agent use filesystem or code_exec to access the files.

workspace/ is separate from session files. Use workspace/ for persistent private working files and session_files/{session_id}/ for files that belong to the current chat.

Two tools expose session files to the user:

  • share_file copies an existing local file into the session directory and returns a download link. It requires an absolute source path and has its own size limit (SHARE_FILE_MAX_SIZE_MB, default 1024 MB).
  • content_publish registers a file that already exists in the session directory and exposes it as an inline viewer/card through /sessions/{id}/files/{filename}. It does not copy files.

Debug endpoints

  • GET /sessions/{id}/context — returns what the LLM actually sees (may differ from messages after compression).
  • GET /sessions/{id}/planning — returns session.planning_logs: raw planning phase outputs per turn.