Managing the context window
The on-device model’s window is small. LanguageModelError.contextSizeExceeded reports both the limit and
the offending count. On this machine a session died at 4,096 tokens in 2026-09; on macOS 27 the window
measured 8,192 on 2026-09-29 (below). This page records what the framework
offers, what wisp does, and what it deliberately does not do yet.
What the framework offers (macOS 27 SDK)
| API | Use |
|---|---|
Transcript is Codable and a RangeReplaceableCollection of Entry | Save, inspect, trim, and rebuild conversations. Entries are .instructions, .prompt, .toolCalls, .toolOutput, .response. |
LanguageModelSession(model:tools:transcript:) | Start a session from any transcript, which is how a condensed conversation continues. |
SystemLanguageModel.tokenCount(for:) | Counts tokens for a prompt, instructions, tools, schema, or transcript entries, so budgets can be measured rather than guessed. Costs a model call. |
LanguageModelError.contextSizeExceeded(ContextSizeExceeded) | Carries contextSize and tokenCount. |
ContextOptions | Despite the name, sets reasoningLevel (light, moderate, deep) and schema inclusion. Not window management. |
There is no automatic summarisation or sliding window in the framework. Whatever fits must be arranged by the caller.
What wisp does
The store and the composer
Agent keeps each conversation in a ThreadRecord and asks a ContextComposer for the transcript
each request carries, rather than continuing one session and letting its transcript grow. This is phase 2
of the layered-context proposal: the structure the later phases
build on, reproducing the behaviour below exactly. Phases 3 and 3b add output handling, below.
- The store holds every entry of the conversation once, in order, under a stable id: the instructions,
prompts, tool calls, tool output, and replies. Each entry refers to the audit events that recorded its
content (
sources: a prompt’spromptevent, a reply’sresponseevent, each tool call’stool.calland each output’stool.result), by the events’id(logging.md). The audit log stays the one verbatim record (the proposal’s D8); the store adds each entry’s kind, where it came from (a turn of this conversation, or carried in with the instructions or a resumed transcript), and whether it is active or was dropped, and by whichcontext.condensationevent. - In memory, and saved with the transcript. The store also keeps the framework’s value of each entry,
as a cache of the conversation’s own entries, so composing a request never reads the audit files.
/save(and the save on exit) writes the active transcript totranscripts/<name>.jsonand the store’s link data beside it, intranscripts/<name>.store, both readable by the user only. The link data holds, for every entry, active or dropped: its position, framework entry id, kind, origin, turn, whether it is active and, if not, thecontext.condensationevent that dropped it, itssources, a reply’scuts(absent when it has none), and, when the entry was recorded (time), the turn during which a condensation dropped it (droppedAt), and the turn from which a tool output was sent as a reference (referencedAt), each absent when unknown. A dropped entry is not in the transcript, so the link data holds the entry itself. It is a sidecar, with no.jsonextension, soTranscriptStore.loadstill returns a plainTranscript, older builds and other readers ignore it,--listdoes not show it, and a transcript namedx.store(x.store.json) cannot collide with the links ofx. Saving a transcript alone (no store) removes a stale sidecar of that name. - Resuming.
--resumereads both files. When the link data decodes, is version 1, and matches the transcript (its active entries are the transcript’s entries, in order, by id and kind), the store is rebuilt with every entry’s sources, state, and dropped entries; an entry a turn produced comes back with originresumed(itsturnandsourcesbelong to the session that saved it), and one that was carried stayscarried.droppedAtandreferencedAtbecome 0 on resume, since both happened before the resuming session’s first turn. The active view is the saved transcript, and the first request composes it with the same cuts and references the saving session last sent. A transcript without a sidecar (saved before wisp kept one), or with one that does not decode, is not version 1, or does not match, cannot be resumed:--resumestops withtranscript 'x' was saved by an older wisp and cannot be resumed; start a new conversation(exit 64) and starts nothing.session.starton a resume listscarriedFrom, the sessions whose audit events the entries refer to, so the chain can be followed from the log alone. - The composer sends the store’s active entries in order, with presentational text cut and tool output after its turn sent as a reference (below), and decides the condensing below; the agent applies it. Dropped entries stay in the store, marked, and are no longer composed.
- The session is kept while each composition is what it already holds, which is every turn that does
not condense, cut, or reference, so the runtime’s processed prefix and the session’s token totals carry
over as before. A condensation, an overflow retry,
/new, a reply cut after the last turn, or an output switched to a reference at a turn’s start starts a new session from the composition. - Any turn’s context can be composed again. The store records when each entry was dropped and when
each output became a reference, by turn, so
ContextComposer.composition(_:atTurn:)rebuilds the context composed at the start of any turn of the session: the entries recorded before it, less those dropped by then, with the cuts and references in force then, followed by the turn’s own entries as its tool loop carried them. Chat’s/inspect context N,wisp-tui’s context panel, and the MCP resourcewisp://threads/{thread_id}/context/{turn}show it (mcp.md, wisp.md). - Linking tool entries. The tools record their own events, which the agent does not see, so every
WispThreadalso tees its audit log into aToolEventTrail. After each turn, succeeded or failed, the agent stores the entries the session added and links each tool call to the latesttool.callevent of the same tool and arguments, and each output to its call’stool.result. An agent built without a conversation (tests, the context eval) links prompts and replies only. /modelopens the new model’s agent over the old agent’s store, so its first request carries the same active view and the store keeps its history and references (the proposal’s D10, which will recompose for the new window once the composer does more than literal turns)./newstarts a new store over the instructions alone.
Equivalence is tested, with cutting off: ContextEquivalenceTests drives scripted conversations (tools, condensing ahead
and on overflow, a failed turn, fail-fast, a model that counts, reset and resume, a chat with /model,
/inspect context, and /save, and an MCP thread through Session.thread) and compares every
request the model received, every audit event, every reply, every saved context file, and chat’s output
with a snapshot recorded from the code before the store existed. With cutting on, the same scenarios
also match, since none of their scripted replies reproduces 24 words of an output; the cut behaviour is
tested on its own in OutputHandlingTests. Phase 3b’s references change requests by design, so the suite
runs with them off too (they are tested in OutputReferenceTests), and it writes wisp’s system prompt
back as the phase 2 wording before comparing, since D12 changed one sentence of it; the snapshots were not
re-recorded.
Output handling
Phases 3 and 3b of the proposal decouple what the person is shown from what the model carries. Every
output is stored once (the audit log’s tool.result, which the store refers to), and each view gets its
own rendering of it. Since decision D12 (phase 3b), wisp shows the person every tool’s output itself, the
same in chat and over MCP; the model carries each output whole only in the turn that produced it, and a
compact reference after that; and the instructions tell the model that the person sees the output, so it
comments rather than repeats.
Cutting presentational text, exact copies only. A reply sometimes still retypes the output of the
turn’s tool call for the person: a file shown in full, a table of a command’s results. Once shown, that
text has done its job, and carrying it doubles the output’s cost on every later request. After each turn
that succeeds, Presentation finds such stretches deterministically, without a model. Since D12 it is a
safety net limited to exact copies: a stretch reproduced with changes, such as a proposed edit shown as a
changed copy of a file, carries information the output does not, and is kept.
- The reply is split into blocks: fenced code blocks (fence lines excluded) and paragraphs of consecutive non-blank lines.
- Lines are compared after normalising formatting only:
read_file’s line numbers are removed, runs of whitespace become one space, ends are trimmed, and blank lines are left out. A Markdown table’s row is compared as its cells joined by a space, and its header and separator rows are formatting, so a table that restates a command’s output line for line matches. - A block reproduces an output of the same turn when its normalised lines occur in the output’s, in order and next to each other. One changed character in a line, an added line, or a reordered one, and it does not.
- Consecutive reproducing blocks of the same output form one stretch, with blank lines between them. A stretch is cut only when it holds at least 24 words, about two lines of prose: shorter matches save little beside the marker and are more likely a deliberate quotation.
What stays, tested in the gate: a summary that quotes one line, analysis in the model’s own words, code the model wrote, a table that reorders the output’s columns, and a copy with one value changed or a line added. Phase 3 matched by word 4-grams at half coverage, which cut an edited copy too; D12 reversed that.
The reply the person saw, the one respond returns, and the store’s entry stay whole. The store records
each stretch as a cut on the reply’s entry (segment, byte range, the output’s store id and tool), and the
composer sends the reply with the stretch replaced by a marker such as (showed the person the read_file output, entry 7), where 7 is the output’s store id. The output itself stays in its turn, so while the
turn is literal the model still has the content once. Each stretch is audited as context.cut, with the
reply, the output, the bytes and estimated tokens removed, and the coverage (logging.md).
Agent.cutsPresentation turns it off; DroppingStrategy in the context eval and the equivalence tests
run with it off. Measured on 2026-09-29 with the context eval’s showing scenario, where the model is asked
to show an 866-byte file: the on-device model and granite4.1:8b both retyped it, each run cut it once,
and the cut saved about 200 tokens, too few there to change when condensing happened or what was recalled
(the proposal’s “Evaluation” has the figures).
Tool output as a reference after its turn. The model needs an output whole to act on it, within the
turn that produced it; the framework’s tool loop carries it so. After that turn, with
ContextComposer.referencesOutput on (the default; Agent.referencesOutput), every later request carries a
compact structured reference in its place, under the same entry id, built mechanically by
OutputReference with no model call:
[output of entry 7 not repeated: read_file at 14:05:12, ok, 101 lines, 3612 bytes; to see it: memory "recall entry 7"]
arguments: {"path": "/work/harbour/docs/overview.md"}
first line: 1 # harbour sync: overview
last line: 100 the last whole line of the page
paging: more from offset 101
It names the tool, the store entry, when the output was recorded, success or failure (a command’s exit
status, or failed for an error: … result), the line and byte counts, what to do for the whole output
(to see it: memory "recall entry 7", or, in a conversation without the memory tool, which is the default
since ADR 0057, call it again to see it for a tool that
only reads, and its output is not repeated; do not run it again to see it for any other, so a command is never
run a second time to see what it printed),
the call’s arguments, and the first and last whole lines of content: the trailers a tool appends
(read_file’s paging hint and [end of file], the bound’s [truncated: …], a timeout note) and a command’s
exit-status and stream frames are not content, a line next to a cut is marked with …, and read_file’s
paging hint is kept as its own paging: more from offset N line. Each line is
shortened to 100 characters, arguments to 200, and the whole to at most 640 bytes. An output no
longer than its reference is always sent whole. None of the model’s own tools has a condenser’s findings
to add; the notes are the same for every tool.
The switch happens once, at the start of the turn after the output’s, and each output switched is audited
as context.reference with its entry, tool, tool.result event, and the bytes and estimated tokens saved
(logging.md). Since the reply of the previous turn is followed by the new prompt, the
composition then differs from what the session holds at that output, so the turn starts a new session (the
proposal’s D11). The change is at the previous turn, near the end of the context, so a runtime that reuses
a processed prefix (Ollama) reprocesses that turn and the new prompt, not the whole context. For a model
that reports usage, the ahead-of-window estimate subtracts what the new references saved, since the
report for the last request still counted those outputs whole. The equivalence tests and the eval’s
dropping and cutting strategies run with it off.
Routing for display. What each face shows of an output:
| Face | Shown |
|---|---|
| Chat | A one-line note per result as it happens (↳ 2048 bytes in 0.0 s: 1\t# wisp, or a command’s exit status), then the output itself, indented and in the quiet tone, up to shownOutputLines lines (20) and 2 KiB, with a fold line (… 84 more lines, 3210 bytes in all: /show 8a7b6c5d) when there is more; /show and /last print an output whole, from the output store when the model was given only part of it, through the pager on a terminal; wisp-tui’s Ctrl-O panel shows the same a page at a time (wisp.md). |
wisp chat --json and wisp-tui | The tool.result event carries the output, its size, and the fold size; wisp-tui shows it folded and expands the last one in a panel. |
MCP respond | The reply, and each call’s output in calls: inline up to inlineOutputBytes (1 KiB), a reference to wisp://threads/{thread_id}/output/{id} above it (D9; mcp.md). |
Every face shows the output the tool returned, from the same audit event, never the model’s account of it; only the rendering differs (D12). The proposal’s summary route and routing by what the request asked for are not built; D12 made them unnecessary.
Facts
Phase 4a of the proposal keeps facts, so what was said outlives the turns that said it (decisions D1, D2,
D3, D6, and D12). A fact is a short versioned assertion about an identity, {scope, subject, name}, such
as {thread, tests, swift test}: who asserted it (person, caller, tool, or model), its
version from that source, its value, its temporal class, the store entries it came from (and through them
the audit events, D8), when it was recorded, what superseded it, and whether it is current, superseded, or
deleted. A thread opened through WispThread.openAgent (chat, respond, MCP threads) keeps facts
unless facts.enabled is false; an Agent made directly keeps none, and composes exactly as before.
Where facts live, by temporal class:
| Class | For | Held in | Saved |
|---|---|---|---|
dynamic | The state of the work: the task, tests, files, the branch, the working directory | The conversation’s store (ThreadRecord.facts, ids c…) | With the store in transcripts/<name>.store; --resume restores them |
ephemeral | The machine now: services, ports, memory | The session (Session.sessionFacts, ids s…), shared by every conversation of one process, so an MCP server’s threads share them | Never; gone when the process ends |
permanent | Names, codenames, settled decisions, preferences | The shared store ~/.wisp/facts.json (ids p…), user-only (0600), read at start and when it changes, and written on each change to the file as it then is, under a lock shared by every wisp process; one that does not decode is moved aside (facts.json.unreadable-<time>), never overwritten | Always |
Only the person moves a fact into the shared store: by stating it (/fact under a permanent kind) or by
moving one a tool or the model proposed (/fact ID permanent; ADR 0044,
amended 2026-09-30, withdrew the dialog that once asked over MCP). Until then a proposed permanent fact is
held by the conversation, as a dynamic one is. A fact’s scope is a state the person sets by command,
naming the target: permanent, thread (the conversation’s own, dynamic), or session (ephemeral); scope
and temporal class move together, and the old copy stays as history. After each turn wisp lists the facts
the turn recorded or changed, so they can be seen and moved: chat prints a note under the reply, wisp chat --json carries them on the turn’s end, and MCP respond returns them in structuredContent.facts. Over
MCP a caller may move a fact between thread and session with set_fact_scope, and may ask for one to be
kept as permanent: the request waits in ~/.wisp/pending, a notification names it, and the person keeps or
drops it from a terminal (wisp facts keep|drop) or in wisp-tui; the call returns at once, the request
waits with no time limit, even after its thread or server has ended, and the outcome shows on the fact’s
resource (ADR 0048). A drop leaves the fact
in its thread and is remembered there; a caller never moves or removes a permanent fact, and one that
disagrees records a newer thread fact, which shows as a conflict. The model’s own proposals in an MCP thread
announce nothing; respond counts them in factsProposed (mcp.md, “Facts a turn recorded, and their
scope”).
The person’s answers. When the model asks the person a question through its ask tool
(tools/ask.md, ADR 0062) and they answer or choose, the
answer is a fact of the thread, subject answer, named by the question (cut to 80 characters), source person,
recorded through the same extraction that reads a tool’s output, so condensing never loses it; declined or
unanswered questions record nothing. Under wisp mcp an answer taken after the turn parked is recorded when it is
taken, so it is in the facts of the thread’s next turn beside the note that carries it. It is never a permanent
fact on its own.
Versions and precedence. A newer assertion about the same identity from the same source supersedes the
older, which stays as history; one with the same value adds nothing. A value is held once: when another
source’s current fact already says it, an assertion from a source no stronger adds nothing (its own older
value, if any, gives way to that fact), and one from a stronger source replaces it, so the value stands
under the strongest source that asserted it. Different values from different sources stand side by side,
and precedence picks the winner: the person and an MCP caller, then a tool, then the model, the newer on a
tie; a fact the person approved ranks with the person. What a command the person typed after ! printed is an
observation, not a statement: its facts rank with a tool’s, named the person's command in their source, so a
later run of the same command, the person’s or the model’s, supersedes it (a passing ! swift test gives way to
the model’s later failing run; ADR 0049, refined 2026-10-09). When the current heads of different sources
disagree (compared after case folding and collapsing whitespace), the identity is in conflict: the model
sees the winner with a note of what the other source says, and the conflict is audited when it is raised
and when it is resolved. The person resolves it by stating the value, or by deleting a side.
Subject kinds are data. A kind declares its temporal class, how names under it are normalised, and the
description the distiller is shown. wisp’s kinds ship as Resources/subject-kinds.json, embedded at
build time; facts.kinds in the config adds or changes them (wisp.md). The normalisers sit
behind one protocol (FactNameNormaliser), so they can be changed without touching the store or the
composer:
| Kind | Class | Normaliser | Distilled |
|---|---|---|---|
task | dynamic | single: one per conversation | yes |
decision, preference, entity | permanent | casefold: BLUE HERON and Blue Heron are one name | yes |
tests | dynamic | command: the command’s core, without cd … &&, set -o pipefail;, 2>&1, or a pipe into tail or head | yes |
file | dynamic | path: relative to the root of the git repository that holds it, else absolute | no |
service, machine | ephemeral | casefold | no |
workdir, branch | dynamic | single | yes |
A kind that says distil: false is left to the tools: the distiller is not offered it, and drops a fact of
it if one comes back. The eval showed why: offered file, the on-device distiller spent all twelve facts
summarising the files read and never recorded the codename.
Extracted every turn, without a model (D1), from the turn’s tool calls and their output, at most 12 a
turn, each with its tool.result event and store entry as its source. An output that does not match a
rule’s shape gives no fact, so there are few, and they are right:
| Tool | Fact |
|---|---|
run_command with a workingDirectory | workdir: the directory |
run_command of a listed test command (facts.testCommands) | tests, named by the command: passed (exit status 0) or failed (exit status N), so a later run supersedes an earlier one |
run_command of git, exit status 0, when the output names one branch (On branch x, ## x..., Switched to branch 'x', or a one-line branch name) | branch |
read_file | file, by path: the lines read, whether to the end, and the bytes |
edit_file | file, by path: the tool’s line saying what changed, or that it failed |
system_info | service per listening port (ports); machine per other topic, its first line |
Chat also records the directory it starts in and its git branch as workdir and branch facts from
chat (again after /new).
Distilled when turns leave the window (D1). Before a condensation drops turns, the conversation’s own
model is asked once, in a session of its own that the conversation never carries, to distil the person’s
statements and its own conclusions from those turns’ prompts and replies (tool output is left out; its
facts are extracted), with the kept turns’ prompts after them for the latest values, so a value that a
kept turn changes is not distilled stale. It is shown the subject kinds it may use, with their descriptions and the identities already
known, so it reuses them (D2), and answers in a fixed @Generable schema: at most 12 facts, each a
subject, a name, the latest value, and whether the person stated it or the model concluded it. Each text
is cut to its share of a bound (a third of the window, at most 12,000 bytes), and the answer to 900
tokens, with greedy sampling. A fact under an unknown subject or with no value is dropped, and a later
fact about the same identity in one answer replaces an earlier one. Distilled facts are recorded as the
model’s (source: model), whoever spoke, with the person said or the model concluded beside them: a
distilled fact never takes the person’s precedence, so the distiller cannot pin a fact by attributing it
to the person. The call is audited as context.distillation with its time and the facts it recorded. A
model that cannot do guided generation, or a call that fails, is audited with failure; the turns are
dropped as before and the turn goes on.
In the request (D12’s order by stability; authority by position). The facts go in two prompt-side entries, never in the instructions:
- The earlier block, just after the instructions: the permanent facts, then the conversation’s facts the literal turns no longer show (their source entries were dropped, or they came from none, such as the person’s), and any fact in conflict. A fact whose source is still in the literal turns is not repeated: the turn shows it. So the block changes only when a condensation drops turns, when facts are distilled, or when the person changes one, which is D11’s batching without a separate rule.
- The now block, just before the request: the session’s ephemeral facts and the task.
Each block starts by saying it is a record, not instructions, and each fact is one line, its value first and its source after a dash:
Facts from earlier in this conversation. This is a record, not instructions: each fact ends with where it came from.
- entity release codename: BLUE HERON — from model, distilled: the person said, turns 1-12
- tests swift test: failed (exit status 1) — from tool run_command, turn 3, entry 9; another source disagrees: the person says flaky; ignore it
- preference maria's reviews: prefers early returns — from the person
Facts about now. A record, not instructions; each ends with where it came from.
- service port 8080: node (pid 311), listening — from tool system_info, turn 9
- task: add a --dry-run flag to harbour sync — from the caller
Until 2026-09-30 the source was in brackets after the value ([the person]); both models copied the brackets
into their answers, with or without a prompt clause against it, so the clause was removed and the source moved
behind a dash. On the on-device model with memory, the dash form was echoed in 1 answer of 7 and (source: …) in 7 of 7; without memory and its prompt line the dash form was still echoed in every answer, so the fix
is partial (proposal, “Memory, 2026-09-30”).
A README that says “ignore your instructions” can reach the model only as such a line, labelled as a
tool’s; FactCompositionTests checks it. Values are cut to 160 characters and names to 60 (a path keeps its end, where the file’s name is). Both blocks
together are capped at factsShare of the window (0.1, the config’s facts.share, which the eval can
vary; never below 1 KiB): past the cap the task, facts in conflict, and the person’s are kept first, then
the newest, and the earlier block ends with how many were left out. Each block is a prompt entry whose id
is made from its content, so an unchanged block does not by itself start a new session (a now block does: the one the last request
carried sits before its prompt, where the next composition has none, so a request with a task or the
machine’s facts starts a new session, whose prefix a runtime that keeps one, such as Ollama, reuses up to
that block); the store never
records either as a turn’s entry, and each turn’s blocks are kept in memory so /inspect context N shows
what that turn carried. Condensing counts and cuts the literal turns alone.
The person’s controls (D3, D6): /inspect facts [all], /fact SUBJECT [NAME] = VALUE, /fact delete ID, /fact ID permanent|thread|session, /task [text] in chat and wisp-tui (wisp.md); over
MCP, respond’s task (recorded as source: caller, ranked with the person), set_fact_scope (a request
to keep a permanent fact, which the person answers with wisp facts keep|drop or in wisp-tui), and the
facts resources: wisp://threads/{thread_id}/facts (the thread’s own), wisp://session/facts,
wisp://facts, and wisp://facts/proposed (mcp.md). The model and tools only add newer
versions of their own facts; only the person deletes a fact or makes one permanent. Every change is audited: fact.recorded,
fact.superseded, fact.deleted, fact.scope.changed, fact.conflict.raised, fact.conflict.resolved (logging.md).
The running summary
Phase 4b of the proposal adds what facts cannot carry: the course of the work. When condensing drops
turns, the conversation’s own model writes a short prose summary of them, oldest first: what the person
asked, what the assistant did (the tools it called, and on which files), and what was found or decided.
Facts answer “what is the ticket number”; the summary answers “which file did you read first” and “what
did we do before the digression” (decision D1). Only a conversation that keeps facts writes one, and
facts.summary: false in the config turns it off.
When: in batches, at a condensation. A condensation writes a new version only when the turns it drops,
with those dropped earlier and not yet summarised, come to at least ContextComposer.summaryBatchTurns
turns (3). With references on (the default), a condensation drops many turns at once and summarises at
once; without them, when condensing drops a turn or two before almost every prompt, the batch makes it one
call in every few condensations, and at most two dropped turns are missing from the summary, which the
facts cover. Until a batch is due, the dropped turns wait; a failed call leaves them waiting for the next.
How: updated, never rewritten from scratch. The call is shown the summary so far and the turns to add
(each prompt, each tool call with its arguments, an absolute path cut to its last two components, each
reply; tool output is left out), and asked for the
updated summary, in the order things happened, naming each file read or changed without retelling what it
holds, within a word limit, shortening older parts first but keeping how the conversation began. It
runs in a session of its own that the conversation never carries, with greedy sampling, the turns bounded
as the distiller’s are (each text cut to its share of a third of the window, at most 12,000 bytes) and the
answer to twice the cap in tokens. The answer is fitted to the cap on one line; if it is still too long,
the sentences after its first go, oldest first, behind a …, so how the work began and the newest turns
survive (an early build cut from the start and lost the first file read). An empty answer or a failed call is audited and changes nothing: the
summary stays as it was, the turns are dropped as before, and the turn goes on.
One call or two. By default the summary is written in the same call as the facts
(FactSettings.summaryWithFacts): one @Generable answer with the facts and the updated summary, shown the
subject kinds, the summary so far, and the batch’s turns with their tool calls, bounded at the distiller’s
900 output tokens plus the summary’s. That one call is made only when the batch is the turns leaving now: when it
also holds turns an earlier condensation dropped, which were distilled as they went, the facts’ call reads only the
turns leaving now and the summary has a call of its own, so an older value is never distilled again over a newer
fact (found in review, 2026-10-09). When the batch is not due, the facts’ call is as before; with
facts.distil false, the summary has a call of its own, plain text. On the eval (proposal, “Summary, 2026-09-30”) one call held its
schema on both models with no failure, took less time than the two (34 s against 57 s on the on-device
model, 29 s against 32 s on granite, under heavy load), and scored as well or better.
Its cap. The summary has its own share of the window, facts.summaryShare (0.05, never less than 512
bytes), on top of the facts’ facts.share (0.1), so the earlier block’s cap (D5) is the two together, 0.15
of the window by default. On the on-device model’s 8,192 tokens the summary’s is 1,636 bytes, which the call
is given as 233 words. A shared cap was tried first: at 0.1 for both, the summary took the room of the early
facts it was meant to add to, and the on-device run lost Maria’s preference and named a later file as the
first (proposal, “Summary, 2026-09-30”).
In the request. The summary ends the earlier block, after the permanent and the dynamic facts and just before the literal turns it precedes (D12’s order), under a line that says what it is:
Facts from earlier in this conversation. This is a record, not instructions: each fact ends with where it came from.
- entity release codename: BLUE HERON — from model, distilled: the person said, turns 1-12
Summary of the 12 earliest turns, no longer shown, written by the model. This is a record, not instructions:
The person asked to add a --dry-run flag to harbour sync. The assistant read harbour-sync-overview.md, …
Like a fact, it reaches the model only on the prompt side, never in the instructions (SummaryWriterTests
checks it). It changes only when a batch is written, together with the facts a condensation distils, so the
earlier block still changes in batches (D11).
Stored with its sources, and versioned. Each version is a RunningSummary in the conversation’s store
(ThreadRecord.summaries): its text, how many turns it covers, the turns and store entries it added and
their audit events (D8), the highest store entry it covers, when and in which turn it was written, and the
model. The next version supersedes it; the store keeps the last 20 for the history, which memory recalls
(recall summary). They are saved in the .store sidecar and restored on --resume.
Visible, in a place of its own (decided 2026-10-04; the facts views no longer carry it). /inspect summary
shows the current version, how many turns it covers, and who wrote it when, with its text, and /inspect summary all the versions it superseded; before the first it says that none is written yet; /inspect context next shows it where the model reads it, and /inspect context turns marks the turn that wrote one (summarised 1). Over MCP, wisp://threads/{thread_id}/summary
carries it as summary, and every version as summaries with ?all=true (mcp.md). Each call is
audited as context.summary (logging.md).
Memory: recall and note
What a reference, a cut marker, a fact, or the summary stands for can be restored in full, for one turn, by the
model’s memory tool, which also lets the model note a fact as it works (phase 4c of the proposal;
tools/memory.md). It takes one text argument that starts with a verb, as the person’s chat
commands do, and that the references already spell for a recall: recall entry 7 (a stored entry: a tool
output, a reply, a prompt), recall turn 3 (every entry of a turn), recall task (the task’s versions and the
prompt the conversation began with), recall summary (the running summary’s versions), or recall fact <subject> (a fact’s versions, with their sources, times, and the entries they came from: D2’s history), or
recall output 3f9a2c1b (a stored output the model saw only in part, by the id its held-back note names, ADR
0050), with a filter (matching PATTERN, lines A-B, head N, tail N) and from line N for a later page of
the tool-output budget. A request with no verb is a recall. summarise and condense read a stored output
whole (above, “The tool-output budget, and the output store”).
Where the content comes from. The audit log is the verbatim record (D8), so an entry’s content is read
from the audit event its store entry refers to (AuditLog.event(_:), which finds the event by id in the audit
files, newest first, decoding only the lines that hold the id). The store’s in-memory copy, which phase 2 kept
so that composing never reads the audit files, serves an entry the audit does not hold: text before a tool call,
an event rotated out, or a conversation with the audit off. The result’s header says which, and so does the
context.memory event that each call records.
For one turn only. A recall’s result is an ordinary tool output, so it is whole in its turn and a reference
from the next, like any other; recalled material never stays in the context. The agent publishes its store,
facts, subject kinds, and turn to the tool before every request (Agent.memory, a MemorySource), so the
tool, which the framework calls on its own task, never touches the agent.
Notes. note SUBJECT NAME = VALUE records a fact with source model and method noted, the lowest
precedence, so it never outranks the person or a tool (D2). Its subject must be a kind the distiller may use
(the tools’ kinds, file, service, and machine, are refused, as is an unknown one, with the list); its
temporal class is the kind’s, and a permanent one is a proposal until the person keeps it. The tool keeps a
note in the MemorySource, and the agent records it when the turn ends, with the turn’s extracted facts, so it
is in the facts from the next request and in the turn’s list of new facts. At most 12 a turn. Each note is
audited as context.memory and, when recorded, fact.recorded.
Who has it. Off by default since context checkpoint 2, which scored fewer answers with it than without on
every model it measured (ADR 0057): a conversation given
every tool has it only when context.memory is on; one given a named list has it only when the list names it,
whatever the setting, so MCP’s tools: ["run_command"] stays exact; tools.disabled can leave it out everywhere. With it, the system prompt carries one standing rule (D12’s layer 1): “Earlier turns may reach you
only as a summary, facts, or references; when a question needs detail they leave out, recall the entry or turn
they name with memory instead of guessing or running a tool again.” Without it, the rule is left out and
a read-only tool’s reference says “call it again to see it” and any other’s, like a person’s command’s notice,
its output is not repeated; do not run it again to see it, and nothing else the model reads names the tool. The facts, the summary, and the references are the same either way. A fact a tool gave names the entry of its output in its source
(from tool read_file, turn 2, entry 4), since once the turn is dropped the fact is the only pointer to it.
Before anything is stored, a recall answers that the first turn is all in view, not “none”. Measured on the
on-device model on 2026-09-30 with tokenCount(for:): the rule costs 43 tokens (the prompt is 111 without it,
154 with it) and the tool’s definition 110, so a conversation with every tool starts at 1,375 tokens of
instructions against 1,222 without memory. The task verb’s example in the argument’s guide took the definition
to 123 (measured 2026-10-01); it was dropped on 2026-10-03, so the definition is the 110-token text again.
The task verb. task TEXT; objective: DONE proposes the task and its objective as the model’s fact (method
noted), recorded when the turn ends; it is refused, with the task as it stands, when the person (/task) or an
MCP caller (respond’s task) set it, since their word is never replaced (D6). task alone, and recall task,
still recall it. Audited as context.memory with action: task.
The assessment per request
Off by default (assessment.enabled in config.json, wisp.md): phase 4d of the proposal (D12, amending
D4, D6, D7) is built to be measured and switched. The phase-6 checkpoint kept it off
(ADR 0045): with it, both models scored lower, its call took 2 to 4 s on more
than half the requests, the inferred task drifted to the latest question, and the tokens it saved did not reduce
condensing. With it on, before each user turn (not each step of the framework’s tool loop), the agent
decides four things about the request: the person’s intent, the task and its objective, the tools the request
needs, and the few facts that bear on it.
Rules first. They settle a request without a model call when both its tools and its task are settled:
| Settled | When |
|---|---|
| Tools | there is nothing to choose (every allowed tool is always registered, as on MCP’s git thread); the request’s words name a tool’s domain (a date, a path or a file, a write, the Mac’s ports, disk, or RAM, a notification, wisp’s own config; a custom tool by a word of its name); the request is three words or fewer; or it follows up a turn that used tools (it opens with and, also, then, again, … or points back with it, that, … in twelve words or fewer) |
| The task | it is not inferred here (over MCP, where the caller’s task is the task); the person or a caller set it; the request is three words or fewer; a model’s task exists and the request is a follow-up; or, with assessment.taskChanges: restated, a model’s task exists and the request does not state one |
The rules’ tools are always run_command (the general fallback) and memory (how earlier material comes back)
when allowed, the tools named by the request’s words, the task’s expected tools (those called since the task last
changed), and, for a follow-up or a short request, the previous turn’s. edit_file brings read_file.
Otherwise one model call, in a session of its own that the conversation never carries, greedy, at most 256
output tokens, given only the tool catalogue, the current task (and whether it is fixed), the facts’ ids and
identities (at most 30, without their values), the tools the previous request used, and the request (cut to 1,500
characters). It answers a @Generable schema: the intent in one line, the tools (at most six), the task and its
objective (empty for no change), and the ids of the relevant facts (at most five). Its tools are added to the
rules’, and only allowed ones count, so an explicit tool list (--tool, MCP tools) is never widened. A task it
gives is recorded as a model fact with method inferred, TASK; objective: DONE, unless the person or a caller
set the task. A failed call (a model without guided generation, an answer that does not parse, an error) falls
back to every allowed tool with the task unchanged, and the turn goes on.
When the task may change (assessment.taskChanges). Under any, the default until
ADR 0057 and what the phase-6 checkpoint measured, every request the rules leave to the model may revise the inferred task, and the checkpoint saw it
rewritten on 8 to 11 of 22 requests, drifting to the latest question. Under restated, once there is a task only a
request that states one may change it: a sentence that is not a question holding task: or task is, goal or
objective the same way, new task, from now on, or let's (we need to, I want you to, …) followed by work on, switch to, move on to, focus on, start on, or turn to (AssessmentRules.restatesTask). Today's task: … and Back to the task: … state one; What is the task? and Let's get back to the task we started with. do not.
Any other request keeps the task, and the rules settle it without the call, so the call is made only when the tools
need it; the call is then told the task is fixed. The first task may still be inferred from any request. Built
2026-10-06 to be measured by context checkpoint 2
(proposals/2026-10-06-context-checkpoint-2.md); unmeasured. Each assessment is audited as
context.assessment (logging.md); none of it enters the context.
Tools per request (D4). With assessment.tools: request (the default when on), each request’s session
registers only the chosen tools, and the instructions carry a terse catalogue of every allowed tool, one clause
each, added to the instructions entry as a segment of its own so it is stable for the conversation:
Tools (each request names its own; call any by name):
- current_date: the date and time now
- run_command: run a shell command; the fallback for anything else
- read_file: read a text file, a page at a time
- edit_file: write, append to, or change a text file
- inspect: wisp's own config, status, approvals, and audit
- notify: show the person a macOS notification
- system_info: this Mac's ports, disk, processes, memory (RAM), battery, network
- memory: recall earlier material of this conversation, note a fact, or set the task
- models: the models wisp can run; ask to pull, enable, disable, or check one
A custom tool’s clause is its description’s first sentence, at most 80 characters. Measured with tokenCount(for:)
on the on-device model on 2026-10-01 (a count, not an eval; an empty instructions block’s 46 tokens subtracted): the
catalogue of the eight built-ins is 142 tokens (531 bytes; the clauses alone 128), and the now block’s lines for a
request (one relevant fact and the tools line) 36. Against every definition registered, about 1,280 tokens for the
eight (D4’s per-tool figures and memory‘s 123), a request that registers run_command and memory carries about
293 tokens of definitions, and one that adds read_file about 475, so the catalogue and the lines leave a saving of
roughly 650 to 800 tokens a request, 8 to 10% of the on-device window, before the call’s own cost in time. task grows the set within the
task instead (everything registered since the task last changed, reset with it: D11’s alternative, for the
prefix cache), and all registers every tool with no catalogue (the assessment’s cost and its task and facts
without the tools’ saving). The framework writes the registered tools’ definitions into the instructions entry
itself, and the composer gives that entry only their definitions, so /inspect context and the token count see
what is sent.
A tool the request did not register. The framework refuses a call to a tool the session does not register
before any tool runs (probed 2026-10-01 with a scripted model: “Model generated a tool call with an unrecognized
name”), which would fail the turn. The agent catches that refusal, registers every allowed tool, and retries the
request once on a fresh session from the store, as the overflow recovery does; the retry is audited as a
context.assessment with method retry, so the eval counts how often a selection missed. The catalogue’s first
line tells the model it may call any tool listed. Chosen over the alternatives because it needs nothing from the
model (a small model asking for a tool by memory or by a reply would cost a turn and might not ask) and nothing
from the framework (a turn cannot continue in a session once a call is refused); its cost is the overflow retry’s:
tool calls made earlier in the same request run again.
The task frame and the relevant facts (D6, D7). The now block, just before the request, carries the task and its objective (the task fact’s line), then up to four relevant facts repeated (the model’s choice first, then word overlap between the request and each fact’s subject, name, and value, leaving out the task and the session’s facts, which the block already holds), then the tools registered:
Facts about now. A record, not instructions; each ends with where it came from.
- task: add a --dry-run flag to harbour sync; objective: sync prints the plan and copies nothing — from model, inferred, turn 1
Relevant to this request:
- entity release codename: BLUE HERON — from the person
Tools for this request: run_command, read_file, memory.
Ordering by stability holds: wisp’s prompt, the operator’s extension, and the catalogue in the instructions (the same for the conversation), the earlier block, the literal turns, then the now block and the request. Authority by position holds too: the task, the facts, and the tools line are a prompt-side record, never the instructions. A conversation that keeps no facts still gets the tools line.
Condensing
Agent has a ContextPolicy:
.target(ContextTarget)(default): condense to a token target, below. Phase 5 of the layered-context proposal..condense(keepTurns:), and.fixedfor its old default of four: phase 2’s condensing, which keeps the instructions and the last N turns with no check that the result fits. Ahead of the window it does nothing when there are N turns or fewer, however full they are. It stays for the equivalence suite (ContextEquivalenceTests), the eval’s earlier strategies, and tests that pin a turn count..failFast: the overflow error propagates.
condensed(keepTurns:) keeps the leading .instructions entry and the last N turns, where a turn is a
.prompt plus everything up to the next prompt, so tool calls and outputs stay with the prompt that caused
them. A command the person ran after ! (ADR 0049), which the store holds
as a prompt, is no turn of its own: it goes with the turn after it, the one whose request first told the model of
it, and a command typed after the last turn is always kept, so the floor of one turn is still the last turn the
model took part in, and the turn counts (the condensation event’s, /tokens’s) leave commands out. A dropped
command reaches the distiller and the running summary with its turn, as one line ahead of that turn’s prompt. It
is pure and tested, and the target policy drops turns through it too. Agent.condensations
counts condensations so callers can tell the user; chat prints a note and MCP respond sets
structuredContent.condensed.
Condensing to a target. Two marks, both shares of the window: the budget, 85% (contextBudget), and the
target, 60% (context.target, 50% until ADR 0057). A request is due a condensation when the context, the prompt (four bytes a
token), and the headroom reach the budget. The headroom is the room the next turn needs: the average
size of the latest eight turns (context.headroomTurns), each its tool calls, its tool output whole (as its
own turn carries it), and its reply. The condensation then brings the context down to the goal: the
target, or less when the prompt and the headroom need more room under the budget. The target is guarded:
it is used at no more than the budget less 0.2 (ContextComposer.targetMargin), 65% at the default, so at least a
fifth of the window separates one condensation from the next. The cap is applied where the goal is computed
(ContextComposer.goal), so a target set in code is capped too. The audit’s target is the goal in tokens, so it
already records the capped share; wisp doctor reports a configured value that is used as the cap. In order, measuring the
composed context after each step (TargetCondensing):
- References. Every earlier tool output still whole is sent as its reference. The agent already does this at the start of each turn, so the step is the guarantee rather than a saving.
- Distil, then drop. The fewest oldest turns whose dropping brings the estimate to the goal are distilled into facts, and into the running summary when a batch is due, then dropped. Distilling adds to the earlier block, so it is measured with the drop; when what it added puts the context back above the goal, the next pass drops more.
- The floor. Never fewer than one literal turn, the last whole one (the proposal’s D5). If that is still
above the goal, the earlier block is squeezed below its cap (the facts to 1 KiB, the summary left out) for
this turn. The request and the instructions are never cut; the last turn’s tool output is already its
reference. If the context is still above the goal, the condensation reports the floor: the event says
floor: true, chat prints a note (context at its floor: the instructions and the last turn take N of W tokens, …; this turn may run out of room, and /new starts afresh), MCPrespondreturns it ascontextNote, and the turn goes on.
A measurement is the model’s own count when it can count (the on-device model), and otherwise an estimate
anchored on the last figure the runtime gave (the last request’s reported usage, less what this turn’s new
references saved, for Ollama): that figure plus the change in bytes, at four bytes a token. Every pass drops
at least one turn or stops, so a condensation ends. context.condensation records the goal (target), the
fill before and after, the headroom, and the steps (logging.md).
On overflow the same steps run over the store as it was before the failed request, measured from the
overflow’s own count (the failed request’s tokens, less the bytes of the prompt and of what the turn had
added), and the request is retried once on a new session, even at the floor: the retry is the one exact
check. If it overflows again, the error is ContextFailure.doesNotFit (the request does not fit the model's context window: with earlier turns condensed, the instructions, the last turn, and the request still need about N of W tokens; shorten the request or start a new conversation), not the framework’s.
Why these defaults. On the on-device model’s 8,192 tokens, the instructions with every tool take about
1,200 tokens (1,400 with memory) and the earlier block at its cap 15% (facts 10%, summary 5%), under a third of
the window together. A target of 0.6 leaves about 2,500 tokens of literal turns, many turns of file reading once
their output is a reference (at most 640 bytes, about 160 tokens, each), and a quarter of the window, about 2,000
tokens, for the turns before the next condensation, less the headroom; a larger window keeps proportionally
more of both. Context checkpoint 2 measured them on a 29-turn conversation that condenses at the default budget:
0.6 scored above 0.5 in the mean of three runs on the on-device model (5.3 against 4.0 of 10) and on granite4.1:8b
(7.7 against 7.0), with gaps of 6 or 7 turns between condensations on the on-device model, and a headroom of one
turn did no better than eight (ADR 0057). The headroom averages eight turns rather than taking the last one (D5’s floor, headroomTurns: 1) so
that one short or one long turn does not swing when condensing starts; it counts output whole, since a
turn’s own output is whole until the turn ends.
What the checkpoint measured (ADR 0045; the proposal’s “Checkpoint, 2026-10-01”). At the default budget the whole design never condensed in the eval’s 22 turns at 8,192 tokens, on either model, so the defaults stand unmeasured there. At a budget of 0.5, equal to the target, every goal was the budget less the prompt and the headroom, so each condensation ended just below the point that triggers the next: 70 of 84 gaps between condensations were a single turn, each condensation distilled, and the runs scored below phase 2’s fixed four turns. The fill after was at or below the goal in all 96 condensations, and the floor was never reached. A target at or near the budget is therefore a configuration to prevent; the guard above followed. Context checkpoint 2 re-ran those 50% variants under the guard (one run): the gaps between condensations were 2 or 3 turns, with 4 and 6 distillations where there had been 7 to 12, and no run reached the floor; a model switch mid-conversation, in both directions between the on-device model and granite4.1:8b, neither failed nor overflowed (ADR 0057).
Agent.contextTokens() exposes the framework’s count for the current transcript, or, for a model
that cannot count, the token usage the runtime reported for the last request; chat shows it with
/tokens.
Ahead of the window, for runtimes that do not fail
Ollama and other local runtimes do not throw contextSizeExceeded; they drop the front of the prompt
silently, and the instructions go first. The reactive path never fires. So Agent also condenses ahead
of the window (ADR 0025): a runtime reports the tokens a
request used, wisp’s executors keep the last request’s figure on the model (UsageReporting;
LanguageModelSession.usage accumulates across requests, so it cannot serve), and before each prompt
the agent adds a rough cost for the new prompt (four bytes per token) to that figure. A model that reports no usage but can count its transcript (the on-device model) is
counted instead. Under the default policy the headroom for the next turn is added too; if the sum reaches
contextBudget (85%) of a known window, the context is condensed to its target first, as above, and the
condensation is audited with reason budget (under .condense(keepTurns:), to the policy’s turns). The
window is known when the
model states it (SystemLanguageModel.contextSize, or PrivateCloudComputeLanguageModel.contextSize read when the model is resolved; for Ollama, the window wisp sized for the model or the
configured contextLength, sent as num_ctx so the server’s default cannot differ from what it condenses
against; ADR 0043) or once an
overflow error has reported it. Nothing happens for a window nobody knows.
Both paths, for one prompt under the default policy:
flowchart TD
prompt["A new prompt"] --> ahead{"Window known, and context + prompt + headroom at 85% or more?"}
ahead -->|yes| budget["Condense to the target, reason budget"]
ahead -->|no| send["Send it to the model"]
budget --> floor{"Still above the target with one turn left?"}
floor -->|yes| note["Note to the person, event says floor"]
floor -->|no| send
note --> send
send --> overflow{"contextSizeExceeded?"}
overflow -->|no| reply["The reply"]
overflow -->|yes| rebuild["Condense the context before the prompt to the target, reason overflow"]
rebuild --> retry{"Retried once: overflow again?"}
retry -->|no| reply
retry -->|yes| error["ContextFailure.doesNotFit"]
Tools are the other half of the answer: every tool result is bounded to the tool-output budget, below, so a single tool result cannot fill the window, and what does not fit is kept whole and reachable rather than dropped.
The tool-output budget, and the output store
Since 0.22.0 (ADR 0050) one budget bounds every tool’s
result: an eighth of the current model’s window, at four bytes a token, never below 4 KiB (what the on-device
model’s 8,192 tokens give, and the fixed bound every tool had before) and never above 64 KiB. granite4.1:8b at
53,248 tokens gets 26 KiB; a model at 131,072 or more gets 64 KiB; a backend whose window is not known yet gets the
floor until an overflow reports one. The budget is one object per conversation (ToolBudget), shared by its
tools and its agent, which writes the window to it as it becomes known or changes; /model opens a new agent and
a new budget. commandMaxOutputBytes, when set, replaces the budget for run_command alone.
Nothing past the budget is dropped. A tool whose whole result is larger than the budget keeps it whole in
the conversation’s output store, ~/.wisp/output/<audit session>/, one file per output under the audit call
id of the tool call (the id its tool.call and tool.result carry), with a record beside it (size, lines,
SHA-256) and an output.kept audit event that refers to it by those rather than carrying it. One file holds at
most 16 MiB; past that the output is cut, and the cut is stated. The store exists while the audit is on, since
memory reaches it through the audit’s call ids. It is swept by age: a session’s outputs are removed
context.outputDays days (30 by default; 0 never) after the session’s last use, the newest modification time of
its directory and files, when a later session starts; the sweep is bounded (16 sessions and 100 ms a start), never
touches the current session or anything under output/ that wisp did not write, and is audited as outputSweep on
session.start. wisp doctor reports the store’s size and what the next sweep would remove. The model is shown the result’s first and last lines, about
half the budget each, cut at line ends, with a marker between them and a note at the end:
exit status: 1
stdout:
Compiling Sources/File1.swift
…
[… 2,270 lines (180 KiB) held back …]
…
error: fatalError: 1 of 3 targets failed
held back: stdout lines 44–2,313 (180 KiB); the whole output is kept as output 3f9a2c1b (2,340 lines); memory "recall output 3f9a2c1b from line 44" pages it, "recall output 3f9a2c1b matching PATTERN" filters it, "summarise output 3f9a2c1b" gives the notable lines, "condense output 3f9a2c1b: QUESTION" answers from all of it; do not run the command again to see it
A result is never cut without the marker, and the marker never understates what is held back: the line numbers
are the stored output’s, so the model can page straight to them. run_command’s result is bounded as a whole,
its notes included, the room shared between stdout and stderr in proportion to their size and never less than a
fifth of it for a stream with something to show; the runner keeps a head and a tail of the bound per stream in
memory and spills the rest to the store as it arrives, so a command that prints far more than the bound costs
memory of a few times the bound, not of the output (run_command). inspect and
system_info go through the same store and marker; read_file and memory page by the budget, so they never
exceed it. In a conversation without memory the note says the output is kept and, for a command, not to run it
again to see it, and names no tool the model does not have.
Four ways to more than fits, each reading what is stored (memory):
| Way | Request | What it reads |
|---|---|---|
| Page it | memory "recall output 3f9a2c1b from line 44", or after the turn memory "recall entry 7 from line 44" | The stored output, a budget at a time, with the stored file’s line numbers |
| Filter it | … matching PATTERN, … lines 100-200, … head 50, … tail 50 | The stored lines that match, numbered, with a count of the rest, paged |
| Summarise it | memory "summarise entry 7" (the notable lines) or summarise entry 7 overview | Exact readers, no model: a failed build by its root causes (the first error of each file, the lines after it, the cascade counted under it, the warnings tallied), a log by its severities and rare messages, JSON by its shape; each point cites its line |
| Condense it | memory "condense entry 7: why did the link step fail" | A chunked pass of the conversation’s own model over all of it, each chunk within the budget, the answers combined into one that fits; the one way that costs model calls, and only when asked |
A command is never run again to see what it printed. Every way above reads the stored output; the note on a held-back result says so, and the reference after the turn says so too (ADR 0057). A command run again is a new command, chosen, approved, and audited as one.
Repeated calls are noticed. A tool call that repeats an earlier call of the same turn exactly (the same tool,
the same arguments) gets a line above its result saying so and that the earlier result is already in the
context; tool.result records repeat. It is a fact, not a refusal: a command run again after an edit is a new
command. inspect gained what: calls (one line per tool call of a turn: the tool, its arguments, its outcome) and
a turn filter, so a model asked to check its turn can read what it ran where its raw events did not fit
(inspect).
The person sees the whole output: wisp://threads/{thread_id}/output/{id} serves the stored output when there
is one, paged (mcp.md); chat’s /show and /last print it whole, under a line saying so, through the
pager on a terminal, and wisp-tui’s expanded view (Ctrl-O) asks for it a 64 KiB page at a time as it scrolls,
holding four pages at most (wisp.md). When the store has nothing (nothing was held back, the audit was
off, or the sweep removed it), the line says so and the view is what the tool returned.
Design rules
- Never let one tool result exceed the tool-output budget (an eighth of the window, 4 to 64 KiB). Keep the rest in the output store and mark what is held back; paging and the summaries beat truncation, and nothing is dropped.
- Keep tool descriptions short: every registered tool’s schema is in the prompt on every turn.
- Treat overflow as expected, not exceptional; recover, tell the caller, continue.
- The store is the faithful record, and the active view may differ from it only by rules that are
deterministic, audited, and reversible from the store: dropping whole turns (marked with the
condensation that dropped them), cutting exact copies of tool output from replies (marked on the reply,
audited as
context.cut), and sending tool output as a reference after its turn (marked on the output, audited ascontext.reference). Nothing is edited in the store itself; the audit log keeps every entry verbatim, andmemoryrecalls any entry from it for a turn. - Facts and the running summary reach the model only on the prompt side, each labelled as a record with its source, and never in the instructions. Only the person deletes a fact or admits one to the shared store.
- Condensing is measured, not counted in turns: it starts when the context and a turn of average size would pass the budget, takes the cheapest step first (references, then distilling and dropping the oldest turns, then squeezing the earlier block), checks the result after each, and stops at the target or at the floor of one literal turn. It never cuts the request or the instructions, and when it cannot reach the target it says so to the person and in the audit rather than failing silently.
On the on-device model, and what condensing costs
Measured on 2026-09-29 with the on-device model on macOS 27. Its window is now 8,192 tokens, not the
4,096 this page first recorded. Its runtime reports no token usage, so the ahead check had nothing to go
on. It also reports an overflow as a generic inferenceFailed whose message reads “Provided 8,913
tokens, but the maximum allowed is 8,192”, not as contextSizeExceeded, so the retry never ran either. A
conversation that reached 91% of the window failed its next large turn instead of condensing.
Both are fixed:
- The ahead check counts. For a model that reports no usage, it uses the model’s own count of the transcript.
- The retry recognises the message form as well as
contextSizeExceeded(Agent.overflow(in:)).
A scripted chat then showed what condensing costs. It planted a fact (“the codename is BLUE HERON”) in turn 1, then asked the model to read and summarise six of these docs, about 1,400 tokens a turn:
- Before turn 7 the transcript was condensed from six turns to four, and the planted fact went with the first two.
- Asked for the codename, the model said it did not know.
- Asked which file it read first, it named the oldest file still in its window, confidently and wrongly. Nothing tells a model that older turns were dropped.
- Four turns of that size keep the transcript near the 85% budget, so from then on it condensed before almost every turn, a turn at a time.
That chat is now an eval, ContextEvalTests (scripts/check eval), with four facts, a task, a fact
that changes, a ten-file digression, and six questions. It is the baseline the
layered-context proposal is measured against. On 2026-09-29
today’s dropping scored 0 of 6 on the on-device model and 1 of 6 on granite4.1:8b at the same 8,192-token
window. Granite scored 6 of 6 at 32,768, where nothing was dropped. Both models again confidently named a
later file as the first one read. The proposal’s “Evaluation” section has the figures.
To see this for yourself, /inspect context next in chat shows the context the next request carries, entry by entry
under its store id, /inspect context N the one composed at the start of turn N, and /inspect context turns what
changed at each turn; wisp-tui shows the same in a panel (Ctrl-T), and an MCP caller reads
wisp://threads/{thread_id}/context. None of them costs the model anything. A bare /inspect context saves the exact context the next request carries:
instructions, prompts, tool calls, tool output, and replies. It writes Markdown to read and JSON to
rebuild a session from, in ~/.wisp/context/. Every condensation also saves the transcript before and
after it, and names both files in its context.condensation event (savedBefore, savedAfter), so
the dropped turns can be read rather than guessed. Files are saved only while audit.enabled is true.
Not done yet, and why
- The assessment on by default. The per-request assessment (phase 4d of the
layered-context proposal) is built and stays off: at the phase-6
checkpoint its call’s time bought nothing measurable (the tokens it saved did not reduce condensing, and
scores fell), and its inferred task drifted with each question (ADR 0045).
Context checkpoint 2 measured
assessment.taskChanges: restated, which kept the task on every run and is now the default, but the assessment with it scored within a run’s noise of the assessment off, at 7 to 10 more model calls a conversation, so it stays off (ADR 0057). memoryon a second scenario. Checkpoint 2’s one scenario is a reading conversation; whethermemorypays in one that edits, builds, and tests, or at windows far above 8,192, is unmeasured, so it is a setting (context.memory) rather than gone (ADR 0057).- Counting before each prompt, for every model. Calling
tokenCount(for:)before each prompt is exact but costs a model call. The ahead check uses the free usage report where a runtime gives one, and counts only for a model that reports nothing (ADR 0025, amendment of 2026-09-29). - Map-reduce for long documents. Summarising a file longer than the window needs chunked sub-sessions
and a merge step. That belongs in a dedicated tool (a Rust candidate), not in
Agent.