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:
HomerandClaude Opus 5.5 committed 2026-10-27 20:27:17 +00:00
1 parent 8cfbfbbca4
commit f20e8c4307
28 files changed
+595 -81

No files matched your search

+39 -34
View File
@@ -18,6 +18,7 @@ import { contextWindow } from "../provider/discover.ts"
import { summaryTurns, type Store } from "./store.ts"
import { estimateTokens, estimateValue } from "../provider/tokens.ts"
import { duration } from "../duration.ts"
import { memoryResults, presentIds, ResultContext } from "./results.ts"
export interface EngineOptions {
bus: Bus
@@ -39,7 +40,7 @@ export interface EngineOptions {
budget?: { wall_seconds?: number; output_bytes?: number; completion_tokens?: number }
/** Rules added by "always allow (project)" are handed here to persist. */
persistRule?: (rule: Rule) => void
compaction?: { autoAt?: number; prune?: boolean }
compaction?: { autoAt?: number }
/** Starts a subagent for the task tool; absent in a subagent. */
spawn?: ToolContext["spawn"]
/** The model to switch to when this one's server cannot be reached (its `fallback` list), or
@@ -59,7 +60,6 @@ export interface PromptMeta {
}
const MAX_PARALLEL = LOOP.parallel.max
const PRUNED = "[This output was removed to save context. Run the tool again if you need it.]"
/** What a message sent mid-turn starts with. Said, not hidden: Hermes found that a model refuses an
* unmarked one as an injection. A resumed session shows the message without it. */
/** What /continue (ctrl+g) sends: carry on after being stopped, or after an answer that ended
@@ -139,6 +139,10 @@ export class Engine {
/** The step's "background now" signal (ToolContext.backgroundNow), while its tools run. */
private bgNow: AbortController | undefined
/** The session's tool results: their ids, what each insertion says, what recall shows
* (session/results.ts, harness context.md). */
results: ResultContext
constructor(readonly o: EngineOptions) {
this.mode = o.mode
this._effort = o.effort
@@ -146,6 +150,13 @@ export class Engine {
this.client = o.client
this.sessionId = o.sessionId
this.plan = this.storedPlan()
this.results = this.resultsFor(o.sessionId)
}
/** A session's results: kept with it in the store, or in memory without one. A stub may point
* only at a result still in this conversation. */
private resultsFor(sessionId: string | undefined): ResultContext {
return new ResultContext(this.o.store && sessionId ? this.o.store.results(sessionId) : memoryResults(), () => presentIds(this.messages))
}
/** The plan the store holds for this session, if any. */
@@ -191,6 +202,7 @@ export class Engine {
this.sessionRules = []
this.recentCalls = []
this.withdrawn.clear()
this.results = this.resultsFor(sessionId)
// A new session's plan is whatever the store holds for it: none for a fresh one, the old one
// on a resume.
this.plan = this.storedPlan()
@@ -230,6 +242,10 @@ export class Engine {
for await (const ev of this.client.stream({ system: "", messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], tools: [], effort: null, signal }))
if (ev.type === "finish") summary = ev.message.parts.filter((p) => p.type === "text").map((p) => (p as { text: string }).text).join("").trim()
if (!summary) throw new Error("the model returned an empty summary")
// The harness's own table of the reads still in the conversation (context.md §7), after the
// summary; the conversation is then the summary alone, so no stub can point at a result before it.
const table = this.results.compactionTable()
if (table) summary = `${summary}\n\n${table}`
if (this.o.store && this.sessionId) this.o.store.compaction(this.sessionId, summary)
this.messages = summaryTurns(summary)
this.usage = undefined
@@ -255,42 +271,16 @@ export class Engine {
return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
}
/** Replace the content of old tool results, keeping the most recent `protect` tokens whole.
* Returns the tokens saved. (OpenCode prunes the same way before it summarises.) */
prune(protect: number): number {
let recent = 0
let saved = 0
for (let i = this.messages.length - 1; i >= 0; i--) {
const m = this.messages[i]!
const size = estimateValue(m)
if (recent < protect) {
recent += size
continue
}
if (m.role === "tool" && m.content.length > 400 && !m.content.startsWith(PRUNED)) {
saved += estimateTokens(m.content) - estimateTokens(PRUNED)
this.messages[i] = { ...m, content: PRUNED }
}
}
return saved
}
/** Before a step: past compaction.auto_at of the window, prune; if that is not enough, compact. */
/** Before a step: past compaction.auto_at of the window, compact. Old tool results are never
* pruned in place (harness context.md: nothing already sent is rewritten — it would throw the
* prompt cache away from that point); compaction is the one deliberate break. */
private async manageContext(signal: AbortSignal) {
const window = contextWindow(this.model)
const autoAt = this.o.compaction?.autoAt ?? 0.85
if (!window || autoAt >= 1) return
const limit = window * autoAt
let used = this.contextUsed()
const used = this.contextUsed()
if (used === undefined || used < limit) return
if (this.o.compaction?.prune !== false) {
const saved = this.prune(Math.min(40_000, Math.floor(window * 0.3)))
if (saved > 0) {
used -= saved
this.o.bus.emit({ type: "notice", message: `context at ${Math.round(((used + saved) / window) * 100)}%: pruned old tool outputs, ~${saved} tokens freed` })
}
if (used < limit) return
}
// Compacting again and again within one turn loses more each time: the window is simply too
// small for the task. Say so once and stop, rather than summarising in circles.
if (this.autoCompactions >= 2) {
@@ -612,6 +602,8 @@ export class Engine {
// Decide every call first — asking is sequential — then run what was allowed.
const results = new Map<string, ToolResult>()
/** Each call's arguments as its tool got them: a repeated listing or search is compared by them. */
const argsOf = new Map<string, unknown>()
const runnable: { call: ToolCallPart; tool: Tool; args: unknown; paths: string[]; writes: boolean; edited?: string }[] = []
for (const call of calls) {
if (signal.aborted) break
@@ -619,7 +611,10 @@ export class Engine {
if ("result" in decided) {
results.set(call.id, decided.result)
if (decided.denied) bus.emit({ type: "tool_denied", id: call.id, name: call.name, reason: decided.result.output })
} else runnable.push({ call, ...decided })
} else {
runnable.push({ call, ...decided })
argsOf.set(call.id, decided.args)
}
}
const bgNow = new AbortController()
this.bgNow = bgNow
@@ -673,7 +668,7 @@ export class Engine {
for (const call of calls) {
const r = results.get(call.id) ?? { output: "Not run: the reply was cancelled.", isError: true }
this.turnOutputBytes += Buffer.byteLength(r.output)
this.push({ role: "tool", callId: call.id, name: call.name, content: r.output, isError: r.isError })
this.push({ role: "tool", callId: call.id, name: call.name, content: this.inserted(call.name, argsOf.get(call.id), r), isError: r.isError })
if (r.images) images.push(...r.images)
}
// No dialect takes an image inside a tool result the same way; a user turn right after the
@@ -686,6 +681,16 @@ export class Engine {
}
}
/** What a result becomes in the conversation (harness context.md): its id first, a read's lines
* and hash, a stub for an unchanged re-read or a repeated listing, an edit's diff, a long output's
* head and tail. Decided now, once: nothing already sent is rewritten. */
private inserted(tool: string, args: unknown, r: ToolResult): string {
const c = r.context
if (c?.kind === "read" && !r.isError) return this.results.insert({ ...c, body: r.output })
if (c && c.kind !== "read" && c.kind !== "output" && !r.isError) return this.results.insert(c)
return this.results.insert({ kind: "output", tool, args: args ?? {}, text: r.output, ...(r.isError ? { error: true } : {}) })
}
private async decide(
call: ToolCallPart,
toolMap: Map<string, Tool>,