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 by the summary formatter (up to 4000 chars, then head+tail halves) instead of the 800-char preview cap
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)

Stored service records

A few role="system" messages are persisted — they record something that happened in the session rather than carry a prompt: the compression notice (is_compression=True) and the Synapse event record (metadata["source"] == "synapse_event", carrying the dispatcher's task text for one platform event). They are kept out of session.context reaching the model by default: ContextBuilder.build() filters stored system messages through an allowlist (_LLM_SYSTEM_SOURCES), and only its members (task_note, synapse_event) are handed to the LLM. A stored system message outside that allowlist shows in the transcript and never reaches the model. The webclient renders the Synapse record as its own card; every other stored system message is skipped at display time.

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: Prompt 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. The service records described above are the exception: they are stored messages, but only those the allowlist admits ever reach the model.

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
  • list_all_summaries(user_id=None, is_admin=False, special=None) → the same rows and order as list_all, but history is not loaded: display_message_count and preview come from one aggregate query. List views use it, so GET /sessions costs ~10–40 ms instead of ~300 ms and never allocates the hot history.
  • 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. Like list_page, it returns summaries: callers that need "where does the term appear" ask search_matches.
  • search_matches(session_ids, search) → {session_id: (display indices, snippet)}, one aggregate over the given sessions instead of their loaded histories. Indices are positions in the display history, the numbering clients scroll to.
  • 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).

Search speed. search_list matches content ILIKE '%q%' over session_messages and session_messages_archive, which without an index is a sequential scan of every message (~400 ms on a 29k-row dev clone, ~1 s on the 391 MB prod table). The store creates GIN trigram indexes on both content columns when pg_trgm is already installed — it does not create the extension itself, because that needs privileges the service user may not have. Install it once per database:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

The indexes are built on the next store init (CREATE INDEX IF NOT EXISTS, a few seconds on 150 MB) and session search drops to tens of milliseconds.

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.

Token accounting and the window

Every window decision — the trigger, the hysteresis target, the hard-truncate budget, check_context_size, image budgeting — is made in real prompt tokens, not in a character heuristic.

ContextCompressor.estimate_tokens(context, model) prices a message list through navi/core/token_calibration.py:

  • The calibration fits a line tokens ≈ overhead + chars × tokens_per_char to the last 8 observations of (chars of the built context, real prompt_eval_count), recorded by record_real_baseline() whenever a real count comes back from Ollama. It needs at least two observations whose sizes differ by 1.25× or more, and rejects an implausible slope (outside 1 token/char … 1 token per 4 chars) — which in practice only happens when a prompt was truncated server-side.
  • Without a usable line it falls back to chars / 1.8. The old chars // 3 was measured ~1.8× below the real prompt size on prod: "65% of the window" was met on paper while the real prompt stayed above the window, so compression re-fired on the next messages (48 passes in a day on one session).
  • Images are priced one by one from their own header (navi/core/image_tokens.py): ceil(w/32) × ceil(h/32) + 2 after a downscale to at most 2048² px, clamped to 256…4096, measured against qwen3.6. A 1024×1024 image costs ~1026 tokens and a 2048² one ~4098; the flat 500 this code used to charge undercounted them 2×–8×, so a session full of screenshots never reached the 90% trigger. An image whose header cannot be read falls back to context_image_tokens (1024). Calibration is per model (calibration_for(model)), so an observation from one model never prices another, and it lives in a module-level registry because the post-turn worker builds a fresh compressor per pass.
  • A prompt that carried images is not fed to the calibration fit: image bytes are not in chars, so their tokens would land in the fitted intercept/slope and skew every later text estimate for that model.

What is counted is the view the model is sent. real_baseline_estimate() prices the delta messages after truncate_oversized(), and compress_session/would_compress wrap their estimator in it too. The raw stored context may hold a megabyte-long tool result that build() caps per message; counting it as ~167k tokens made the pre/mid-turn gates fire for work the partition intentionally refuses (a single message cannot be split) and, worse, made check_context_size end the run with "context too large" over a prompt the model never received. The reported failure — the first operation fills the window, compression does not happen and the session dies — was exactly that mismatch: the check printed the truncated size (~18k) in its own error text while deciding on the raw size (167k).

