Skip to content
Merged
95 changes: 95 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,101 @@ All notable changes to this project are recorded here. The format follows

## [Unreleased]

### Changed

- **Compaction ends below its trigger or at a floor, whatever the summariser
does.** A pass now masks old tool outputs, summarises the oldest spans,
folds old summaries, and — when those stop short — removes the oldest spans
without a model, until the whole prompt is at `compaction_target_ratio` of
the trigger or nothing removable is left. A summariser that fails, hangs,
returns nothing or returns something unparseable is a normal outcome, and a
pass that could change nothing is not opened. See `docs/compaction.md`.
- **A summary is plain text under five fixed headings**, requested with
`complete_text` under a system-role instruction, read tolerantly (a JSON
envelope, an unterminated one, a reply without headings and a reply the
output cap cut are all kept) and clamped section by section at line
boundaries. The single JSON `summary` string and its 1,024-character ceiling
are gone.
- **Exact values are carried by code.** Every tier records what it takes out
of the window — operator instructions, files touched, identifiers of
recognisable shape with their line, failed calls, the latest plan — in one
ledger message, rebuilt by code on every pass and never shown to the
summariser.
- **Old tool outputs are masked by age** (`compaction_mask_keep_recent_results`,
`compaction_mask_min_tokens`), keeping the lines the output said only once
(`compaction_mask_distinct_lines`) and a readable pointer to the stored
original. The summariser is shown a masked output in full.
- **Adjacent units are summarised as one span** up to
`compaction_summary_group_max_tokens`, whatever their size, and a span's
output budget is `compaction_summary_ratio` of it, clamped by
`compaction_summary_min_output_tokens` and `compaction_summary_max_output_tokens`
(now 2,048). The summariser's input is bounded
(`compaction_summariser_input_max_tokens`, and a quarter of the window) and
each call has a deadline (`compaction_summary_timeout_seconds`).
- **`compaction_completed` reports every tier and the pass's `outcome`**, and
the whole prompt before and after; `compaction_fold_min_messages` is now 4.

### Removed

- `compaction_routine_min_clear_ratio` (replaced by `compaction_target_ratio`),
`compaction_summary_string_max_chars`, `compaction_summary_envelope_tokens`,
`compaction_summary_chars_per_word`, `compaction_summary_output_tokens_per_word`,
`compaction_summary_tokens_per_word`, `compaction_summary_min_words` and
`compaction_fold_summary_target_words`: they sized a JSON string in words.

### Fixed

- **A compaction the `pre_compact` hook refuses leaves the run running.** The
refusal returned from inside `COMPACTING`, and the run stayed in that state
for the rest of the turn. It now returns to `RUNNING` with the reason
`compaction_refused_by_hook`.
- **The compaction gate sizes the whole prompt.** The trigger and the
emergency cliff are whole-prompt sizes, but the gate held the history's
estimate against them. With a large system prompt and tool surface — 56k of
a 256k window in the case that showed it — the history reached the trigger
only after the request had passed the provider's ceiling, so the fit clipped
the output cap turn after turn and compaction first ran on a refusal. The
gate now adds what the last request carried besides the history (its system
messages and tool definitions, calibrated); the figure rides the snapshot.
- **A history made only of short rounds can be compacted.** Tier 2 skipped
every unit below `compaction_summary_min_unit_tokens`, and the fold takes only
summaries and operator turns, so a run that works in many rounds of one short
tool call each built a history no tier could reduce: every pass freed
nothing and the run failed on the retry budget. Adjacent small units of the
same provenance, with nothing between them, are now joined up to
`compaction_summary_group_max_tokens` (6,000; 0 disables it) and summarised
as one unit when the group clears the floor.
- **A failed round never completes on an earlier run's answer.** Completing a
failed run "on its preserved answer" read every message not tagged as
seeded, so on a host that hands the engine a session's earlier turns
untagged, the previous run's reply counted: a run whose every request was
refused completed with nothing written and no error reported. The answer
must now have been written in the round that failed, the span after the
last message a caller put in.
- **A refusal the adapter classified as final is neither retried nor wound
down.** The retry decision read only the `reason` of an attached
classification and ignored its `retryable` flag, although `ClassifiedLike`
carries both; the flag now decides when it is set. And a permanent
`LLMProviderError` no longer enters the wind-down: its one turn is a request
to the endpoint that has just refused the run, and it was refused the same
way. The fallback chain is still tried first.
- **The wind-down notice names a deadline and a stalled model for what they
are.** A run stopped by its wall clock was told it had reached its budget,
and a run whose model kept returning reasoning with no answer and no tool
call was told the model endpoint had failed — which the model then passed on
to the operator as the reason. `soft_stop_notice_text_deadline` names the
time limit, and the new `model_no_progress` cause, entered by the
empty-round policy, carries `soft_stop_notice_text_model_no_progress`. Blank
texts fall back to the general notice.
- **The wind-down notice does not outlive its run.** The "tools are withdrawn,
write your answer" message the loop writes into history when a run winds
down stayed there after the run ended. When the wind-down's own final turn
failed — a provider outage that caused the wind-down also killed the answer —
the notice was the last thing in the session, and the next turn obeyed it:
the model reported what it had not finished and called nothing, although
every tool was back. The notice is removed once the drive reaches a terminal
state and written back only for a resumed run that is still wound down.

