Harness spec: stubs per request, the compaction table from the reads it covers
context.md and loop.json `context`, spec 3.0.0-draft: - A stub names only a result in the request being built. A chat on the server sends no earlier reply's tool results, so a read in the next reply is answered in full there; the CLI, which resends every result, keeps stubbing across steps. Said for reads and for repeated listings alike. - The compaction table is drawn from the reads the summary covers: the full reads since the previous compaction in turns still in the history, whether or not the request still carries them. A turn taken back takes its reads with it. The CLI's dedupe table is exactly that set; a chat finds it in what it stored. - Ids are the result's number in the session, in hex, at least 4 digits (#0001); the examples and the prompt block now use such ids. - A bracket names the path as the call gave it; what identifies a file is its absolute path. Conformance `context`: a `reply` step (a request carrying none of the results so far) and a case for both rules. The CLI's runner keeps the history apart from the request; compactionTable takes the reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+150
-40
No files matched your search
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"about": "Context hygiene (context.md, loop.json `context`): what is inserted into the conversation for each step of a session, in order. Steps: `read` (`path`, the file's whole `content`, `offset`, `limit` — the read tool's body: `<n>: ` lines, the note when lines remain), `edit` / `write` (`path`, `before` — absent for a new file — and `after`), `output` (any other tool's result: `tool`, `args`, `text`), `recall` (`target`, `offset`, `limit`) and `compact` (the table added after a summary; the dedupe table is emptied). Each step but `compact` carries the `id` the session gives its result. `limits` overrides loop.json's `cap` for the case. The answer is the text each step inserts: a read headed by path, lines, length and the first 8 hex digits of the content's SHA-256, a stub for an unchanged re-read inside an earlier full read, a diff for an edit, a capped output with its elided line, a stub for a repeated listing or search with the same result. LLeMbas: services/context.py (to be written); CLI: session/results.ts.",
|
||||
"about": "Context hygiene (context.md, loop.json `context`): what is inserted into the conversation for each step of a session, in order. Steps: `read` (`path`, the file's whole `content`, `offset`, `limit` — the read tool's body: `<n>: ` lines, the note when lines remain), `edit` / `write` (`path`, `before` — absent for a new file — and `after`), `output` (any other tool's result: `tool`, `args`, `text`), `recall` (`target`, `offset`, `limit`), `reply` (a new reply begins whose request does not carry the results inserted so far — a chat's next reply: they stay in the history a compaction covers, but no stub may name them) and `compact` (the table added after a summary, from the full reads since the previous compaction in the history; the request and the dedupe table are emptied). Each step but `reply` and `compact` carries the `id` the session gives its result; `reply` answers \"\", and so does a `compact` with no reads. `limits` overrides loop.json's `cap` for the case. The answer is the text each step inserts: a read headed by path, lines, length and the first 8 hex digits of the content's SHA-256, a stub for an unchanged re-read inside an earlier full read, a diff for an edit, a capped output with its elided line, a stub for a repeated listing or search with the same result. LLeMbas: services/context.py; CLI: session/results.ts.",
|
||||
"cases": [
|
||||
{
|
||||
"steps": [
|
||||
@@ -398,6 +398,83 @@
|
||||
"[#5001]\nxxxxxxxxxxxxxxxxxxxx […cut]\n[0 lines elided, full output #5001]",
|
||||
"[#5002]\nfirst line\n[1 lines elided, full output #5002]\nlast"
|
||||
]
|
||||
},
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "read",
|
||||
"id": "#6001",
|
||||
"path": "a.md",
|
||||
"content": "alpha\n"
|
||||
},
|
||||
{
|
||||
"op": "output",
|
||||
"id": "#6002",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "*.md"
|
||||
},
|
||||
"text": "a.md"
|
||||
},
|
||||
{
|
||||
"op": "reply"
|
||||
},
|
||||
{
|
||||
"op": "read",
|
||||
"id": "#6003",
|
||||
"path": "a.md",
|
||||
"content": "alpha\n"
|
||||
},
|
||||
{
|
||||
"op": "output",
|
||||
"id": "#6004",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "*.md"
|
||||
},
|
||||
"text": "a.md"
|
||||
},
|
||||
{
|
||||
"op": "read",
|
||||
"id": "#6005",
|
||||
"path": "a.md",
|
||||
"content": "alpha\n"
|
||||
},
|
||||
{
|
||||
"op": "read",
|
||||
"id": "#6006",
|
||||
"path": "b.md",
|
||||
"content": "beta\n"
|
||||
},
|
||||
{
|
||||
"op": "compact"
|
||||
},
|
||||
{
|
||||
"op": "read",
|
||||
"id": "#6007",
|
||||
"path": "c.md",
|
||||
"content": "gamma\n"
|
||||
},
|
||||
{
|
||||
"op": "reply"
|
||||
},
|
||||
{
|
||||
"op": "compact"
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
"[#6001 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
|
||||
"[#6002]\na.md",
|
||||
"",
|
||||
"[#6003 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
|
||||
"[#6004]\na.md",
|
||||
"[#6005 a.md lines 1-1 of 1 sha b6a98d9c] Unchanged since #6003, which already shows lines 1-1.",
|
||||
"[#6006 b.md lines 1-1 of 1 sha f2c82dec]\n1: beta",
|
||||
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| a.md | 1-1 of 1 | b6a98d9c | #6003 |\n| b.md | 1-1 of 1 | f2c82dec | #6006 |",
|
||||
"[#6007 c.md lines 1-1 of 1 sha ae9a6306]\n1: gamma",
|
||||
"",
|
||||
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| c.md | 1-1 of 1 | ae9a6306 | #6007 |"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+39
-19
@@ -15,32 +15,46 @@ replaces the history with a summary.
|
||||
|
||||
## 1. Every result has an id
|
||||
|
||||
Every tool result is given an id when it is inserted: `#` and lowercase hex, at least
|
||||
`ids.digits` (4) digits, unique in the session and never reused — not after a compaction either. It
|
||||
is the first thing in the result, in brackets: `[#a3f2]`, then a newline and the result.
|
||||
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
|
||||
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 —
|
||||
|
||||
```
|
||||
[#a3f2 src/app.ts lines 1-200 of 340 sha 1c9e04ab]
|
||||
[#0012 src/app.ts lines 1-200 of 340 sha 1c9e04ab]
|
||||
1: import …
|
||||
```
|
||||
|
||||
— and an empty file `[#a3f2 notes.txt empty sha e3b0c442]`. The full hash is kept by the session;
|
||||
— 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 path still in the dedupe table**, and the lines asked for lie **wholly inside** that
|
||||
of the same file still in the dedupe table**, and the lines asked for lie **wholly inside** that
|
||||
read's lines:
|
||||
|
||||
```
|
||||
[#b710 src/app.ts lines 50-80 of 340 sha 1c9e04ab] Unchanged since #a3f2, which already shows lines 1-200.
|
||||
[#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
|
||||
@@ -55,7 +69,7 @@ as many whole lines as fit `head_lines` (200) and `head_chars` (20,000) — and
|
||||
`tail_lines` (100) and `tail_chars` (10,000) — with one line between:
|
||||
|
||||
```
|
||||
[1234 lines elided, full output #c9d0]
|
||||
[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
|
||||
@@ -67,11 +81,11 @@ from an offset).
|
||||
`recall(id, offset?, limit?)` shows a stored result again, in full, up to 2000 lines from `offset`:
|
||||
|
||||
```
|
||||
[#d004 recall #c9d0 lines 1-2000 of 3400]
|
||||
[#0021 recall #0020 lines 1-2000 of 3400]
|
||||
…
|
||||
```
|
||||
|
||||
An id the session never gave: `[#d004] No stored result #ffff in this session.` The `#` may be left
|
||||
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
|
||||
@@ -81,17 +95,17 @@ of the change (three lines of context, hunks only — the path is in the bracket
|
||||
naming the path and the new content's hash:
|
||||
|
||||
```
|
||||
[#e201 src/app.ts sha 7d0a91c3]
|
||||
[#0022 src/app.ts sha 7d0a91c3]
|
||||
@@ -12,3 +12,3 @@
|
||||
```
|
||||
|
||||
`write` of a new file answers `[#e202 docs/new.md sha 52b6e3a0] Created, 14 lines.` — the model
|
||||
wrote the content and has it. A deleted file is `[#e203 old.md deleted]`. An `apply_patch` over
|
||||
`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:
|
||||
|
||||
```
|
||||
[#e204]
|
||||
[#0025]
|
||||
[src/a.ts sha 7d0a91c3]
|
||||
@@ -1,2 +1,2 @@
|
||||
[docs/new.md sha 52b6e3a0] Created, 14 lines.
|
||||
@@ -105,7 +119,7 @@ capped like any output, the first line kept.
|
||||
|
||||
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 `[#f002] Same result as #f001.` The call is run again and compared, so a change from
|
||||
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.
|
||||
|
||||
@@ -114,8 +128,14 @@ two results.
|
||||
Compaction is the one deliberate break of the cache, and it already exists. In 3.0.0 two things
|
||||
come with it:
|
||||
|
||||
- After the summary the harness adds — itself, not the model — a table of the reads still in the
|
||||
dedupe table: the newest full read of each path, newest last, at most `compaction.rows` (50):
|
||||
- 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
|
||||
@@ -124,7 +144,7 @@ come with it:
|
||||
|
||||
| Path | Lines | sha | Result |
|
||||
|---|---|---|---|
|
||||
| src/app.ts | 1-200 of 340 | 1c9e04ab | #a3f2 |
|
||||
| src/app.ts | 1-200 of 340 | 1c9e04ab | #0012 |
|
||||
```
|
||||
|
||||
With no reads there is no table.
|
||||
|
||||
+3
-3
@@ -64,7 +64,7 @@
|
||||
"context": {
|
||||
"about": "Context hygiene (spec 3.0.0, context.md): every measure acts when a result is inserted, and nothing already sent to the model is ever rewritten — changing earlier context throws away the prompt cache from that point. conformance/context.json gives the texts.",
|
||||
"ids": {
|
||||
"about": "Every tool result gets an id: `#` and lowercase hex, at least 4 digits, unique in the session and never reused (not after a compaction either), so `recall` finds any of them. It is the first thing in the result, in brackets.",
|
||||
"about": "Every tool result gets an id: `#` and its number in the session, counting from 1, in lowercase hex, at least 4 digits (`#0001`, `#000a`, `#10000`) — unique in the session and never reused (not after a compaction either), so `recall` finds any of them. It is the first thing in the result, in brackets.",
|
||||
"digits": 4
|
||||
},
|
||||
"read_header": "[{{id}} {{path}} lines {{from}}-{{to}} of {{of}} sha {{sha}}]",
|
||||
@@ -88,7 +88,7 @@
|
||||
"tail_chars": 10000
|
||||
},
|
||||
"dedupe": {
|
||||
"about": "by_hash: a read whose file has the same content hash as an earlier full read in the table, and whose lines lie wholly inside that read's, is answered with `unchanged`. by_result: a call with the same arguments as an earlier one in the table, whose result is the same text, is answered with `same_result`. Stubs point at the full result, never at another stub. The table is per session, and compaction empties it.",
|
||||
"about": "by_hash: a read whose file (by its absolute path) has the same content hash as an earlier full read in the table, and whose lines lie wholly inside that read's, is answered with `unchanged`. by_result: a call with the same arguments as an earlier one in the table, whose result is the same text, is answered with `same_result`. Stubs point at the full result, never at another stub. The table is the full results in the request being built — never one the request does not carry (a chat's earlier reply, a turn undone) — and compaction empties it.",
|
||||
"by_hash": [
|
||||
"read"
|
||||
],
|
||||
@@ -100,7 +100,7 @@
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"about": "The one deliberate break of the cache. After the summary the harness adds the table of reads still in the dedupe table — the newest full read of each path, newest last, at most `rows` — and then empties the dedupe table, so no stub ever points at content that is gone from the context. No reads: no table.",
|
||||
"about": "The one deliberate break of the cache. After the summary the harness adds the table of the reads the summary covers — the full reads made since the previous compaction, in turns still in the history being summarised, whether or not the request still carries them; of those the newest full read of each file, newest last, at most `rows` — and then empties the dedupe table, so no stub ever points at content that is gone from the context. No reads: no table.",
|
||||
"heading": "## Files read before this summary",
|
||||
"line": "Read a file again for its content; recall shows a result by its id.",
|
||||
"rows": 50
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- LLeMbas CLI and LLeMbas, spec 3.0.0: how tool results are kept short (context.md). -->
|
||||
# Tool results
|
||||
- Every tool result starts with its id in brackets, like `[#a3f2]`. A read's bracket also says the path, the lines shown, the file's length and a short hash of its content: `[#a3f2 src/app.ts lines 1-200 of 340 sha 1c9e04ab]`.
|
||||
- Reading a file that has not changed since an earlier read in this conversation returns a stub instead of the lines: `Unchanged since #a3f2, which already shows lines 1-200.` The content is in that earlier result. Use it; do not read the file again to see it.
|
||||
- A listing or search repeated with the same result returns `Same result as #f001.` in the same way.
|
||||
- Long output is shortened to its beginning and its end, with `[1234 lines elided, full output #c9d0]` between them.
|
||||
- Every tool result starts with its id in brackets, like `[#0012]`. A read's bracket also says the path, the lines shown, the file's length and a short hash of its content: `[#0012 src/app.ts lines 1-200 of 340 sha 1c9e04ab]`.
|
||||
- Reading a file that has not changed since an earlier read in this conversation returns a stub instead of the lines: `Unchanged since #0012, which already shows lines 1-200.` The content is in that earlier result. Use it; do not read the file again to see it.
|
||||
- A listing or search repeated with the same result returns `Same result as #0026.` in the same way.
|
||||
- Long output is shortened to its beginning and its end, with `[1234 lines elided, full output #0020]` between them.
|
||||
- `recall` with an id shows that stored result in full, or from a line on: for lines left out of a shortened output, or for a result from before the conversation was summarised. Nothing is lost; recall only what you need.
|
||||
- An edit answers with the diff of the change, not the whole file. The earlier read with that diff applied is the file now: read it again only for lines you have not seen.
|
||||
- After the conversation is summarised, earlier results are no longer in front of you. The summary ends with a table of the files read before it, with their ids; a file read again then comes back in full.
|
||||
+12
-8
@@ -7,8 +7,8 @@
|
||||
//
|
||||
// The one rule: every measure acts when a result is inserted, and nothing already sent is ever
|
||||
// rewritten — changing earlier context throws the prompt cache away from that point. A stub only
|
||||
// ever points at a result that is still in the conversation (`present`): after a compaction, an
|
||||
// /undo or a rewind, the result it would point at is gone, so the read is answered in full.
|
||||
// ever points at a result in the request being built (`present`): after a compaction, an /undo or
|
||||
// a rewind, the result it would point at is gone, so the read is answered in full.
|
||||
import { createHash } from "node:crypto"
|
||||
import { structuredPatch } from "diff"
|
||||
import { LOOP } from "../harness.ts"
|
||||
@@ -224,18 +224,22 @@ export class ResultContext {
|
||||
return `${fill(CONTEXT.recall_header, { id, target, from, to, of: lines.length })}\n${shown}`
|
||||
}
|
||||
|
||||
/** The dedupe table: full results still in the conversation, oldest first. */
|
||||
/** The dedupe table: the full results in the request being built, oldest first. A stub names
|
||||
* only one of them (context.md §2). */
|
||||
private table(): StoredResult[] {
|
||||
const here = this.present()
|
||||
return this.store.all().filter((r) => r.full && here.has(r.id))
|
||||
}
|
||||
|
||||
/** The table compaction adds after its summary: the newest full read of each path still in the
|
||||
* conversation, newest last, at most compaction.rows; "" with none. After it the conversation is
|
||||
* the summary alone, so nothing is present and the dedupe table is empty by construction. */
|
||||
compactionTable(): string {
|
||||
/** The table compaction adds after its summary (context.md §7): of the full reads the summary
|
||||
* covers — made since the previous compaction, in turns still in the history — the newest of
|
||||
* each file, newest last, at most compaction.rows; "" with none. `reads` defaults to the dedupe
|
||||
* table: the CLI resends every result, so the results in the conversation are exactly those
|
||||
* (an /undo or a rewind took its reads with it). After it the conversation is the summary alone,
|
||||
* so nothing is present and the dedupe table is empty by construction. */
|
||||
compactionTable(reads: StoredResult[] = this.table()): string {
|
||||
const newest = new Map<string, StoredResult>()
|
||||
for (const r of this.table()) if (r.kind === "read" && r.of! > 0) newest.delete(r.key!), newest.set(r.key!, r)
|
||||
for (const r of reads) if (r.kind === "read" && r.full && r.of! > 0) newest.delete(r.key!), newest.set(r.key!, r)
|
||||
const rows = [...newest.values()].slice(-CONTEXT.compaction.rows)
|
||||
if (!rows.length) return ""
|
||||
return [
|
||||
|
||||
+14
-5
@@ -117,15 +117,23 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
const files = new Set(c.files as string[])
|
||||
return instructionChain(c.boundary as string, c.start as string, (p) => files.has(p) && readableUnder(c.grants as Record<string, string> | undefined, p))
|
||||
},
|
||||
// Context hygiene: one session's insertions, step by step. The conversation is the results
|
||||
// inserted since the last compaction, which is what a stub may point at.
|
||||
// Context hygiene: one session's insertions, step by step. `messages` is the request being built
|
||||
// (what a stub may point at); `history` the results since the last compaction, which its table
|
||||
// is drawn from. `reply` starts a request that carries none of them, as a chat's next reply does.
|
||||
context: (c) => {
|
||||
const messages: { role: string; content: string }[] = []
|
||||
const rc = new ResultContext(memoryResults(), () => presentIds(messages), { ...CONTEXT.cap, ...((c.limits ?? {}) as object) })
|
||||
const history = new Set<string>()
|
||||
const store = memoryResults()
|
||||
const rc = new ResultContext(store, () => presentIds(messages), { ...CONTEXT.cap, ...((c.limits ?? {}) as object) })
|
||||
return (c.steps as Record<string, any>[]).map((st) => {
|
||||
if (st.op === "compact") {
|
||||
const table = rc.compactionTable()
|
||||
if (st.op === "reply") {
|
||||
messages.length = 0
|
||||
return ""
|
||||
}
|
||||
if (st.op === "compact") {
|
||||
const table = rc.compactionTable(store.all().filter((r) => history.has(r.id)))
|
||||
messages.length = 0
|
||||
history.clear()
|
||||
return table
|
||||
}
|
||||
let text: string
|
||||
@@ -138,6 +146,7 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
} else if (st.op === "recall") text = rc.insert({ kind: "recall", target: st.target, offset: st.offset, limit: st.limit }, st.id)
|
||||
else text = rc.insert({ kind: "output", tool: st.tool, args: st.args, text: st.text }, st.id)
|
||||
messages.push({ role: "tool", content: text })
|
||||
history.add(st.id)
|
||||
return text
|
||||
})
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user