The window is a parameter, not a global. AgentProfile.context_window (or settings.ollama_num_ctx when unset) is resolved once per turn (Agent._window_for) and threaded through compress_and_save_session(..., window=) → compress_context / _hard_truncate / check_context_size, and is also sent to Ollama as num_ctx. Without this, a model with a larger real window than ollama_num_ctx is compressed against the wrong budget.

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 real_baseline_estimate(session.context, session.context) (real prompt_eval_count from the last call + calibrated delta for the messages appended since it; estimate_tokens only when no baseline exists yet) and adds the tokens of the incoming message — including its images — which is not yet in session.context. Compresses when tokens >= window * threshold. Guarded by would_compress(..., current_tokens=estimated_tokens, model=...) so CompressionStarted is only emitted when the partition can actually shrink the stored context; the dry run reuses the caller's real-token count instead of re-deriving it with a different heuristic.
  2. Mid-turn (in run_stream() → _compression_events_midturn, every iteration > 0): estimates tokens via real_baseline_estimate(session.context, preflight_ctx) 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 ctx.context_tokens from the last call and WorkerContext.max_context_tokens as the window) and delegates to compress_and_save_session(reason="postturn", window=ctx.max_context_tokens) with the mid-turn keep_recent_messages — the same pipeline (retry, hard-truncate, safety net, archiving) as the pre/mid-turn paths. It also compresses unconditionally when ctx.context_truncated is set, because a truncated prompt reports about half the window and the threshold can never see it (see Truncated prompts below).

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.90 — trigger at 90% of the active window (real tokens)
  • context_compression_target: float = 0.65 — hysteresis target: after compression the kept region should fit ~65% of the active window, so the trigger doesn't re-fire a few messages later
  • context_keep_recent: int = 4 — keep last N conversational turns verbatim
  • context_summary_temperature: float = 0.3
  • context_summary_max_tokens: int = 6000 — 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, active window // 6)
  • context_image_tokens: int = 1024 — price of one image whose dimensions cannot be read from its header; readable images are priced by area

Per-profile overrides (AgentProfile): context_window (the window itself), compression_keep_recent, compression_max_tokens, compression_prompt_file — the compression ones are 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, model=profile.model, window=self._window_for(profile)) raises ContextTooLargeError when the estimated input exceeds window - output_reserve_tokens. The total uses real_baseline_estimate() when a baseline is available (real bulk + calibrated delta), falling back to estimate_tokens() (calibrated line, chars / 1.8 without one, plus the per-image cost described above). output_reserve_tokens is capped at a quarter of the window, so a deliberately small test/profile window does not fail every check. The error is surfaced to the user as a synthesized assistant response + StreamEnd rather than a raw system error.

Truncated prompts

Ollama does not error on a prompt bigger than num_ctx: it silently evaluates about half the window, drops the head of the prompt (system prompt included) and reports only what it evaluated in prompt_eval_count. Recording that count as the session baseline pinned the session at ~50% — every later estimate started from the truncated number, the 90% trigger never fired, and the model kept receiving a prompt whose beginning was gone («сессия заполнена», while Navi's own meter said 50%). record_real_baseline(..., sent_tokens=…) therefore compares the reported count with our estimate of the prompt that was sent; below 70% (_TRUNCATION_RATIO) nothing is recorded, the previous honest baseline survives, and the compressor raises a context_truncated flag that the post-turn worker consumes to compress regardless of the threshold. The agent also keeps the session's token count at the honest estimate rather than the truncated one, so the UI meter does not drop after a truncated call.

Compression algorithm

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

  1. Resolve effective keep_recent/max_tokens from profile.compression_* overrides, then run _plan_compression(context, keep_recent, keep_recent_messages, window=..., estimate=...) — the single decision point shared with would_compress() (dry-run), so the prediction and the real compression can never drift apart. It partitions messages into to_summarize (old turns) and to_keep (recent keep_recent turns), shrinks keep_recent until the kept region fits window × context_compression_target measured with estimate in real tokens (turn-based mode), and retries with keep_recent_messages=2 when the mid-turn partition found nothing. window/estimate default to settings.ollama_num_ctx and estimate_context_tokens.
    • 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 ≈ 10_600 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, then head+tail halves (2000+2000) instead of collapsing to the non-critical preview; non-critical results are capped at an 800-char preview. Base64 images are collected for vision models up to an image budget (window × 0.25, priced per image); images over it are left out of the call, while their [+ N image(s)] note still tells the summarizer they existed. Without the cap, a dozen screenshots made a summarization prompt that could not fit its own window, was silently truncated, and produced a summary written as if the history had been read.
  4. Truncate formatted input to _MAX_SUMMARY_INPUT_CHARS = 32_000 chars, keeping head (75%, oldest messages) + tail (25%, the messages closest to the kept window) — a head-only cut used to silently drop the newest summarized work.
  5. Call llm.complete() with think=False to produce a bullet-point summary. The summary prompt asks the model to end with one KEYS: item, item, … line — the breadcrumbs of the compressed history (names, commands, paths, config keys, amounts, tickets, decisions, links). split_keys_line removes that line from the summary text and returns the keys; a summary without it comes back unchanged.
  6. Replace to_summarize with a single summary message (role=user, is_summary=True), carrying the keys in metadata["breadcrumbs"].
  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(session, session_store, ..., window=...) 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 to the calibrated estimate_tokens(new_context), archives old messages when session_messages_window is exceeded, persists, and logs compressor.compressed with the trigger reason (preturn / midturn / postturn / forced). It also clears the real-token baseline (the context just shrank).

The demotion is persisted explicitly. SessionStore.save() only writes flags for rows present in session.messages; an older summary is context-only (is_display=False), so flipping is_context on it in memory never reached the database and the summary came back on the next load — on prod that left up to 15 live summaries in one session, ~80% of its context. compress_and_save_session therefore collects the demoted context-only rows (seq ≥ 0, absent from session.messages) and calls session_store.set_context_flag(session_id, sequence_numbers, False), a narrow UPDATE session_messages SET is_context = … by sequence number that deliberately does not filter on is_display.

All four trigger paths go through compress_and_save_session, so a summarizer LLM failure is always non-fatal: retry with a wider keep window, then hard-truncate. CompressionWorker only catches genuinely unexpected errors (e.g. a store failure) and logs a warning.

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).

