Files
LLeMbas-CLI/harness/context.md
T
HomerandClaude Opus 5.5 8e06590d57
ci / check (push) Canceled after 0s
Harness spec 3.1.0: the prefix stays still, in the CLI
The system prompt is made once per session (start, compaction, model
switch), kept in session_meta and resent byte for byte; it holds no
date. What changes mid-session -- the date, a mode the person chose, a
plan or memory or instruction file they edited, a personality, MCP
instructions that arrived later -- is a <session-note> on the next user
message, stored with it. The tool list is fixed for the session:
plan_submit is always offered and answers unavailable outside plan
mode, the wrap-up keeps the tools and runs no call, a withdrawn tool
stays listed and answers its refusal; the first request waits up to
mcp_wait_ms for MCP servers. Compaction asks for its summary as the
next turn of the same conversation. Cached input tokens are parsed
(incl. llama.cpp timings), stored per request and shown in /context.

Spec: context.md §7/§10, loop.json context.note (+ plan_ended,
plan_own, personal, empty, mcp_wait_ms), modes.json plan.refused,
compact.md without the transcript heading. Conformance area prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:22:02 +00:00

237 lines
12 KiB
Markdown

# Context hygiene
Spec 3.1.0. What a session does to keep tool results from filling the model's context, and (§10, from
3.1.0) what it keeps still so the model's server can reuse what it has already read — the same in
a chat and in an agent session, in both projects. The texts and numbers are `loop.json` `context`;
the cases are `conformance/context.json`; what the model is told is `prompts/blocks/context.md`.
## The one rule
**Every measure acts when a result is inserted. Nothing already sent to the model is ever
rewritten.** Changing earlier context invalidates the prompt cache from that point on: a local
server processes everything after it again, and a cloud API charges full input price for it again —
on a long context that costs more than the measure saves. So no earlier result is ever shortened,
replaced by a stub or removed afterwards. The one deliberate break is compaction, which already
replaces the history with a summary.
## 1. Every result has an id
Every tool result is given an id when it is inserted: `#` and the result's number in the session —
counting from 1, in lowercase hex, at least `ids.digits` (4) digits — so `#0001`, `#0002`, …,
`#000a`, …, `#10000`. Unique in the session and never reused — not after a compaction either. It is
the first thing in the result, in brackets: `[#0001]`, then a newline and the result.
A read's bracket says more: the path **as the call gave it**, the lines shown, the file's length in
lines and the first `sha_digits` (8) hex digits of the SHA-256 of the file's content —
```
[#0012 src/app.ts lines 1-200 of 340 sha 1c9e04ab]
1: import …
```
— and an empty file `[#0013 notes.txt empty sha e3b0c442]`. The full hash is kept by the session;
only it decides whether a file is unchanged.
**Paths in a bracket are the call's.** A read's bracket, an edit's (§5) and the compaction table
(§7) name the path as the call gave it — what the model wrote, so it recognises it. What identifies
the file — for a stub, for the table's "newest read of each path", for whether a file was read before
it is changed — is its absolute, resolved path (`filesystem.md`, *Paths*). Every other answer (an
error, a refusal, a search's results) names the absolute path.
**Every result is stored with the session, in full** — before any cap, and for a stub the text it
stands for — so `recall` can show any of them for as long as the session exists.
## 2. An unchanged re-read is a stub
**A stub names only a result that is in the request being built** — one whose text is among the
tool results this request sends to the model. That set, not anything the session stored, is the
dedupe table. Where a request does not carry earlier results — a chat on the server sends no earlier
reply's tool results with the next reply; after an undo, a rewind or a compaction they are gone — a
read is answered in full, because what a stub would point at is not in front of the model. It
holds for §2 and §6 alike.
A read is answered with a stub when the file's content hash equals that of an **earlier full read
of the same file still in the dedupe table**, and the lines asked for lie **wholly inside** that
read's lines:
```
[#0019 src/app.ts lines 50-80 of 340 sha 1c9e04ab] Unchanged since #0012, which already shows lines 1-200.
```
The bracket is the new read's; the sentence names the earlier result and its lines. A stub points
at a full result, never at another stub. A file that changed — by this session's own edit or from
outside — has another hash and is read in full; a range reaching past the earlier read is read in
full.
## 3. Large output is capped on the way in
A result over `cap.max_lines` (400) lines or `cap.max_chars` (40,000) characters keeps its head —
as many whole lines as fit `head_lines` (200) and `head_chars` (20,000) — and its tail — likewise,
`tail_lines` (100) and `tail_chars` (10,000) — with one line between:
```
[1234 lines elided, full output #0020]
```
A first line longer than `head_chars` is cut to it and ends ` […cut]`; the elided line follows it
whatever it counts. Reads and `recall` are not capped this way: each has its own window (2000 lines
from an offset).
## 4. `recall`
`recall(id, offset?, limit?)` shows a stored result again, in full, up to 2000 lines from `offset`:
```
[#0021 recall #0020 lines 1-2000 of 3400]
…
```
An id the session never gave: `[#0021] No stored result #ffff in this session.` The `#` may be left
out of the argument. A recall is not capped, is not deduplicated, and is itself stored.
## 5. Edits answer with the diff
`edit`, `multiedit`, `apply_patch` and `write` over an existing file answer with the unified diff
of the change (three lines of context, hunks only — the path is in the bracket) under a bracket
naming the path and the new content's hash:
```
[#0022 src/app.ts sha 7d0a91c3]
@@ -12,3 +12,3 @@
```
`write` of a new file answers `[#0023 docs/new.md sha 52b6e3a0] Created, 14 lines.` — the model
wrote the content and has it. A deleted file is `[#0024 old.md deleted]`. An `apply_patch` over
several files answers the id alone, then each file in its own bracket with its change, in the
patch's order:
```
[#0025]
[src/a.ts sha 7d0a91c3]
@@ -1,2 +1,2 @@
[docs/new.md sha 52b6e3a0] Created, 14 lines.
[old.md deleted]
```
A note the tool adds (a skill file written outside skill_manage) follows the diff. The diff is
capped like any output, the first line kept.
## 6. The same search or listing twice is a stub
For `dedupe.by_result` — `list`, `glob`, `grep`, `search` — a call with the same arguments (compared
as JSON with sorted keys) as an earlier one in the dedupe table, **whose result is the same text**,
is answered `[#0027] Same result as #0026.` The call is run again and compared, so a change from
anywhere shows up as a full result. Other tools are never deduplicated: a command's output twice is
two results.
## 7. Compaction
Compaction is the one deliberate break of the cache, and it already exists. **From 3.1.0 the
summary is asked for as the next turn of the same conversation**: the same system prompt, the same
tools, every message as it was, and then one more user message — `prompts/tasks/compact.md`, whose
`{{transcript}}` and `{{previous_summary}}` are empty in this form, since the model has the
conversation, an earlier summary included, itself. So the request
that makes the summary costs only that message; a separate request with a pasted transcript was a
full re-read of everything, and a transcript capped to fit it lost the session's middle. A tool
call in the summary's reply is not run; its text before the call is the summary, and a reply with
none fails the compaction as an empty one does. The summary request is never shown as a turn of the
session, and it is not stored as one. In 3.0.0 two things came with compaction, and still do:
- After the summary the harness adds — itself, not the model — a table of the **reads the summary
covers**: the full reads made since the previous compaction (or since the session began) in turns
that are still part of the history being summarised — whether or not their results are still in
the request. A turn taken back (an undo, a rewind, a reply regenerated or deleted) takes its reads
with it. Of those, the newest full read of each file (by its absolute path), newest last, at most
`compaction.rows` (50); a stub is not a read here. A session that resends every result (the CLI)
finds exactly these in its dedupe table; a chat, which does not, finds them in what it stored for
the turns being summarised:
```
## Files read before this summary
Read a file again for its content; recall shows a result by its id.
| Path | Lines | sha | Result |
|---|---|---|---|
| src/app.ts | 1-200 of 340 | 1c9e04ab | #0012 |
```
With no reads there is no table.
- **The dedupe table is emptied**, so no stub ever points at content that is no longer in the
context. A read after compaction is a full read; a `recall` of any earlier id still works.
## 10. The prefix: what the model has already read
A model's server keeps what it has read of a request and reuses it for the next one only as far as
the two are the same from the first byte. The system prompt and the tool list come first, so **a
change to either is a full re-read** of everything after them — on a long session minutes of work
on a local server, and the full input price again on a cloud API. Measured (2026-11-05, a local
model): a date that changed at midnight sent a 16,912-token session from 99 % cached to 0 %,
139 seconds. So, from 3.1.0:
1. **The system prompt is made once per session and sent unchanged.** It is built when the session
starts, kept with the session, and every request sends those exact bytes. It is built again only
where everything is read again anyway: after a compaction (§7), or when the session moves to
another model. A session that is resumed sends what it kept.
2. **Nothing in it is a clock.** No date or time. The date goes in a note (rule 3): the first user
message of a session carries it, and so does the next one after the date has changed.
3. **What changes during a session goes at the end, as a note** on the next user message, never
into earlier text: the permission mode (`modes/<mode>.md` as the note's body when it changes,
and its first line is in the prompt only as it was at the start), the plan when the person changed
it (the model's own `plan_write` and `plan_update` already answer with it), a memory or
instruction file the person edited, a personality chosen, an MCP server's instructions that
arrived later, the date. The note is a block after the person's words:
```
<session-note>
The permission mode is now plan: …
</session-note>
```
(`loop.json` `context.note`.) Each change is one block of the note, the blocks separated by a
blank line, in the order `context.note` lists them; a value that became empty reads
`context.note.empty`; the date is the person's own (their zone, else the machine's). A note is
part of that message from then on, like anything sent. A request with no user message (a tool
step), and words the person adds to a turn already running, carry no note; it waits for the next
message that starts a turn. A change the model made itself (its own `plan_write`, `memory`,
`settings`, a file it wrote) needs no note: its tool result already said it.
4. **The tool list is the same for the whole session.** A tool whose `requires` can come and go
during a session — `page`, `display`, `forge`, and plan mode for `plan_submit` — is offered from
the start whenever the session could ever meet it, and a call made while it is not met answers
the tool's `unavailable` text. Only what is fixed for the session may leave a tool out: the
session kind (`sessions`), the model (`vision`), a subagent's narrowing, what the project's
configuration or the person switched off before the session began. The tools stay listed in
the wrap-up request too (`loop.json` `wrap_up`): its message says they are withdrawn, and a call
in that reply is not run. A tool withdrawn after its final refusal stays in the list and answers
that refusal. **The one exception is an MCP server**: a session waits up to `mcp_wait_ms` for
the servers it is configured with before its first request, so their tools are in the first
list; a server that connects later adds its tools when it connects — one re-read, once — and its
instructions come as a note.
5. **Nothing already sent is rewritten** — the one rule above, for results; old tool output is
never pruned in place.
6. **Every request's cache use is recorded** where the provider reports it (cached input tokens),
beside the request's other usage, so a slow prompt has a cause on record.
What a server does with a matching prefix is its own business; a harness that keeps these rules has
done what it can.
## 8. Earlier reasoning
Left to the model's own chat template: the harness sends the history as it does, and neither strips
reasoning a template keeps nor adds what it drops.
## 9. What the model is told
`prompts/blocks/context.md`, in every session that has `recall`.
## Not in 3.1.0
Eliding old content in batches behind a threshold, and measuring quality and tokens with and without
these measures, are left for a later spec. Every measure above ships with its unit and conformance
cases.
## Over the link
A device keeps its sessions' stored results itself; `recall` in an agent session reads the device's
store. The ids are in the result text, so a client showing the transcript shows them as they are.