## [2.0.0a21] - 2026-09-25

### Added
Expand Down
144 changes: 25 additions & 119 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,10 @@ crashes. The shared assistant loop is **not** a single immutable path:
`provider_reserves_output_in_context_window`, true by default — an endpoint
that sizes its input window independently of the requested output sets it
false and gets that share of the window back. Consumers read the effective
value; the emergency cliff is held strictly above it.
value; the emergency cliff is held strictly above it. Both are whole-prompt
sizes, so the gate adds to the history's estimate what the last request
carried besides it — its system messages and tool definitions, in the same
calibrated tokens; before a run's first request that part is zero.
- `runtime/request_budget.py` — fits every assembled request to the hard
window by clipping its output cap. The size it fits to is the calibrated
estimate, except near the edge: once the estimate reaches
Expand Down Expand Up @@ -544,124 +547,27 @@ crashes. The shared assistant loop is **not** a single immutable path:
snapshot and survives a resume on the same model; a new run starts from the
configured `token_estimate_calibration`, so a host that knows its content
runs dense seeds that value per scope.
- `runtime/context/compaction.py` — three passes over the transcript, in
order, each one taking what the pass before it could not.
**Tier 1** replaces an over-budget tool result with a placeholder and puts
the bytes in the blob store; the content is recoverable and the preview says
what was shed. It may also shed aged reasoning or an over-budget frozen
reference while preserving message metadata, including seed provenance.
Routine **Tier 2** and **Tier 3** leave seeded prior-run turns untouched.
**Tier 2** summarises old turns through the compaction LLM —
one atomic unit at a time, an assistant `tool_use` turn and the results
answering it standing or falling together so no pair is orphaned. It never
summarises a turn the operator wrote: an instruction is short, so
paraphrasing it frees almost nothing, and it is specific, so the paraphrase
is a rewrite — "remove the model-name field from the header" becomes "the
user asked for changes" and the run acts on that instead. **Tier 3** folds
what Tier 2 leaves: over a long session, one summary per tool batch plus
every operator message become the whole window, and neither pass below can
take a byte off them. Each contiguous run of such messages that is at least
`compaction_fold_min_messages` long and `compaction_fold_min_tokens` big
becomes one consolidated summary in which the operator's instructions
survive as exact quotes; the task turn and the
`compaction_fold_keep_operator_turns` most recent instructions stay
verbatim. Reactive provider-overflow recovery may summarise and fold seed-only spans,
keeping only the `compaction_force_keep_recent_turns` trailing messages (one by
default); every replacement retains the seed tag and
spans split at seed/current boundaries, so the host's persistence filter
keeps the runs separate. Frozen compaction references remain protected. A
fold is a summary like any other, so a later fold absorbs it once its
neighbourhood has grown again.

Both summarisers assemble their request through `build_llm_request`, record
it to the request manifest, and are told the same two rules the wording of a
summary lives or dies by: keep every identifier — paths, ids, ports, URLs,
numbers, error codes — verbatim rather than substituting a plausible value,
and state an outcome with no tool result or confirmation behind it as
UNKNOWN rather than as done or not done. The per-turn prompt states its
budget in characters as well as words, says a longer reply is cut off and
discarded, asks for the count and the records that matter instead of a copy
of a long tool result, and names the single key it wants — a model that
listed every record of a long result wrote a reply the output cap cut, and a
cut reply is never parsed. Both instructions are templates
(`compaction_turn_summary`, `compaction_fold_summary`), not literals, so an
operator serving another language has somewhere to put the translation.

What a pass is allowed to cost is bounded on every axis: a unit below
`compaction_summary_min_unit_tokens` is not sent at all (a summariser writes
a sentence or three whatever it is handed, so below some size the call is
spent to discover the summary is no smaller), the word budget in the prompt
scales with the unit rather than being a fixed sentence count and is capped
at what the output cap can hold at
`compaction_summary_output_tokens_per_word` (four — the English figure of two
understates JSON escaping and a non-Latin script, and a budget sized that way
comes back cut off, never parses and is never committed) less
`compaction_summary_envelope_tokens` for the JSON around the words, and never
above what the grammar's own `maxLength` will accept, calls go out
`compaction_summariser_parallelism` at a time instead of one after another
while the run sits in `COMPACTING`, and the fold takes at most
`compaction_fold_max_spans_per_pass` runs per pass. A summary that comes
back no smaller than what it would replace is discarded, never committed.

