Architecture¶
This page is a deep-dive into how Mnesis works internally. Every claim is derived directly from the source code — no documentation or README was used as a primary source. Readers who want to extend or debug the library should start here.
Component Overview¶
session.history() — Per-Turn Context Snapshots¶
session.history() returns a list[TurnSnapshot] — one entry per completed
send() or record() call, in chronological order.
Each TurnSnapshot contains:
turn_index— 0-based counter.role— always"assistant"(snapshots are captured after the full user + assistant exchange is persisted).context_tokens— aContextBreakdownwith five token counts:system_prompt,summary(compaction summaries in context),messages(raw non-summary messages),tool_outputs(subset ofmessagesfrom tool parts), andtotal(equalssystem_prompt + summary + messages).compaction_triggered— whether background compaction was scheduled during this turn. (TurnResult.compaction_resultis separate: it is set only when a hard-threshold compaction blocked the turn before the LLM call.)compact_result— theCompactionResultfrom the most recentcompact()call that completed before this snapshot was captured, orNone.
How token counts are computed. After every turn is fully persisted,
ContextBuilder.build() is called once more to assemble the post-turn context
snapshot. This second build call populates three additional fields on
BuiltContext: raw_message_tokens (the tokens_used variable from the
main assembly loop), tool_output_tokens (accumulated by inspecting each
ToolPart in the includable messages), and summary_token_count (already
present). The system-prompt token count comes from
TokenEstimator.estimate(system_prompt, model_info). Exceptions during
snapshot capture are caught and logged; a zeroed-out ContextBreakdown is
used in their place so the caller is never affected.
compact_result lifetime. When session.compact() is called explicitly,
the CompactionResult is stored in _pending_compact_result. The next
_capture_turn_snapshot() call drains this slot and attaches the result to
the snapshot, then clears the slot. This means compact_result appears on
the first turn snapshot that follows the compact() call, not on a
synthetic snapshot of its own.
session.send() Walkthrough¶
send() is defined in src/mnesis/session.py. The following is the exact
sequence as it executes, step by step.
Key observations derived from the source:
-
The user message row (
messagestable) and itscontext_itemsentry are both committed before the LLM call happens. If the process dies mid-stream, the user turn is already durable. -
The assistant message row is inserted before streaming begins (a stub row with no tokens). Token counts are written after streaming completes via
update_message_tokens(). This is the only mutation of a message row permitted byImmutableStore. -
Hard-threshold blocking (
await wait_for_pending()) happens before the LLM call. The hard check reads the context just assembled byContextBuilder.build(); if it blocks, the context is rebuilt after compaction so the LLM never sees an over-limit context. -
Soft-threshold compaction fires after the turn completes via
asyncio.create_task(). The current turn is not delayed. -
The
on_partstreaming callback is invoked inline for both sync and async handlers. If the callback returns a coroutine (async handler), that coroutine is awaited immediately via anasyncio.iscoroutinecheck. Both handler styles are supported, but slow handlers will delay streaming throughput.
ImmutableStore Guarantees¶
ImmutableStore (src/mnesis/store/immutable.py) sits on top of aiosqlite.
The schema lives in src/mnesis/store/schema.sql.
SQL schema (core tables)¶
| Table | Purpose |
|---|---|
sessions |
One row per MnesisSession.create() call |
messages |
One row per user or assistant turn (also compaction summaries) |
message_parts |
One row per MessagePart within a message |
context_items |
The ordered "what is currently in context" view |
summary_nodes |
DAG metadata for compaction summary nodes |
file_references |
Content-addressed large file cache |
Append-only guarantee¶
append_message() and append_part() both execute INSERT statements. There
is no DELETE or UPDATE on the messages or message_parts tables for
business content. The only permitted mutations are:
update_message_tokens()— writestokens_*,cost, andfinish_reasonon an assistant message after streaming finishes. This is the single allowed mutation onmessages.update_part_status()— writestool_state,compacted_at,started_at,completed_at, and optionally mergesoutput/error_messageinto thecontentJSON. This is the single allowed mutation onmessage_parts. Settingcompacted_atto a non-null Unix millisecond timestamp is what constitutes a tombstone.
What a tombstone is¶
A tombstone is a message_parts row whose compacted_at column is non-null.
The row is never deleted. ImmutableStore._deserialize_part() reads back the
compacted_at value and sets it on the resulting ToolStatus.compacted_at
field. ContextBuilder._convert_message() then checks part.compacted_at is
not None and replaces the tool output with a one-line string:
The original tool output remains in the database under content JSON, fully
recoverable for auditing.
context_items — the O(1) context view¶
Every non-summary append_message() call atomically inserts a row into
context_items in the same transaction:
INSERT INTO context_items (session_id, item_type, item_id, position, created_at)
SELECT ?, 'message', ?, COALESCE(MAX(position), 0) + 1, ?
FROM context_items WHERE session_id = ?
The MAX(position) + 1 subquery is computed atomically inside the INSERT,
preventing position races on concurrent appends. When compaction runs,
swap_context_items() atomically DELETEs the compacted message rows and
INSERTs one 'summary' row at the minimum position of the deleted rows — all
in a single transaction.
ContextBuilder then assembles context by reading just this table (one SELECT)
rather than scanning all messages. This makes context assembly O(k) in the
number of current context items, not O(n) in total session length.
Connection pooling (StorePool)¶
StorePool (src/mnesis/store/pool.py) maintains one aiosqlite.Connection
per resolved database path. Multiple ImmutableStore objects sharing the same
path share one connection. This is essential for AgenticMap where many
parallel sub-sessions would otherwise each open a connection to the same
SQLite file and fight over the write lock. The pool uses a per-path
asyncio.Lock to prevent concurrent connection-open races. WAL mode is
enabled at connection time, permitting unlimited concurrent readers alongside
one writer.
When close() is called on an ImmutableStore that uses a pool, the method
is a no-op — the pool owns the connection lifetime. Pool shutdown happens via
pool.close_all() at application teardown.
Concurrency model: one lock per connection¶
Every await on a shared aiosqlite connection yields to other coroutines, and
Python's sqlite3 keeps one implicit transaction per connection: it opens at the
first write and any commit() ends it. Unserialised, a multi-statement write
such as swap_context_items() (read the span, DELETE it, INSERT the summary,
commit) could be interleaved with other work: a ContextBuilder read on the same
connection would see the span missing, an unrelated coroutine's commit() could
publish the DELETE without its INSERT, and an unrelated write could be
rolled into the swap's transaction.
The pool therefore owns one asyncio.Lock per connection (StorePool.write_lock()),
and every ImmutableStore on that connection (a private, pool-less store has its
own) uses it:
ImmutableStore._transaction()takes the lock for the whole statement sequence, commits on success and rolls back on any exception, including cancellation, so a failed or cancelled sequence never leaves partial writes for a latercommit()to publish. All writes inImmutableStoreandSummaryDAGStoreuse it.- Reads that must not observe a transaction in progress take the same lock:
get_context_items()(the context snapshot) and thesummary_nodesqueries behindget_active_nodes()/get_latest_node(). Other reads are of append-only rows and need no lock. - The lock is not reentrant and the critical sections contain only SQL on the connection, never another store call, so there is no nested acquisition. Holding it across only the statements (not LLM calls or other awaits) keeps contention to milliseconds.
BEGIN IMMEDIATE ... COMMIT or a single statement cannot replace the lock: the
sequence spans several execute() calls, and aiosqlite runs each as a separate
job on its worker thread, so nothing prevents another coroutine's job from being
queued between them.
ContextBuilder Assembly¶
ContextBuilder.build() (src/mnesis/context/builder.py) runs on every
send() call. It returns a BuiltContext carrying a messages: list[LLMMessage]
and a token_estimate.
Step-by-step assembly¶
Step 1 — Compute budget.
usable = model.context_limit - model.max_output_tokens - config.compaction.compaction_output_budget
available = usable - system_prompt_tokens
Step 2 — Read context_items.
A single SELECT item_type, item_id FROM context_items WHERE session_id = ? ORDER BY position ASC
returns the current ordered snapshot. If the table is empty (upgraded database
with no migration path), a backward-compatibility scan of all session messages
is performed instead.
Step 3 — Reserve summary budget.
For each item_type = 'summary' entry, SummaryDAGStore.get_node_by_id() is
called to retrieve the SummaryNode. Its token_count is accumulated and
subtracted from available. Summary nodes are cached within the build call to
avoid repeated round-trips.
Step 4 — Walk messages newest-to-oldest.
Message IDs from context_items are fetched via
get_messages_with_parts_by_ids() (one SQL query). The builder iterates in
reverse order, accumulating token counts with estimator.estimate_message(),
and breaks as soon as adding the next message would exceed available. Excluded
messages (budget overflow) are silently omitted — the LLM never sees them.
Step 5 — Convert to LLMMessage list.
The final list is assembled in position order:
- Summary nodes (injected as
role="assistant"messages with the summary text as content). - Raw messages that fit the budget (chronological order restored by
.reverse()).
Within each raw message, _convert_message() applies:
- TextPart — appended verbatim.
- ToolPart with compacted_at is not None — replaced with the tombstone string.
- ToolPart with no tombstone — rendered as a [Tool: name] ... [/Tool] block.
- FileRefPart — rendered as a [FILE: path] ... [/FILE] block.
Precedence when things do not fit: The most recent messages always take priority. Older messages are dropped first. If summary nodes are so large they consume the entire budget, raw messages may be completely absent from the assembled context (only summaries remain). This is an extreme edge case handled by the multi-round condensation loop.
Compaction Lifecycle¶
Thresholds¶
-
Soft threshold:
soft_threshold_fraction * usable(default 60%). Checked after each turn completes (send()andrecord()). If crossed,check_and_trigger()callsasyncio.create_task(run_compaction())and returnsTrueimmediately. The current turn is not affected. It is a no-op (returnsFalse) while a previous compaction is still running, so at most one compaction is in flight per session. A manualsession.compact()runs as that same tracked task, after waiting for any running background one. -
Hard threshold:
usable(100%). Checked at the start ofsend(), before the LLM call. If the hard limit is crossed,send()waits for the in-flight compaction task (await wait_for_pending()), rebuilds the context and re-checks; if it is still over the limit it starts one fresh run, waits for it once and rebuilds. This prevents sending an over-limit context to the provider (the retry is bounded so a context that cannot shrink cannot loop). -
Tool schemas. The measure for both thresholds is the stored context plus the serialized
toolsdefinitions of the latestsend(tools=...)call (estimated with the session model). They ride along with every request but live outside the history, so leaving them out would let a tool-heavy session cross the provider's limit while the thresholds see headroom. -
When compaction cannot help. A run that ends at or above the soft threshold with nothing left to summarise or condense (typically the protected last two user turns plus live summaries alone exceed it) is stalled. Whether work remains is judged from the history, not from token deltas:
record()can append during a long run, so the context growing says nothing about progress. Re-running on every turn would spend LLM calls for nothing, so the background (soft) trigger is paused (CompactionEngine.stalled) and logs acompaction_cannot_reduce_contextwarning once. A new user turn re-arms it (it can make older messages summarisable); a turn that arrives while a run is in flight prevents that run from pausing on its stale view. The warning is logged once per pause episode (reset when a run gets under the soft threshold or finds work left). The pause never applies to the hard limit: the nextsend()that finds the context over the hard limit still blocks on a compaction run. Such a run makes no summarisation calls when nothing is summarisable; the only LLM call it can make is a condensation, and only for a set of live summary nodes that is new since the last condensation that failed to shrink them even at the deterministic level (an LLM condensation that does not shrink the nodes falls back to the deterministic level in the same run; only if that also fails is the set remembered and skipped while unchanged). Level 3 is unaffected (a run always produces its summary first), and the engine never truncates the protected tail to force a fit: Level 3 runs only over the summarisable messages, so the last two user turns, including the message being answered, stay raw. A manualcompact()always runs and re-evaluates the state. - Blocking runs stop early. The run that
send()waits for at the hard limit stops draining once the context is under the soft threshold (not the hard limit); the rest of the drain is left to the background run that the post-turn soft check schedules. The soft threshold is the target because the engine's measure uses the session system prompt whilesend()checks the hard limit with a per-turnsystem_promptoverride: stopping just under hard would let the override push the sent context back over it. As a bounded backstop, if the rebuilt context is still over the hard limit and the run left work,send()runs one more compaction to the full drain target (at most one extra run, no loop). A background run can use up tomax_compaction_roundssummarisation passes (each Level 1 then Level 2 on failure), so the worst case is2 * max_compaction_roundssequential summariser calls (20 with the default); typically 1 to 2. - A session stuck above the soft threshold (the protected turns plus live summaries alone exceed it) re-arms on every new user turn, so it compacts once per user turn: one summarisation of whatever left the protected tail plus a condensation attempt. That is accepted: each run does real work (the turn that just left the tail), and the alternative, staying paused, risks sending an over-hard context.
- Not fixable by compaction. A single user message larger than the usable window, or a protected last-two-turns tail that is itself over the hard limit, cannot be shrunk: those turns are never summarised, so the request is sent as is (the builder may truncate older messages) and the provider may reject it.
What the thresholds measure. Both compare against
BuiltContext.context_tokens: the estimated size of the current context
window — system prompt + live summary nodes + every raw message in
context_items, counted before the builder drops messages that do not fit
the budget (token_estimate is capped at usable and so can never signal
overflow). Compaction swaps messages for a summary in context_items, so this
value drops immediately afterwards. It is derived from persisted state, so a
reloaded session starts with the correct value. MnesisSession.token_usage (lifetime billed tokens,
which only grows and counts the full prompt every turn) is accounting only and
plays no part in these decisions.
The multi-round loop in _run_compaction_inner() uses the same yardstick.
The session hands the engine a measurement callback (context_measure) that
builds the context with the session's model, estimator and system prompt, so the
engine's "does it fit" check, tokens_before and tokens_after are exactly the
numbers the trigger compares. When the engine knows the session's ModelInfo
(including model_overrides), condensation runs until that measure is below half
of the soft threshold (usable * soft_threshold_fraction * 0.5; the 0.5 is a
private constant, not configuration). Stopping exactly at the soft threshold
left the context hovering at the trigger and re-compacted on nearly every turn
once leaf summaries accumulated; the lower target makes compactions rarer in
practice. It is not a guarantee: when the system prompt, the most recent turns and
a single summary already exceed the target, every compaction condenses and the
target is never reached. Each run merges all live summary nodes into one, so a
run condenses at most once and max_compaction_rounds is effectively never
reached. Without a ModelInfo the stop target is the fixed 200K-window hard
budget; without the callback the measure falls back to an estimator-based count
of the raw tail plus live summaries (no system prompt).
run_compaction() sequence¶
CompactionEngine.run_compaction() is the outer entry point. It wraps
_run_compaction_inner() in a try/except so that unexpected exceptions do
not crash the caller. On exception, COMPACTION_FAILED is published and a
stub CompactionResult (no summary committed) is returned. Level 3 is not
forced by this handler — Level 3 is only the final fallback inside
_run_summarisation() when Level 1/2 return None.
Cancellation contract: setting the abort event ends the run with the same
stub result and a COMPACTION_FAILED event carrying aborted=True; it is logged
at info as compaction_aborted, not as an error. Abort also interrupts a retry
backoff wait immediately (an LLM request already on the wire is not interrupted;
the run stops when it returns), and work committed by earlier rounds stays.
Callers driving run_compaction() / check_and_trigger() directly pass their own
event. A MnesisSession owns one event for every run it starts and sets it only on
session.close(abort_compaction=True); the default close() still waits for the
in-flight compaction, including its retry backoffs, so the summary is fully
persisted. External task cancellation (Task.cancel(), TaskGroup, shutdown) of
the compaction task is not swallowed: asyncio.CancelledError propagates.
Cancelling a waiter is different: wait_for_pending() awaits the task through
asyncio.shield(), so cancelling the caller (for example asyncio.timeout()
around send()) does not cancel the background compaction. The task keeps
running, stays tracked, and is collected by a later wait_for_pending() or by
close(). To bound how long a hard-limit send() can wait on compaction retries,
keep max_retries and max_delay small.
LLM retries: every compaction LLM call (Level 1/2 summarisation and
condensation) goes through the same retry policy as send(), driven by
SessionConfig.retry (RetryConfig: attempts, backoff, transient-error
classification). RetryConfig.max_retries > 0: Mnesis retries compaction
calls and disables LiteLLM retries (num_retries=0) so calls are never retried
twice. max_retries == 0 (default): compaction uses LiteLLM/provider default
retries. Outage handling: when a call outlasts its retries on a retryable
error, the rest of that run skips its remaining LLM levels (summarisation goes
straight to Level 3; condensation is skipped to its deterministic level), so a
run spends at most max_retries backoffs (each up to max_delay) on an
outage. Non-retryable errors, and retryable ones once attempts
are exhausted, escalate to the next level as before.
Unusable completions: an empty or whitespace-only completion, or one that
stopped at the output limit (finish_reason == "length"), fails that level and
the run escalates (L1 → L2 → L3, for summarisation and condensation alike), so
an empty or cut-off summary never replaces history. Neither case is retried
under RetryConfig: they are not transient transport errors, and re-asking
with the same limits is expected to give the same result, whereas the next
level asks for a shorter summary. Truncation is surfaced by the real LLM call
as CompactionTruncatedError; injected llm_call callables keep returning a
plain string.
The inner sequence for each round:
-
Tool output pruning —
ToolOutputPrunerAsync.prune()tombstones stale tool outputs (see ToolOutputPruner below). -
Summarisation — only the raw messages still in the context (
context_items; every message on a legacy database with no rows) are summarised, so content already covered by a live summary is never summarised twice._run_summarisation()attempts Level 1, then Level 2 (if enabled), then Level 3 and returns aSummaryCandidatewhose span is exactly the messages it covered. The summariser's input is capped (see below), taking the oldest messages first; if that leaves summarisable messages and the context is still above the condensation target, further passes (up tomax_compaction_rounds) summarise the rest, each as its own leaf. Newer turns therefore stay verbatim until summarised, and nothing is swapped out of the context without being summarised. Only the first pass may use Level 3, and it runs over the summarisable messages only. If nothing is summarisable (only the protected last two user turns), the leaf step is skipped rather than truncating that tail. The summariser's request is sized to the compaction model:max_tokensis bounded by its output limit, and the input cap leaves room formax_tokensplus the prompt. File IDs outrank the model's prose: a summary whose text plus footer exceeds the budget is rejected so the run escalates (Level 2, then Level 3), and IDs are dropped (least recently referenced first, as in Level 3) only when the footer alone cannot fit. -
Commit —
SummaryDAGStore.commit_summary_node()writes the summary as aMessage(is_summary=True)row plus aTextPartand aCompactionMarkerPart, itssummary_nodesrow, the replacement of the compacted message entries incontext_itemsby one'summary'entry and (for condensation) thesupersededflag on the parents, all in one locked transaction. A cancel or failure at any point rolls the whole commit back, so a node is never left half-committed (e.g. parents still active but already out of the context). -
Condensation loop (if
condensation_enabled=True) — while the re-measured context is not below the condensation target (half the soft threshold) andlen(active_nodes) >= 2, the engine calls_run_condensation()to merge liveSummaryNodeobjects into a single condensed node. The LLM levels cap their input against the compaction model's window (see below), so when the live nodes do not all fit, the oldest ones that do are condensed. "Oldest" is by position in the context (context_items), not by creation time: a condensed node is created late but sits where the oldest span was, so ordering by creation time would merge the wrong nodes and invert the prompt order. Only the condensed nodes are swapped out of the context and superseded (in the commit above), and the rest stay live for the next round. If an LLM condensation does not shrink its input, the deterministic level runs on that same subset only. Loop runs up tomax_compaction_roundstimes, breaking early if no progress is made. -
Event —
EventBus.publish(COMPACTION_COMPLETED, ...)is fired with the fullCompactionResultpayload.
The EventBus.publish(COMPACTION_TRIGGERED, ...) fires synchronously inside
check_and_trigger() before the task is created.
Background vs. foreground interaction¶
The _pending_task: asyncio.Task | None attribute on CompactionEngine is the
sole coordination point between the compaction background task and the foreground
send() loop. The session's compaction_in_progress property reads this:
close() awaits this task, repeating until no handle remains (a run scheduled
meanwhile is awaited too), before releasing the database connection, ensuring
no in-flight write is interrupted. close(abort_compaction=True) first sets the
session's abort event so a run waiting in a retry backoff ends at once with the
stub result instead of being waited out.
Three Compaction Levels¶
All three levels are implemented in src/mnesis/compaction/levels.py.
What all levels share¶
-
Protection window:
_messages_to_summarise()keeps the two most recent user turns out of scope. Only messages before the second-to-last user turn are candidates for summarisation. -
Input cap:
_apply_input_cap()trims messages to at most 75% of the compaction model's context window, measured on the transcript text actually sent (after per-message truncation), before passing them to the LLM, keeping the oldest prefix. At least 3 messages are always included (MIN_MESSAGES_TO_SUMMARISE = 3). The recorded span is that prefix, never the uncapped input. -
Budget and estimator: summaries must fit the session window, so the budget is
ContextBudget(context_limit, max_output_tokens, compaction_output_budget)from the session'sModelInfo(includingmodel_overrides); a bare engine without model info assumes a 200K window. The engine counts tokens with the session model's tokenizer (the same oneContextBuilderuses), so "fitsbudget.usable" means fits the session context and a summary'stoken_countmatches what the builder charges for it. The summariser's own input cap uses the compaction model's window (the session's resolved info when it is the same model). -
File ID preservation:
extract_file_ids_from_messages()collects allfile_xxxidentifiers from the full raw content of the input (text, tool inputs, full tool outputs and errors, with no length cap), including tool outputs the pruner has tombstoned: pruning only hides the output from the context, the original stays in the append-only store.append_file_ids_footer()appends a[LCM File IDs: ...]footer to every summary. Level 3 collects IDs from all messages (including those truncated), ensuring file references are never lost even when prose is discarded. -
ID-to-path pairing: where the path of a file is known, the footer records the pair as
path (file_<hex>)([LCM File IDs: services/a.yaml (file_3fa9…), file_9b1e…]; an ID with no known path stays bare). Paths are recovered from the source text (only when a path and an ID are directly adjacent (at most three separator characters between them:path (file_x),file_x: path; a path has a/or a known file extension; e-mails, URLs, calls and bare identifiers such asjson.loadsnever count; one path names one ID). When unsure, no pair is made: a missing path is harmless, a wrong one is not), and, for condensation, from the parent nodes' footers first (authoritative: they were derived from the original messages) and their prose second. The LLM prompts at every level also requirepath (file_<hex>)to be written together, so prose keeps the association too. Pairs rank last at every level (IDs > prose > paths): they are added after the prose is fixed, and only if they still fit, so they never displace prose or IDs. When the paired footer would not fit the budget, the bare IDs are written instead (all-or-nothing). The old bare-ID footer still parses everywhere (ID extraction only looks forfile_<hex>tokens). -
Faithfulness and people (compliance is model-dependent: observed only partial on Nemotron 3 Super, which listed the assistant's own recommendations under In Progress and invented a Remaining Work item; the prompts also say suggestions the assistant made are not tasks unless the user accepted or requested them): every default prompt forbids inventing next steps (In Progress, Remaining Work and next steps list only what the user or assistant explicitly stated, otherwise exactly "None stated") and keeps named people with their roles; the level 2 formats have a compact
PEOPLE:line. -
Length target: the default level 1 summarisation prompt ends with an explicit output target of about half the input, capped at 75% of the call's
max_tokens, plus the hard limit. The condensation level 1 prompt instead gets structural limits (at most N bullets per section of about 25 words, N derived from that same target) and is told to merge and deduplicate rather than copy. A verbose model therefore finishes under its output cap instead of hittingfinish_reason="length"(which discards the completion and escalates). A customcompaction_promptis sent unchanged. -
Convergence check (L1 and L2 only): If the summary token count is >= the input token count, the LLM failed to compress. The level returns
Noneand the next level is tried.
Level 1 — Structured LLM summary¶
Uses LEVEL1_PROMPT, which requests a structured format with eight sections:
Goal, Key Instructions & Constraints, Discoveries & Findings, Completed Work,
In Progress, Remaining Work, Relevant Files & Directories, Other Important
Context.
max_tokens passed to the LLM is min(8192, budget.usable).
Returns None if the LLM call fails, the result exceeds budget.usable, or
the result is not smaller than the input.
Level 2 — Aggressive LLM summary¶
Uses LEVEL2_PROMPT, which requests a five-field compact format: GOAL,
CONSTRAINTS, FILES, NEXT, CONTEXT. Each message is truncated to 500 characters
before building the transcript. max_tokens is min(budget.compaction_buffer, 4000).
Level 2 is only attempted if config.compaction.level2_enabled=True (default).
Returns None on the same conditions as Level 1.
Level 3 — Deterministic truncation¶
No LLM call. Walks messages in reverse order, keeping those that fit within
85% of budget.usable. Prepends [CONTEXT TRUNCATED — DETERMINISTIC FALLBACK].
Always returns a valid SummaryCandidate — this is the invariant that prevents
the system from ever deadlocking on compaction.
The [LCM File IDs: ...] footer is reserved before prose is sized, so messages
are dropped before any file ID is. The assembled text is validated against
budget.usable (shedding the oldest kept messages in O(n log n)). Only if the
header plus the footer cannot fit in 85% of budget.usable (tens of thousands of distinct
file IDs at default settings, or a compaction_output_budget close to its
maximum) is it truncated, keeping the most recently referenced IDs, and a
level3_file_ids_truncated warning is logged; the files themselves remain in the
immutable store. If the full header does not fit, or would displace IDs that fit
beside a minimal header, the minimal header is used. A budget.usable smaller
than the minimal header cannot be satisfied by any output, so all file IDs are
kept in that case. "Fits" means as measured by the compaction estimator, which
is not necessarily the session model's tokenizer.
Trade-off when the footer alone exceeds the budget (at default settings, over
about 1,400 distinct file IDs): the LLM levels are rejected (text plus footer
exceeds the budget) and Level 3 runs, keeping 1,280 IDs in the measured case
because it sizes the footer against 85% of budget.usable. The best an LLM
candidate could keep is 1,435 IDs with no prose, or about 1,367 beside 412 tokens
of prose: at most 7 to 12% more, for a summary that is almost only IDs. Level 3's
margin is kept for that, since accepting a candidate at 100% of the budget would
need its own validation path. The IDs dropped are still in the immutable store,
and the case needs a pathological ID count.
Escalation flow¶
_run_summarisation():
try Level 1
if None and level2_enabled: try Level 2
if still None: run Level 3 (always succeeds)
return candidate
Condensation levels¶
When multiple summary nodes accumulate and the context is still over budget, condensation merges them. Three levels mirror summarisation:
- Condense L1 —
CONDENSE_LEVEL1_PROMPTmerges the summaries via the structured format, with per-section bullet caps derived from half the input (see "Length target" above). Adaptive skip: a completion cut off at the output limit (CompactionTruncatedError) is never accepted. After two consecutive truncations (an accepted L1 condensation resets the count; one truncation can be a one-off) the engine skips L1 for the rest of its lifetime and later condensations start at L2, so a verbose model does not waste a request per condensation. Empty completions and provider errors neither count nor reset. The state is in memory only (not persisted; a reloaded session probes L1 again once). SetCompactionConfig.condense_skip_level1=Trueto skip L1 from the start. Both needlevel2_enabled(otherwise L1 is kept: skipping would drop to L3). Evidence: on Nemotron 3 Super (reasoning off) condense L1 overran the output cap in 4 of 4 samples across two prompt variants despite bullet limits, while condense L2 scored 9/9 on the recall probe. Summarisation L1 is unaffected. - Condense L2 —
CONDENSE_LEVEL2_PROMPTaggressive single-turn merge (each summary truncated to 800 chars). - Input cap (L1 and L2) — like summarisation, the request must fit the
compaction model's window: the nodes' text is limited to 75% of it and to what
remains after the prompt and
max_tokens, counted in the compaction model's tokenizer units. The oldest nodes that fit are condensed (at least two); if fewer fit, the level returnsNoneand escalates (L2 sends bounded excerpts, L3 sends nothing to a model). The candidate'sparent_node_idsname exactly the nodes condensed, and file IDs are carried from those nodes' footers. - Condense L3 — deterministic: concatenates node prose (own footers
stripped) up to
_CONDENSE_LEVEL3_MAX_TOKENS = 512tokens, then appends one file-ID footer. Bounded like Level 3: the footer and header are sized against 85% ofbudget.usable(IDs take precedence over prose and over the[Condensed from: ...]parent list, which falls back to a minimal header), the least recently referenced IDs are dropped with a warning only when they cannot fit, and the assembled text is validated againstbudget.usable. Always succeeds.
ToolOutputPruner¶
ToolOutputPrunerAsync (src/mnesis/compaction/pruner.py) runs as the first
step of every compaction pass.
What it tombstones vs. protects¶
The pruner scans the message history backward, tracking:
- Protected: most recent user turn —
user_turn_count < 2: skip all messages until the backward scan has encountered 2 user turns. In practice this protects the most recent user turn and all messages between it and the second-most-recent user turn; the second-most-recent user turn itself is the first message that is eligible for pruning. - Protected: summary message boundaries — stop scanning when
msg.is_summaryis encountered. Tool outputs before the last compaction boundary are not re-pruned. - Protected tool names:
_PROTECTED_TOOLS = frozenset({"skill"}). - Already-tombstoned parts:
part.compacted_at is not None— stop scanning that message's tool parts. - Protect window: a rolling token count accumulates output tokens. Parts
that push the cumulative total above
prune_protect_tokens(default 40,000) are candidates; those within the window are skipped. - Minimum volume gate: if total prunable tokens <=
prune_minimum_tokens(default 20,000), the entire prune pass is skipped — no tombstones are written.
Only completed tool parts (not pending, running, or error) outside the
protect window are tombstoned.
What a tombstone looks like¶
In SQLite: message_parts.compacted_at = <unix_ms>. The content JSON is
unchanged.
When deserialized by ImmutableStore._deserialize_part(): the compacted_at
value is written into ToolStatus.compacted_at on the ToolPart.
In the assembled context (via ContextBuilder._convert_message()): the entire
tool content is replaced with:
This one-line string costs approximately 10–15 tokens regardless of how large the original tool output was.
Large File Handling¶
LargeFileHandler (src/mnesis/files/handler.py) intercepts files before
they are added to context.
Flow from call site to FileRefPart¶
- Caller calls
handler.handle_file(path). LargeFileHandlerreads the file and estimates tokens viaTokenEstimator.- If
token_count < FileConfig.inline_threshold(default 10,000 tokens): returnFileHandleResult(inline_content=content_str). The file goes into the message as aTextPart. - If at or above threshold:
a. Compute
content_id = sha256(content_str). b. QueryImmutableStore.get_file_reference(content_id). Cache hit returns immediately with the cachedexploration_summary. c. Cache miss: detect file type (extension map → binary heuristic →python-magicfallback →"unknown"). Generate exploration summary:- Python: AST walk, extracts classes, functions, imports.
- JSON: top-level key list and array fields.
- YAML: top-level key list (via
yaml.safe_load). - TOML: section list (via
tomllib). - Markdown: heading hierarchy.
- CSV/TSV: column list and row count.
- TypeScript/JavaScript: regex-based exports, classes, functions, imports.
- All others: line count + 5-line preview.
d. Store
FileReferenceinfile_referencestable viaImmutableStore.store_file_reference(). e. ReturnFileHandleResult(file_ref=FileRefPart(...)).
What lives where:
| Location | Content |
|---|---|
| Disk | Raw file content (not stored by Mnesis — caller owns it) |
file_references table |
content_id, path, file_type, token_count, exploration_summary |
message_parts table |
FileRefPart serialized as JSON (content_id + summary, no raw content) |
| LLM context | [FILE: path]\nContent-ID: ...\n...\n[/FILE] block |
The raw file content is never stored in the ImmutableStore. Only the
content hash and structural summary are persisted.
Event Flow¶
EventBus (src/mnesis/events/bus.py) is a simple in-process pub/sub system.
Handler execution model¶
- Sync handlers: called inline within
publish()in registration order. - Async handlers: scheduled via
asyncio.get_running_loop().create_task(). If no event loop is running, the handler is silently skipped. - Exceptions from any handler are caught and logged. They never propagate to the publisher.
Who publishes what¶
| Publisher | Event | When |
|---|---|---|
MnesisSession.create() |
SESSION_CREATED |
After store initialization |
MnesisSession.send() |
MESSAGE_CREATED (user) |
After user parts persisted |
MnesisSession.send() |
MESSAGE_CREATED (assistant) |
After assistant parts persisted |
MnesisSession.close() |
SESSION_CLOSED |
After compaction drained |
CompactionEngine.check_and_trigger() |
COMPACTION_TRIGGERED |
Before task created |
CompactionEngine._run_compaction_inner() |
COMPACTION_COMPLETED |
On success |
CompactionEngine.run_compaction() (outer) |
COMPACTION_FAILED |
On unhandled exception |
MnesisSession._check_doom_loop() |
DOOM_LOOP_DETECTED |
When threshold consecutive identical calls found |
LLMMap.run() |
MAP_STARTED, MAP_ITEM_COMPLETED, MAP_COMPLETED |
Operator lifecycle |
AgenticMap.run() |
MAP_STARTED, MAP_ITEM_COMPLETED, MAP_COMPLETED |
Operator lifecycle |
Session bus vs. operator bus¶
Each MnesisSession creates its own EventBus instance inside create().
Operators (LLMMap, AgenticMap) have an optional event_bus constructor
parameter. If not provided (event_bus=None), operator events are not
published at all — the operator runs normally but emits no events.
To receive operator events on the session bus, inject it at construction:
llm_map = LLMMap(config.operators, event_bus=session.event_bus)
agentic_map = AgenticMap(config.operators, event_bus=session.event_bus)
Sub-sessions created by AgenticMap each get their own EventBus (created
inside MnesisSession.create()). Their events are not automatically forwarded
to the parent session bus.
LLMMap and AgenticMap¶
Both operators live in src/mnesis/operators/.
How they relate to MnesisSession¶
Neither operator inherits from nor wraps MnesisSession at the class level.
The dependency graph is:
-
LLMMap— no dependency onMnesisSession. Makes directlitellm.acompletion()calls for each item with no session state, no store, and no compaction. It accepts an optionalEventBusfor operator event delivery but otherwise operates entirely in memory. -
AgenticMap— creates oneMnesisSessionper input item viaMnesisSession.create(). Each sub-session has its own store connection (via a sharedStorePool), its ownCompactionEngine, and its ownEventBus. Sub-sessions do not share state with each other or with the parent caller.
State isolation¶
| Resource | LLMMap | AgenticMap sub-session |
|---|---|---|
ImmutableStore |
None | Own session in shared StorePool |
CompactionEngine |
None | Own instance |
EventBus |
Optional shared or None | Own instance (events not forwarded) |
| Parent context window | Not consumed | Not consumed |
| Conversation history | None | Full multi-turn history in its own session |
The parent's context window cost for an AgenticMap call is O(1) per item:
only the final output_text from each sub-session is returned. The sub-session's
multi-turn history lives in its own SQLite rows.
Concurrency control¶
Both operators use an asyncio.Semaphore to cap concurrent tasks:
LLMMap:llm_map_concurrency(default 16).AgenticMap:agentic_map_concurrency(default 4).
Results are yielded via asyncio.as_completed(), so they stream back in
completion order, not input order. Use result.input to correlate with the
original input.
Template validation¶
Both operators call require_item_variable(template) from
src/mnesis/operators/template_utils.py. This uses the Jinja2 AST parser
(not a regex) to confirm the template references {{ item }}. Templates that
do not reference item raise ValueError before any tasks are spawned.
Design Invariants¶
These are the system-level invariants that all components are designed to uphold, with the specific code locations where each is enforced.
1. The immutable store is append-only¶
No DELETE statement is ever executed on messages or message_parts.
Compaction content is managed exclusively through new rows and the
context_items view — old rows remain in the database for auditing and
recovery.
Enforced by: ImmutableStore.append_message(), append_part(), and the
absence of any DELETE ... FROM messages in the codebase. The only permitted
mutations are update_message_tokens() (status fields only) and
update_part_status() (compacted_at tombstone and tool lifecycle fields).
2. Compaction is asynchronous and non-blocking¶
CompactionEngine.check_and_trigger() always uses asyncio.create_task() and
returns immediately. The current turn completes before compaction begins.
Exception: when the current context size (BuiltContext.context_tokens)
is >= hard_threshold at the start of send(), the call blocks via
await wait_for_pending(). This is the designed
safety valve that prevents over-limit contexts from reaching the provider.
Enforced by: send() in session.py: it builds the context, then
_ensure_under_hard_limit() checks is_hard_overflow() on it, waits for any
in-flight compaction, re-measures, and starts at most one fresh run if the
context is still over the hard limit.
3. Level 3 compaction always succeeds¶
level3_deterministic() never calls the LLM. It truncates to a deterministic
subset of messages and always returns a SummaryCandidate. This ensures the
compaction loop cannot deadlock even if the LLM is unavailable or returns
garbage.
Enforced by: _run_summarisation() in engine.py — Level 3 is the final
fallback with no failure path (always taken on the first summarisation pass; later
drain passes stop instead of truncating the protected tail).
4. File references are never lost through compaction¶
The append_file_ids_footer() function appends a [LCM File IDs: ...] block
to every summary, including Level 3 deterministic fallback. Level 3 collects
IDs from all messages before truncation — even those whose prose is
discarded.
Enforced by: level3_deterministic() calling
extract_file_ids_from_messages(messages) on the full message list before
any truncation occurs.
5. Context assembly is O(k) not O(n)¶
ContextBuilder reads context_items (one ordered SELECT) and then fetches
only the message IDs present in that snapshot. The total session message count
does not affect assembly time.
Enforced by: ImmutableStore.get_context_items() + get_messages_with_parts_by_ids().
6. Concurrent sub-sessions share one SQLite connection¶
AgenticMap creates a StorePool (if none is provided) and passes it to
every MnesisSession.create() call. All sessions sharing the same pool for the
same db_path use one aiosqlite.Connection, preventing database is locked
errors under concurrent writes.
Enforced by: StorePool.acquire() with a per-path asyncio.Lock and
double-checked locking pattern, and by the per-connection transaction lock
described under "Concurrency model" in the StorePool section.