Raw history index

Compression removes messages from the model's window — it does not have to lose them. On every path through compress_and_save_session (pre-turn, mid-turn, post-turn, forced /compact) the exact messages that were demoted are also written to memory_chunks as append-only verbatim chunks with their turn range, timestamps and breadcrumbs (navi/memory/_chunks.py, navi/memory/breadcrumbs.py; see docs/memory.md). Breadcrumbs are the summary's KEYS: line merged with entities extracted from the dropped text, which also covers the hard-truncate fallback that never calls a summarizer.

The index is idempotent via UNIQUE(session_id, content_hash) and failures never fail a compression. PgSessionStore.delete() removes a session's chunks along with the session. Indexing is gated by memory_search_enabled.

The agent then reaches the dropped history through memory_search(scope="raw") — or automatically: when the context holds a summary, the agent injects up to two relevant excerpts per turn (memory_recall_enabled, memory_recall_k, memory_recall_min_similarity) and remembers what it has already injected in session_metadata["injected_chunk_ids"].

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 the file tools or code_exec to access the files.

Images pasted into the chat

Images sent in the images field of a chat message (a paste, or the client's image button) are written into the same session directory, under image_N.<ext>. The extension comes from the bytes themselves, never from a client-supplied name, and the bytes are stored exactly as they arrived — a PNG keeps its alpha channel, so re-encoding to JPEG never happens.

The numbering runs across a session, not per format: the first paste is image_1.png, a later JPEG is image_2.jpg. Sending the same picture twice reuses the file already holding those bytes rather than writing a copy.

A notice stating where they landed is appended to the hidden context copy of the message (the copy the LLM sees, not the one the user sees):

[2 images attached inline — already in your context, no extra loading needed.
On disk: session_files/{id}/image_1.png, session_files/{id}/image_2.jpg]

Images past the context budget are not sent to the model at all, so the notice names them for the image_view tool instead:

[3 images attached inline — 1 are in your context, the rest were not loaded;
open them with image_view: session_files/{id}/image_2.png, session_files/{id}/image_3.png]

The budget is 0.8 × window − estimated context tokens, and each image is charged its own cost (pixel area, see Token accounting and the window) rather than a flat constant — a 1024² screenshot takes four times the room of a 512² thumbnail, and pricing them alike admitted images the model could not actually take.

Before this, a pasted image existed only in the transcript: an agent that had been told session_files/{id}/ exists went looking there for "the attached images", found nothing, and spent turns on fruitless find calls. An image that cannot be stored (no writable directory) is logged as agent.image_save_failed and skipped — it never costs the turn.

Hidden turns (hidden=True, e.g. form submissions) write nothing: they carry machine payloads, not pastes.

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. The card renders STL/glTF models, HTML, SVG, PDF, images, video, audio, and text/source files; the type is detected by navi/content_types.py from the extension and the file's signature (a PNG saved as .txt still opens as an image, a .ts that is not an MPEG transport stream opens as text).

Public read-only share

POST /sessions/{id}/share issues one capability link per session (session_shares table: session_id PK → sessions(id) FK cascade, unique token, created_at). The URL carries the token in the hash — https://…/#share=<token> — so it never reaches the server in a request line and stays out of access logs and the Referer header.

What the holder sees: GET /shared/{token} returns the session name, profile name and dates plus the newest page of display history (get_history_page, the same rows the owner scrolls through); GET /shared/{token}/messages?before_seq= pages further back. What the holder cannot do, by construction: reply (there is no POST), list chats, open the artifacts panel or files, read the LLM context, or learn the session and owner ids — the payload omits both. The webclient renders that feed with the owner's own cards but with the rating controls off and interactive UI components filtered out.

  • Who may issue it — the session's owner, or an admin. navi.sessions.read_all permits reading someone else's session, not publishing it; with auth disabled, only ownerless (local-mode) sessions qualify.
  • Lifetime — no expiry and no revoke, by decision. Deleting the session removes the share row and the link stops resolving; that is the only kill switch.
  • Pending sessions — a lazily-created session has no row yet, so the call answers 409 until the first message is saved.

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.