A call that fails for a reason belonging to the unit — the request does not
fit the summariser's own window, or the reply carried no readable summary
because the output cap cut the envelope — is counted against THAT unit in
`CompactionState.failed_anchor_keys`, and past
`compaction_summary_failed_unit_max_attempts` the routine gate stops sending
it; the fold tier still gets its turn at it. Nothing else is counted: a
transport failure (a rate limit, a 5xx, a recycled summariser) says nothing
about the unit, and neither does a summary that merely came back no smaller,
so neither retires anything. The other units in the batch commit regardless,
so one unit the summariser cannot handle no longer keeps a pass from shedding
anything. The forced passes ignore the census and try every unit — they run
when the alternative is the run ending. The census rides the run snapshot,
and entries whose unit has left the transcript are pruned at the end of every
pass, so it does not grow without bound.

Separately from the census, a pass is charged to a retry budget bounded by
`compaction_failed_max_retries`. Only a pass that tried and failed is charged
— a tier raised, or a summariser call was made and nothing came of it — and
it is charged once, however many tiers failed. Routine and proactive passes
share `CompactionState.retry_count`; the reactive pass after a provider
rejection keeps `CompactionState.reactive_retry_count`, because it is the
only profile that may compact seeded history and proactive failures prove
nothing about it. Progress by either profile clears both counters, since the
next pass of either kind faces a different history, and `rearm()` clears
them too. A transport failure therefore costs the pass one retry and the
unit nothing, while a unit-shaped failure is counted against the unit as
well; the forced passes ignore the census either way. Both counters ride the
run snapshot.

A proactive pass (routine, turn-start emergency, per-iteration) is decided
before it opens. `ContextManager.has_proactive_work` asks each tier whether
it would change anything under the proactive profile, without changing it;
when none would — a history of seeded turns is the usual case — the gate
opens no transaction at all: no `COMPACTING`, no events, hooks, usage row or
snapshot. The engine remembers that probe with the history and the constants
it saw, and does not ask again until either changes. Past the budget, a
proactive pass does not end the run: nothing has been rejected yet, so the
proactive summariser tiers are suspended (a
`compaction_exhausted_proactive_suspended` state change each time a
proactive pass exhausts the budget; `retry_count` is not reset, so after the
suspension one failed pass suspends again) and the request goes out. Tier 1 needs no LLM and keeps running through the suspension. The
suspension ends after `compaction_proactive_suspension_iterations` gate
visits, or once the prompt has grown by
`compaction_proactive_suspension_growth_ratio` of its size when it began,
whichever comes first. A context refusal lifts it at once — the provider's,
or the local fit's refusal by estimate or exact count, both of which run the
reactive pass — and so do `rearm()` and a resume from a snapshot, which does
not carry it. A reactive pass past its budget hands the turn to the
output-cap ladder while a smaller cap is left, and fails the run only when
none is.

The routine per-iteration gate also stands down for
`compaction_no_gain_backoff_iterations` iterations after a pass that freed
less than `compaction_min_gain_ratio`; the count survives the per-message
recovery reset, which used to clear it before it could skip anything. The
backoff ends early once the prompt has grown by
`compaction_no_gain_backoff_growth_ratio` of its size when it was set, and on
any context refusal.

- `runtime/context/compaction.py`, `runtime/context/carrier.py`,
`runtime/context/ledger.py` — the compaction cascade, specified in full in
[The compaction contract](compaction.md). A pass opens when the whole prompt
(history plus system prompt and tools) passes the trigger and aims at
`compaction_target_ratio` of it. **Masking** replaces old or oversized tool
outputs with a placeholder that names the tool, keeps the lines the output
said only once and points at the original in the blob store. **Summarising**
replaces the oldest spans — adjacent tool-pairing units joined up to
`compaction_summary_group_max_tokens` — with plain text under five fixed
headings, requested with `complete_text` under a system-role instruction and
read tolerantly; the **fold** merges runs of old summaries and operator
turns. The **floor** removes the oldest spans without a model when the tiers
above it stop short, so a pass always ends below the trigger or with nothing
left to remove. Every tier first records what it removes in the **ledger** —
operator words, files, exact values, failed calls, the latest plan — one
message rebuilt by code on every pass and never shown to the summariser. An
operator turn is never summarised, tool pairs stay whole, and turns seeded
from an earlier run are touched only by reactive recovery, each replacement
keeping the seed tag. `compaction_completed` reports every tier and the
pass's `outcome`; both instructions are templates
(`compaction_turn_summary`, `compaction_fold_summary`).
- `runtime/stale_result_trim.py` — the prompt-shrinking pass that costs no LLM
call. RC-gated by `tool_result_stale_trim_enabled` (**off by default**), it
rewrites the REQUEST view only — `engine.history` keeps every byte, so
Expand Down
Loading
Loading