Session management, dual-buffer design, and context compression.
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)
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) |
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.
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.
InMemorySessionStoreSimple 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 Sessionget(session_id) → Session | Nonesave(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) sessionscount_all(user_id=None, is_admin=False, search=None) → total matching sessionssearch_list(limit, offset, user_id=None, is_admin=False, search=None, sort_by="last_active", sort_order="desc") → paginated, filtered, sorted sessionsdelete(session_id) → boollist_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 profileset_pinned(session_id, pinned) → boolset_name(session_id, name) → boolarchive_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 backRequires 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.
navi/core/compressor.py)Keeps the LLM context within the token budget by summarizing old conversation turns.
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:
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.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).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.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.
Three trigger points:
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 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.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.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.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 = Truecontext_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 latercontext_keep_recent: int = 4 — keep last N conversational turns verbatimcontext_summary_temperature: float = 0.3context_summary_max_tokens: int = 6000 — max output tokens for the summary LLM calloutput_reserve_tokens: int = 2048 — headroom reserved for the response in check_context_sizecontext_message_token_budget: int = 0 — per-message view truncation budget (0 = auto, active window // 6)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.
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, + 500 per image). 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.
compress_context(context, llm, model, temperature, keep_recent, max_tokens=None, keep_recent_messages=None, profile=None, window=None, estimate=None):
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.
_turn_importance scores each turn; an important old turn can be swapped into the kept set in place of a filler-recent one.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.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.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._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.llm.complete() with think=False to produce a bullet-point summary.to_summarize with a single summary message (role=user, is_summary=True).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.
session.messages — full history is always intact.context_keep_recent conversational turns (or the intra-turn recent window).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.Files uploaded via POST /sessions/{id}/files are stored in session_files/{session_id}/.
session_files_max_size_mb (default: 200 MB)cleanup_loop (started on FastAPI startup) deletes orphaned session directories after their DB session no longer exists..sh, .py, .exe, etc.) are rejected.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.
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]
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).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.