Skip to main content

Module compaction

Module compaction 

Source
Expand description

What a workspace’s agent forgets, and when.

mentra shrinks an agent’s history in two unrelated ways, and only one of them is what the word compaction suggests.

Micro-compaction runs on every provider request (Agent::micro_compacted_history, reached from mentra’s turn runner before each call). It walks the tool results in the transcript and replaces the content of every one past the most recent keep_recent_tool_results with [Previous: used <tool>], for any result over 100 bytes. There is no token budget in that decision: it happens on the fourth tool call of a conversation as readily as on the four-hundredth, on a 1M-token model as readily as on a small one. Mentra 0.23 makes the request-only rewrite observable through Event::RequestToolResultsElided, without changing the canonical transcript. mentra’s own default is usize::MAX — keep everything — and basis agrees, so this is one knob basis states only to make the same default explicit: elision is opt-in a host asks for by number (with_keep_recent_tool_results), which is exactly how mentra models the off switch it already has: keep_recent == usize::MAX returns the history untouched.

Real compaction is the summarizing pass: the transcript is written to a snapshot file, an older prefix of it is replaced by a model-written summary, and the recent tail is preserved. It fires three ways, and it announces itself the same way every time (Event::CompactionStarted / CompactionCompleted):

  • The estimated request size crosses auto_threshold_tokens — an absolute token count, for a model whose context window basis does not know.

  • Or, when the model’s window is known (ModelInfo::context_window — populated from Gemini’s model listing, None for Anthropic and the Responses transport), it crosses auto_threshold_percent of that window instead, which wins over the absolute number whenever both apply. This is the knob that did not exist until mentra could ask the model itself how big it is: 50,000 tokens is most of a small model’s window and a rounding error in a 1M-token one, so no single constant was ever going to be right for both. PreparedRun::context_window and estimated_context_tokens are how a host reads the same two figures for itself, ahead of whichever trigger fires first.

    The two are one setting resolved together, not two triggers that fire independently — but which of them is armed is something a host states rather than something it encodes by leaving a number in. mentra 0.24 splits the switch out of the number (CompactionConfig::auto_compact_trigger, an AutoCompactTrigger), and basis’s two knobs are read onto it, three states for three spellings:

    auto_threshold_tokensauto_threshold_percentwhat mentra is toldwhat fires
    seteitherThresholdsthe percentage of a known window, else the absolute number
    clearedsetWindowShareOnlythe percentage of a known window, and nothing when the window is unknown
    clearedclearedOffnothing, at any window

    The middle row is the one that had no spelling before 0.24, and it is what ARCHITECTURE.md’s compaction row has always promised: a trigger that is a share of the model’s window when the provider reports one. Until then a cleared auto_threshold_tokens was mentra’s off switch for the whole feature, read before the percentage was consulted at all, so a host that wanted the window share had to leave behind an absolute number it did not believe in — and that invented number went live on exactly the models whose window nobody reports, which is where a wrong guess does the damage. basis documented that faithfully while it was true; the tests named for it in this module’s tests are replaced by the three rows above.

  • Or the provider refuses a request as too long (ProviderError::ContextLengthExceeded), regardless of either threshold — including with automatic summarizing off. mentra compacts once and retries the same request; a second overflow after that is not retried again. So an Off posture means basis never compacts ahead of running out of room, not that a conversation which does is guaranteed to fail — the provider’s own refusal gets the one attempt basis’s own trigger would have spent earlier.

§What a failed automatic pass looks like

Like nothing. Both events above are built from a success: mentra synthesizes CompactionStarted and CompactionCompleted together from the one ContextCompacted a finished pass emits, so there is no “started” line to leave dangling — and no line of any kind when the pass does not finish. An automatic pass that fails is retried up to three times and then dropped (Agent::auto_compact_if_needed, mentra 0.24.0 src/agent/compact.rs), which is a reasonable posture — the run continues on an unshortened history rather than dying over a summary — but it is taken silently: no event, no hook, nothing a sink can see. The retries in between surface as Event::Retry, indistinguishable from a model request’s own.

With one exception, and it is the one that matters for control: since mentra 0.24 an automatic pass inherits the bounds of the turn it happens inside, and a cancellation or a deadline reached during it ends the run instead of being degraded past. Silently continuing after a caller asked the run to stop would have made the stop button a suggestion. basis names that ending exactly as it names the same bound reached anywhere else in the turn — Bound::Deadline for the deadline, and for a cancel the run’s own RunFailure::Cancelled with no Bound, because a stop somebody asked for is not an allowance the run outgrew. No basis-side mapping was needed for that; basis’s tests/compact.rs pins both so a change to either is noticed.

basis does not paper over that. Inferring a failure from an estimate that crossed a threshold with no compaction after it would be a guess dressed as an event, and the fix belongs where the failure is (ADR-0005). What a host can rely on is the pass it asks for itself: PreparedRun::compact reports its failure on the stream as well as to the caller.

Neither kind of pass is counted in RunUsage — see there for why.

§What is not here

mentra’s CompactionConfig has twelve fields. This exposes four: the three triggers above and how much recent user text a summarizing pass must leave alone — and derives a fifth, auto_compact_trigger, from two of them rather than offering it, because the numbers already say which posture a host means and a switch beside them could disagree with them (the private Compaction::trigger is where the three rows above are read off — it stays private for that reason, so there is nothing to link to). The mutually exclusive projected-byte budget is pinned off: exposing it needs a Basis-owned policy shape rather than two knobs that can disagree. The summary’s input and output caps, local versus remote summarization, and how many snapshots are kept remain defaults nobody has had a reason to move. A knob basis offers is a knob basis has to keep meaning; they arrive when a host asks, with the case that asked.

transcript_dir is deliberately not a knob at all: where a workspace’s files go is settled by RuntimeBuilder::with_store_dir, and a second answer here could disagree with it — so the conversion to mentra’s config takes the directory as a parameter rather than reading a field.

Structs§

Compaction
How much of a conversation reaches the model, for every run a workspace mints.