context.md §8: every assistant message of the current turn (after the last message that started a turn — a step-boundary delivery does not) carries its reasoning back, as reasoning_content in OpenAI chat and as thinking blocks in Anthropic; earlier turns carry none, which the common templates render identically. §11: an agent session's prompt names no absolute machine path: the environment block names the project and says paths are relative to its root (loop.json context.env), files outside the project under home as ~/…; relative paths resolve against the project root, absolute ones still accepted. agents-missing.md and init.md no longer name the root. New conformance areas: reasoning, paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
16 KiB
Markdown
289 lines
16 KiB
Markdown
# Context hygiene
|
|
|
|
Spec 3.2.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. (From 3.2.0 a turn's reasoning is sent back during the turn and
|
|
not after it, §8: the common chat templates render the history the same either way.)
|
|
|
|
## 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. Reasoning goes back to the model during its turn
|
|
|
|
From 3.2.0 (it was left to the chat template before). A model that thinks before each tool call
|
|
works on from what it thought: sent its own reasoning back, it carries on; sent its calls without it,
|
|
it reasons its way to them again, step after step. Measured on a local model: a step re-read about a
|
|
fifth fewer tokens with its reasoning sent back.
|
|
|
|
- **The current turn** is every message after the last one that started a turn: a message the
|
|
person wrote, or a machine message that starts a turn of its own (a job's or a team's note to an
|
|
idle session, `loop.json` `jobs`). What is delivered at a step boundary while a turn runs — a
|
|
job's note, a team message, words the person adds — does not start a turn, and a tool result
|
|
never does.
|
|
- **Every assistant message of the current turn carries the reasoning it came with**, as the
|
|
model gave it, in full: in the OpenAI chat dialect as `reasoning_content` on the assistant message
|
|
(beside its `content` and `tool_calls`), and in the Anthropic dialect as its thinking blocks with
|
|
their signatures, unchanged and first (which that API already requires for tool use). Reasoning
|
|
read from inline tags (`<think>`; `quirks.json` `reasoning-fields`) goes back the same way, as
|
|
`reasoning_content`, and never as text in `content`. A message that came with no reasoning
|
|
carries none — no empty field.
|
|
- **Assistant messages of earlier turns carry none.** The common templates (Qwen3.x, Nemotron's
|
|
`truncate_history_thinking`, gpt-oss) drop it there themselves, so what the server reads of the
|
|
history is the same bytes either way and nothing is re-read; with a template that would keep it,
|
|
the turn boundary re-reads at most the previous turn. A compaction's summary starts the history
|
|
afresh: nothing before it carries reasoning.
|
|
- **What is stored is unchanged**: a session keeps each message's reasoning as before; this rule
|
|
is only about what a request carries (`conformance/reasoning.json`).
|
|
- A subagent, a team member and the main agent each have their own turns: a member's turn starts
|
|
with its brief or with a message that woke it.
|
|
|
|
## 11. No absolute paths in the prompt
|
|
|
|
From 3.2.0. An absolute path costs tokens on every request, says where a machine keeps its files,
|
|
and is copied by the model into calls where a relative one would do. So in an agent session (a
|
|
device's disk):
|
|
|
|
- **The system prompt, every note and the harness's own task messages (`tasks/init.md`) name no
|
|
absolute path of the machine.** The environment
|
|
block names the project by its root's last path component and says paths are relative to its
|
|
root (`loop.json` `context.env`): `Project: app (paths are relative to its root)`. Where the
|
|
session was started in a directory below the root, one more line names that directory relative
|
|
to the root: `Started in: packages/web (relative to the project root)`. There is no
|
|
`Working directory:` or `Project root:` line with an absolute path any more.
|
|
- **A file in the project is named relative to its root** (an instruction file, `docs/AGENTS.md`); a
|
|
file under the person's home directory outside the project as `~/…` (the global instructions,
|
|
`~/.config/lembas/AGENTS.md`). Only a file outside both is named by its absolute path.
|
|
- **Relative paths are resolved against the project root** — by every file tool (`read`, `write`,
|
|
`edit`, `multiedit`, `apply_patch`, `glob`, `grep`, `list`, `view_image`, `show`) and as the
|
|
directory `bash` starts in (its `cwd` argument, relative, likewise). Where the person started
|
|
below the root is a fact told to the model, not a second base; it still ends the instruction
|
|
chain (`filesystem.md`). **Absolute paths are still accepted** everywhere, as before, and answers
|
|
still name paths as `filesystem.md` says.
|
|
- A chat on the server is not a machine's disk: its paths are the server's tree's, short and the
|
|
same for every instance (`/home/<name>`, `blocks/workspace.md`). Its prompt names nothing longer.
|
|
|
|
`conformance/paths.json`: the environment lines, and how a path is resolved.
|
|
|
|
## 9. What the model is told
|
|
|
|
`prompts/blocks/context.md`, in every session that has `recall`.
|
|
|
|
## Not in 3.2.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.
|