Context hygiene at insert time: result ids, read stubs, the cap, recall
Harness spec 3.0.0 (context.md, loop.json `context`), in session/results.ts, applied by the engine when a result goes into the conversation and never afterwards: - every result is headed by its id ([#0001]); a read also by the path as given, its lines, the file's length and the first 8 hex digits of the content's SHA-256; an empty file says so; - a read of an unchanged file whose lines lie inside an earlier full read is a stub naming it; a list/glob/grep repeated with the same arguments and the same result is "Same result as #…"; - output over 400 lines or 40,000 characters keeps head and tail with the elided line; the full result is kept with the session (a new `results` table in sessions.db, gone with the session); - edit, multiedit, write and apply_patch answer with their hunks under a bracket with the new content's hash; a new file is "Created, N lines."; - recall (new built-in tool) shows any stored result, or a range of it, also after a resume; ids are never reused; - compaction appends the table of the reads still in the conversation to the summary. A stub only ever points at a result still in the conversation, so after a compaction, an /undo or a rewind the file is read in full. compaction.prune is gone: cutting old outputs in place threw the prompt cache away (an old config's key is dropped with a warning). blocks/context.md is in the prompt wherever recall is offered. Conformance runner for the `context` area (11 cases pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
28 files changed
+595
-81
No files matched your search
@@ -25,7 +25,7 @@ const result = () => fake!.requests[1].messages.at(-1).content as string
|
||||
test("the edited command runs, and the result says so first", async () => {
|
||||
const a = app("echo two")
|
||||
await a.engine.prompt("go")
|
||||
expect(result()).toStartWith("The user changed the command before allowing it. What ran: echo two")
|
||||
expect(result()).toStartWith("[#0001]\nThe user changed the command before allowing it. What ran: echo two")
|
||||
expect(result()).toContain("two")
|
||||
expect(result()).not.toContain("one\n")
|
||||
})
|
||||
|
||||
@@ -19,6 +19,8 @@ import { resolveBase } from "../src/library/store.ts"
|
||||
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
|
||||
import { fillPrompt } from "../src/prompt/fill.ts"
|
||||
import { instructionChain } from "../src/prompt/assemble.ts"
|
||||
import { CONTEXT, hunks, lineCount, memoryResults, presentIds, ResultContext, sha256 } from "../src/session/results.ts"
|
||||
import { readBody } from "../src/tool/read.ts"
|
||||
import { applyOps, type Op, type Target } from "../src/memory/store.ts"
|
||||
import { jobNote, type NoteCase } from "../src/jobs/note.ts"
|
||||
|
||||
@@ -115,6 +117,30 @@ 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: (c) => {
|
||||
const messages: { role: string; content: string }[] = []
|
||||
const rc = new ResultContext(memoryResults(), () => 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()
|
||||
messages.length = 0
|
||||
return table
|
||||
}
|
||||
let text: string
|
||||
if (st.op === "read") {
|
||||
const { body, from, to, of } = readBody(st.content, st.offset, st.limit)
|
||||
text = rc.insert({ kind: "read", path: st.path, key: st.path, sha: sha256(st.content), from, to, of, body }, st.id)
|
||||
} else if (st.op === "edit" || st.op === "write") {
|
||||
const sha = sha256(st.after)
|
||||
text = rc.insert({ kind: "edit", files: [st.before === undefined ? { path: st.path, sha, created: lineCount(st.after) } : { path: st.path, sha, hunks: hunks(st.before, st.after) }] }, st.id)
|
||||
} 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 })
|
||||
return text
|
||||
})
|
||||
},
|
||||
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
||||
toolcall_index: (c) => {
|
||||
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
||||
|
||||
+19
-10
@@ -45,12 +45,14 @@ describe("context", () => {
|
||||
expect(fake!.calls.some((c) => c.path.endsWith("/unload"))).toBe(true)
|
||||
})
|
||||
|
||||
test("past auto_at: the older of two large outputs is pruned, the recent one kept, no compaction", async () => {
|
||||
// window 16000, auto_at 0.6 → limit 9600; the most recent 30% (4800 tokens) is protected.
|
||||
test("past auto_at: nothing already sent is cut; the conversation is compacted, with a table of the reads", async () => {
|
||||
// window 16000, auto_at 0.6 → limit 9600. Harness spec 3.0.0 (context.md): old tool outputs are
|
||||
// never pruned in place — that would make the server read everything after them again.
|
||||
const a = app(`connections:\n f:\n dialect: openai-chat\n base_url: URL\n models: { m: { context: 16000 } }\n`, [
|
||||
{ chunks: [toolCall(0, "c1", "read", '{"path":"big.txt"}'), usage(1000, 10)] },
|
||||
{ chunks: [toolCall(0, "c2", "read", '{"path":"big2.txt"}'), usage(6600, 10)] },
|
||||
{ chunks: [delta({ content: "done" }), usage(7000, 5)] },
|
||||
{ chunks: [delta({ content: "## Summary\nRead two files." })] },
|
||||
{ chunks: [delta({ content: "done" }), usage(2000, 5)] },
|
||||
], "compaction: { auto_at: 0.6 }\n")
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i} of a long file`).join("\n")
|
||||
writeFileSync(join(a.project.root, "big.txt"), lines)
|
||||
@@ -58,14 +60,21 @@ describe("context", () => {
|
||||
const notices: string[] = []
|
||||
a.bus.on((e: Event) => e.type === "notice" && notices.push(e.message))
|
||||
await a.engine.prompt("go")
|
||||
expect(notices.some((n) => n.includes("pruned old tool outputs"))).toBe(true)
|
||||
expect(notices.some((n) => n.includes("compacting"))).toBe(false)
|
||||
const results = fake!.requests.at(-1).messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||
expect(results[0]).toContain("removed to save context")
|
||||
expect(results[1]).toContain("line 999 of a long file")
|
||||
expect(notices.some((n) => n.includes("pruned"))).toBe(false)
|
||||
expect(notices.some((n) => n.includes("compacting"))).toBe(true)
|
||||
// The first read went out whole, and stayed whole until the summary replaced the conversation.
|
||||
const sent = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||
expect(sent[0]).toStartWith("[#0001 big.txt lines 1-1000 of 1000 sha ")
|
||||
expect(sent[0]).toContain("line 999 of a long file")
|
||||
// After it: the summary, the harness's table of what was read, and the request word for word.
|
||||
const after = JSON.stringify(fake!.requests[3].messages)
|
||||
expect(after).toContain("Read two files.")
|
||||
expect(after).toContain("## Files read before this summary")
|
||||
expect(after).toMatch(/\| big\.txt \| 1-1000 of 1000 \| [0-9a-f]{8} \| #0001 \|/)
|
||||
expect(after).toMatch(/\| big2\.txt \| 1-1000 of 1000 \| [0-9a-f]{8} \| #0002 \|/)
|
||||
})
|
||||
|
||||
test("still too full after pruning: compacted mid-task, then told to carry on", async () => {
|
||||
test("too full: compacted mid-task, then told to carry on", async () => {
|
||||
const a = app(`connections:\n f:\n dialect: openai-chat\n base_url: URL\n models: { m: { context: 4000 } }\n`, [
|
||||
{ chunks: [delta({ content: "y".repeat(8000) }), toolCall(0, "c1", "list", "{}"), usage(3900, 2000)] },
|
||||
{ chunks: [delta({ content: "## What we are doing\nA long task." })] },
|
||||
@@ -91,7 +100,7 @@ describe("context", () => {
|
||||
expect(text).toContain("window 32.8k")
|
||||
expect(text).toContain("system prompt")
|
||||
expect(text).toMatch(/tool definitions \(\d+\)/)
|
||||
expect(text).toMatch(/tool results\s+503/) // one 6000-character line, cut at 2000 by read
|
||||
expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header
|
||||
expect(text).toMatch(/in use\s+3\.1k/)
|
||||
})
|
||||
})
|
||||
+4
-1
@@ -45,12 +45,15 @@ describe("end to end", () => {
|
||||
// The tool result went back to the model as a tool message with the right id.
|
||||
expect(fake!.requests[1].messages.at(-1)).toMatchObject({ role: "tool", tool_call_id: "c1" })
|
||||
expect(fake!.requests[1].messages.at(-1).content).toContain("1: hello world")
|
||||
// Spec 3.0.0 (context.md): the read is headed by its id, lines and hash; the edit answers with its diff.
|
||||
expect(fake!.requests[1].messages.at(-1).content).toMatch(/^\[#0001 hello\.txt lines 1-1 of 1 sha [0-9a-f]{8}\]\n1: hello world$/)
|
||||
expect(fake!.requests[2].messages.at(-1).content).toMatch(/^\[#0002 hello\.txt sha [0-9a-f]{8}\]\n@@ -1,1 \+1,1 @@\n-hello world\n\+hello lembas$/)
|
||||
// System prompt carries env and the mode.
|
||||
expect(fake!.requests[0].messages[0].content).toContain(`Project root: ${dir}`)
|
||||
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
|
||||
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
|
||||
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
|
||||
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings"])
|
||||
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
|
||||
const ends = events.filter((e) => e.type === "tool_end")
|
||||
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
|
||||
const sid = store.sessions(1)[0]!.id
|
||||
|
||||
@@ -69,8 +69,7 @@ test("every tool says how it is shown (block), from the spec's list", () => {
|
||||
})
|
||||
|
||||
test("every tool the spec gives the CLI is built in, and every built-in tool is in the spec", () => {
|
||||
// recall comes with context hygiene (harness/context.md), in the commit that implements it.
|
||||
expect(ours.map((t) => t.name).sort()).toEqual(forUs.map((t) => t.name).filter((n) => n !== "recall").sort())
|
||||
expect(ours.map((t) => t.name).sort()).toEqual(forUs.map((t) => t.name).sort())
|
||||
})
|
||||
|
||||
for (const t of ours) {
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ describe("in an app", () => {
|
||||
a.newSession()
|
||||
expect(a.jobs()).toEqual([])
|
||||
await a.turns.prompt("list")
|
||||
expect(fake!.requests[3].messages.at(-1).content).toBe("No background jobs.")
|
||||
expect(fake!.requests[3].messages.at(-1).content).toBe("[#0001]\nNo background jobs.")
|
||||
expect(a.registry.running(first)).toHaveLength(1)
|
||||
// Deleting a session stops its jobs.
|
||||
expect(a.registry.stopAll(first)).toBe(1)
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ describe("through the engine", () => {
|
||||
expect(asked.join()).toContain("mcp__fix__echo")
|
||||
expect(fake.requests[0].tools.map((t: any) => t.function.name)).toContain("mcp__fix__echo")
|
||||
expect(fake.requests[0].messages[0].content).toContain("<mcp_instructions>")
|
||||
expect(fake.requests[1].messages.at(-1)).toMatchObject({ role: "tool", content: "echo: via engine" })
|
||||
expect(fake.requests[1].messages.at(-1)).toMatchObject({ role: "tool", content: "[#0001]\necho: via engine" })
|
||||
await app.close()
|
||||
writeFileSync(join(paths.config, "config.yaml"), "")
|
||||
})
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ describe("plan mode", () => {
|
||||
expect(readFileSync(join(a.project.root, seen[0]!.path), "utf8")).toBe(RENDERED + "\n")
|
||||
expect(a.engine.plan?.steps).toEqual(["calc.py: divide by len(values)"])
|
||||
// The model read the plan back with its ids from plan_write.
|
||||
expect(fake!.requests[1].messages.at(-1).content).toBe(RENDERED)
|
||||
expect(fake!.requests[1].messages.at(-1).content).toBe(`[#0001]\n${RENDERED}`)
|
||||
expect(names(fake!.requests[0])).toContain("plan_submit")
|
||||
expect(names(fake!.requests[0])).toContain("plan_write")
|
||||
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: plan")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Context hygiene through the engine (harness spec 3.0.0, context.md): ids, stubs, the cap, recall,
|
||||
// and the rule that a stub only ever points at a result still in the conversation. The texts are
|
||||
// the conformance area `context`'s; here they are checked where the engine inserts them.
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createApp } from "../src/app.ts"
|
||||
import type { AskReply } from "../src/bus/index.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
import { Store } from "../src/session/store.ts"
|
||||
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
|
||||
|
||||
let fake: Fake | undefined
|
||||
afterEach(() => fake?.stop())
|
||||
|
||||
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
||||
|
||||
function setup(script: Parameters<typeof fakeProvider>[0]) {
|
||||
fake = fakeProvider(script)
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n m: { context: 32768 }\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
|
||||
const dir = mkdtempSync(join(tmpdir(), "lembas-results-"))
|
||||
writeFileSync(join(dir, "a.txt"), "one\ntwo\nthree\n")
|
||||
return dir
|
||||
}
|
||||
|
||||
/** The tool results the model was sent in request `i`, in order. */
|
||||
const sent = (i: number): string[] => fake!.requests[i].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||
|
||||
test("an unchanged re-read is a stub; after an edit the file is read in full; a repeated listing is a stub", async () => {
|
||||
const dir = setup([
|
||||
{ chunks: [toolCall(0, "c1", "read", '{"path":"a.txt"}')] },
|
||||
{ chunks: [toolCall(0, "c2", "read", '{"path":"a.txt","offset":2,"limit":1}')] },
|
||||
{ chunks: [toolCall(0, "c3", "glob", '{"pattern":"*.txt"}')] },
|
||||
{ chunks: [toolCall(0, "c4", "glob", '{"pattern":"*.txt"}')] },
|
||||
{ chunks: [toolCall(0, "c5", "edit", JSON.stringify({ path: "a.txt", old: "two", new: "2" }))] },
|
||||
{ chunks: [toolCall(0, "c6", "read", '{"path":"a.txt"}')] },
|
||||
{ chunks: [delta({ content: "done" })] },
|
||||
])
|
||||
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
await app.engine.prompt("go")
|
||||
const r = sent(6)
|
||||
expect(r[0]).toMatch(/^\[#0001 a\.txt lines 1-3 of 3 sha ([0-9a-f]{8})\]\n1: one\n2: two\n3: three$/)
|
||||
const sha = /sha ([0-9a-f]{8})/.exec(r[0]!)![1]
|
||||
expect(r[1]).toBe(`[#0002 a.txt lines 2-2 of 3 sha ${sha}] Unchanged since #0001, which already shows lines 1-3.`)
|
||||
expect(r[2]).toBe("[#0003]\na.txt")
|
||||
expect(r[3]).toBe("[#0004] Same result as #0003.")
|
||||
expect(r[4]).toMatch(/^\[#0005 a\.txt sha [0-9a-f]{8}\]\n@@ -1,3 \+1,3 @@\n one\n-two\n\+2\n three$/)
|
||||
expect(r[5]).toMatch(/^\[#0006 a\.txt lines 1-3 of 3 sha [0-9a-f]{8}\]\n1: one\n2: 2\n3: three$/)
|
||||
// The prompt says what the ids and stubs are.
|
||||
expect(fake!.requests[0].messages[0].content).toContain("# Tool results\n- Every tool result starts with its id in brackets")
|
||||
})
|
||||
|
||||
test("a long output keeps its head and tail; recall shows it whole, from the store, after a resume too", async () => {
|
||||
const dir = setup([
|
||||
{ chunks: [toolCall(0, "c1", "bash", JSON.stringify({ command: "seq 1 1000", description: "count" }))] },
|
||||
{ chunks: [toolCall(0, "c2", "recall", '{"id":"#0001","offset":500,"limit":3}')] },
|
||||
{ chunks: [delta({ content: "done" })] },
|
||||
// After a resume: a new id, never one given before; an unknown one said so.
|
||||
{ chunks: [toolCall(0, "c3", "recall", '{"id":"0001","offset":999}'), toolCall(1, "c4", "recall", '{"id":"#ffff"}')] },
|
||||
{ chunks: [delta({ content: "again" })] },
|
||||
])
|
||||
const store = new Store()
|
||||
const app = createApp({ cwd: dir, mode: "auto", asker: yes, store, modelTitles: false })
|
||||
await app.engine.prompt("count")
|
||||
const r = sent(2)
|
||||
const lines = r[0]!.split("\n")
|
||||
expect(lines[0]).toBe("[#0001]")
|
||||
expect(lines.slice(1, 3)).toEqual(["1", "2"])
|
||||
expect(r[0]).toContain("\n200\n[")
|
||||
expect(r[0]).toMatch(/\n\[\d+ lines elided, full output #0001\]\n/)
|
||||
expect(r[0]).toContain("\n1000\n\n[exit 0]")
|
||||
expect(r[1]).toBe("[#0002 recall #0001 lines 500-502 of 1002]\n500\n501\n502")
|
||||
const id = app.engine.sessionId!
|
||||
const again = createApp({ cwd: dir, mode: "auto", asker: yes, store, resuming: id, modelTitles: false })
|
||||
again.resume(id)
|
||||
await again.engine.prompt("more")
|
||||
const s = sent(4)
|
||||
expect(s.slice(-2)).toEqual(["[#0003 recall #0001 lines 999-1002 of 1002]\n999\n1000\n\n[exit 0]", "[#0004] No stored result #ffff in this session."])
|
||||
})
|
||||
|
||||
test("a stub never points at a result that left the conversation: after /undo the file is read in full", async () => {
|
||||
const dir = setup([
|
||||
{ chunks: [toolCall(0, "c1", "read", '{"path":"a.txt"}')] },
|
||||
{ chunks: [delta({ content: "read" })] },
|
||||
{ chunks: [toolCall(0, "c2", "read", '{"path":"a.txt"}')] },
|
||||
{ chunks: [delta({ content: "read again" })] },
|
||||
])
|
||||
const app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||
await app.turns.prompt("read it", [], "read it")
|
||||
await app.turns.undo()
|
||||
await app.turns.prompt("read it", [], "read it")
|
||||
expect(sent(3)).toHaveLength(1)
|
||||
expect(sent(3)[0]).toMatch(/^\[#0002 a\.txt lines 1-3 of 3 sha [0-9a-f]{8}\]\n1: one/)
|
||||
})
|
||||
@@ -76,7 +76,7 @@ describe("subagents", () => {
|
||||
expect(child.messages[0].content).not.toContain("## Skills")
|
||||
// the parent gets the report, not the reading
|
||||
const result = fake!.requests[3].messages.at(-1).content as string
|
||||
expect(result).toStartWith("calc.py:2 divides by len(v) - 1")
|
||||
expect(result).toStartWith("[#0001]\ncalc.py:2 divides by len(v) - 1")
|
||||
expect(result).toContain("[explore subagent · 2 steps · 1 tool call]")
|
||||
expect(fake!.requests[3].messages.some((m: any) => m.role === "tool" && m.content.includes("return sum(v)"))).toBe(false)
|
||||
expect(events.find((e) => e.type === "sub_tool")).toMatchObject({ type: "sub_tool", callId: "t1", name: "read" })
|
||||
@@ -198,7 +198,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
||||
)
|
||||
await a.turns.prompt("look into it in the background")
|
||||
const started = fake!.requests[1].messages.at(-1).content as string
|
||||
expect(started).toStartWith("Started subagent explore as job1 (find the bug).")
|
||||
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
|
||||
await settled(a, 3)
|
||||
const note = fake!.requests[2].messages.at(-1).content as string
|
||||
expect(note).toStartWith(MARKS.subagent)
|
||||
@@ -298,7 +298,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
||||
)
|
||||
await a.turns.prompt("go")
|
||||
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||
expect(results[0]).toStartWith("Started subagent explore as job1")
|
||||
expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1")
|
||||
expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there")
|
||||
expect(other!.requests.map((r: any) => r.model)).toEqual(["m"])
|
||||
await settled(a, 3)
|
||||
|
||||
Reference in new issue
Block a user