Skip to content

Commit 42a4ff3

Browse files
committed
Preserve role-specific context and recoverable compaction evidence
1 parent c72a1b5 commit 42a4ff3

24 files changed

Lines changed: 1142 additions & 325 deletions

docs/ARCHITECTURE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,20 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h
165165

166166
#### Context compaction (the compaction governor)
167167

168-
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers three cases:
168+
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. Each successful pass produces **one refreshed handoff**, folding the previous handoff together with newly compacted history rather than accumulating frozen summary turns. Recent turns and selected anchors remain alongside it. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers three cases:
169169

170170
- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After a compact that remains over the high watermark, the governor uses **growth hysteresis** (wait for usage to grow by ~10% of the window) instead of re-arming on every cycle; dropping under 60% is not required.
171171
- **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it.
172172
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. Overflow ignores hysteresis for the compact itself.
173173

174174
The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor, after emitting `compact`, self-delivers a content-less inbound message (a host-supplied `requestContinuation` callback). That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history.
175175

176-
Compaction replaces older turns with a structured, workflow-aware summary rather than a stats blob: sections for **What Happened / What We're Doing / Relevant Links / Action Items / Next Steps**, with the active workflow and step woven in so compacting mid-`/build` or mid-`/plan` preserves the contract. The summary is produced by a one-shot model call; on any failure it falls back to a deterministic summary so a compaction cycle never breaks the session.
176+
Two explicit role policies share the pruning engine, model summarizer, and governor:
177+
178+
- **Orchestrator** — A broad, workflow-aware model handoff covers the objective, workflow progress, delegated work, decisions, outstanding obligations, and cross-task dependencies. It uses **What Happened / What We're Doing / Relevant Links / Action Items / Next Steps** sections and retains evidence references. The primary-only evidence archive preserves authorized, post-policy evidence separately from the working summary; existing scoped `read_file`, `grep`, and `search_files` access it through `archive:///` references, not direct sidecar paths.
179+
- **Worker** — The delivered task contract (including supplied plan, context, scope, required outcomes, constraints, and report requirements) and accepted parent follow-ups remain verbatim outside the model-generated execution summary. They are retained in acceptance order, with later conflicting instructions taking precedence; a successful `ask_director` answer remains associated with its question. Queued or rejected messages are not accepted contract entries. The worker summary covers substantive findings, evidence, failures, changes, and next actions within that scope. Workers do not receive an evidence archive.
180+
181+
The summary input prioritizes the current fold and previous handoff, not an archive-wide sample of old history. Oversized evidence is processed in bounded, substantive chronological chunks before a final combined handoff; binary attachments contribute metadata and references rather than decoded text. If the model call fails, returns empty text, or mandatory evidence exceeds the input budget, compaction retains the original usable context. An unshrinkable worker contract likewise retains the original context. Production compaction has no statistics-only fallback; the governor's existing bounded overflow recovery stops retries when compaction cannot make room.
177182

178183
### Web Tools and Providers (`src/web/`)
179184

docs/IMPLEMENTATION.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,10 @@ session; that tree re-write is inherent to git and left as residual cost.
439439

440440
`index.ts` installs `uncaughtException` and `unhandledRejection` handlers (and catches a rejected `main`). Each calls `writeCrashReport` (`crash/report.ts`) to write a best-effort report to `~/.corbits/projects/<project-key>/errors/<timestamp>.txt`, using the same `projectKeyFor`/`projectSessionsRoot` (`session/project-key.ts`) that keys that project's session directories, so a crash report lands next to the session's `run.json` and transcript rather than under a separately computed slug. The file records the failure kind, an ISO timestamp, the cwd, and the stack. `projectSessionsRoot` shells out to git with no timeout, so the handler never calls it directly — `primeCrashReporting` resolves and caches the directory once at startup (right after config load), and `writeCrashReport` only ever reads that cached value; if priming never ran or failed, it falls back to an `unresolved` bucket rather than touching git mid-crash. `writeCrashReport` swallows its own errors and returns `null` without logging; the handler in `index.ts` is what prints the one-line failure notice to stderr when that happens, then exits non-zero.
441441

442+
### Binary compaction evidence
443+
444+
The primary evidence archive stores authorized image and PDF payloads as raw bytes, with `encoding: "raw"`, MIME `contentType`, byte length, and a SHA-256 `contentHash`. Binary payloads are not decoded as UTF-8. The existing scoped `read_file` path for an `archive:///` occurrence verifies the hash and length, then exposes a metadata header with `encoding: "base64"` and `decodedByteLength`, followed by base64 wrapped at 76 characters. Reads use the existing line `offset` and `limit`, capped at 64 lines per binary page. Archive search matches binary metadata only, not the encoded payload. This representation uses the existing archive access tools; workers have no archive access.
445+
442446
### Event Stream
443447

444448
`agent.stream()` emits `ReactorEmittedEvent` objects. `docs/ARCHITECTURE.md`'s
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expect, test } from "bun:test";
2+
import { mkdtemp } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { createCompactionArchive, hashAuthorizedBytes } from "../session/compaction-archive.js";
6+
import { evidenceArchiveSearchPlugin } from "./evidence-archive-search-plugin.js";
7+
8+
test.each(["image/png", "application/pdf", "legacy-image"])(
9+
"archive %s pages recover every byte after reopening, including beyond the scan ceiling",
10+
async (format) => {
11+
const blobs = new Map<string, Uint8Array>();
12+
const opts = {
13+
sessionId: "paging",
14+
contextDir: await mkdtemp(join(tmpdir(), "archive-paging-")),
15+
writeBlob: async (key: string, bytes: Uint8Array) => {
16+
blobs.set(key, bytes);
17+
},
18+
readBlob: async (key: string) => {
19+
const bytes = blobs.get(key);
20+
if (bytes === undefined) throw new Error("missing");
21+
return bytes;
22+
},
23+
};
24+
const archive = createCompactionArchive(opts);
25+
const bytes = Uint8Array.from(
26+
{ length: format === "legacy-image" ? 6000 : 7 * 1024 * 1024 },
27+
(_, index) => index % 251,
28+
);
29+
const legacy = new TextEncoder().encode(Buffer.from(bytes).toString("base64"));
30+
blobs.set("img-legacy", legacy);
31+
const occurrence =
32+
format === "legacy-image"
33+
? await archive.recordExistingBlobReference({
34+
kind: "attachment",
35+
blobKey: "img-legacy",
36+
contentHash: hashAuthorizedBytes(legacy),
37+
provenance: "persistBlobs:aged-image",
38+
})
39+
: await archive.recordAuthorizedPayload({
40+
kind: "attachment",
41+
payload: bytes,
42+
binary: {
43+
encoding: "raw",
44+
name: "evidence",
45+
contentType: format,
46+
byteLength: bytes.length,
47+
},
48+
});
49+
const reopened = createCompactionArchive(opts);
50+
const plugin = evidenceArchiveSearchPlugin(() => reopened);
51+
const handler = plugin.middleware!(async () => {
52+
throw new Error("unexpected passthrough");
53+
});
54+
const read = async (offset: number) =>
55+
String(
56+
(
57+
await handler(
58+
{
59+
id: "read",
60+
name: "read_file",
61+
arguments: { path: `archive:///${occurrence.occurrenceId}`, offset, limit: 64 },
62+
},
63+
new AbortController().signal,
64+
)
65+
).content,
66+
);
67+
if (format !== "legacy-image") {
68+
const deepPage = await read(110000);
69+
expect(deepPage).not.toContain("scan limit");
70+
expect(deepPage).toMatch(/110001\t/);
71+
}
72+
const lines: string[] = [];
73+
let offset = 0;
74+
for (let page = 0; page < 3000; page += 1) {
75+
const text = await read(offset);
76+
expect(text.length).toBeLessThan(10_000);
77+
for (const match of text.matchAll(/^\s*(\d+)\t(.*)$/gm)) {
78+
expect(Number(match[1])).toBe(lines.length + 1);
79+
lines.push(match[2]!);
80+
}
81+
const next = /Use offset=(\d+) to continue/.exec(text);
82+
if (next === null) break;
83+
expect(Number(next[1])).toBeGreaterThan(offset);
84+
offset = Number(next[1]);
85+
}
86+
expect(lines[0]).toContain('"encoding":"base64"');
87+
expect(Buffer.from(lines.slice(1).join(""), "base64").equals(Buffer.from(bytes))).toBe(true);
88+
},
89+
120_000,
90+
);

src/plugins/evidence-archive-search-plugin.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,51 @@ describe("advertiseArchiveSurface", () => {
7676
});
7777

7878
describe("evidenceArchiveSearchPlugin", () => {
79+
test.each(["image/png", "application/pdf"])(
80+
"binary %s reads paginate byte-identically without exposing base64 to search",
81+
async (contentType) => {
82+
const archive = memoryArchive("binary-pages");
83+
const bytes = Uint8Array.from({ length: 20_000 }, (_, i) => i % 256);
84+
const occ = await archive.recordAuthorizedPayload({
85+
kind: "attachment",
86+
payload: bytes,
87+
binary: { encoding: "raw", contentType, name: "evidence-binary", byteLength: bytes.length },
88+
});
89+
const plugin = evidenceArchiveSearchPlugin(() => archive);
90+
const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler;
91+
const lines: string[] = [];
92+
let offset = 0;
93+
for (let page = 0; page < 20; page += 1) {
94+
const result = await handler(
95+
makeCall("read_file", { path: formatArchiveRef(occ.occurrenceId), offset, limit: 2000 }),
96+
new AbortController().signal,
97+
);
98+
const text = String(result.content);
99+
expect(text.length).toBeLessThan(10_000);
100+
for (const match of text.matchAll(/^\s*\d+\t(.*)$/gm)) lines.push(match[1]!);
101+
const continuation = /Use offset=(\d+) to continue/.exec(text);
102+
if (continuation === null) break;
103+
expect(Number(continuation[1])).toBeGreaterThan(offset);
104+
offset = Number(continuation[1]);
105+
}
106+
expect(lines[0]).toContain("base64");
107+
expect(lines[0]).toContain(occ.contentHash);
108+
expect(Buffer.from(lines.slice(1).join(""), "base64").equals(Buffer.from(bytes))).toBe(true);
109+
const result = await handler(
110+
makeCall("grep", {
111+
pattern: Buffer.from(bytes).toString("base64").slice(0, 60),
112+
path: "archive:///",
113+
}),
114+
new AbortController().signal,
115+
);
116+
expect(String(result.content)).not.toContain(formatArchiveRef(occ.occurrenceId));
117+
const metadata = await handler(
118+
makeCall("grep", { pattern: "evidence-binary", path: "archive:///" }),
119+
new AbortController().signal,
120+
);
121+
expect(String(metadata.content)).toContain(formatArchiveRef(occ.occurrenceId));
122+
},
123+
);
79124
test("search_files lists archive:/// refs and read_file returns the payload", async () => {
80125
const archive = memoryArchive("sess-primary");
81126
const occ = await archive.recordAuthorizedPayload({

src/plugins/evidence-archive-search-plugin.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,16 @@ async function readArchiveOccurrence(
229229
if (occurrenceId === undefined) {
230230
throw new Error("read_file archive path must be archive:///{occurrenceId}");
231231
}
232-
const text = await archive.readAuthorizedPayload(occurrenceId);
233232
const offsetArg = num(args.offset);
234233
const offset = offsetArg !== undefined && offsetArg > 0 ? Math.floor(offsetArg) : 0;
235234
const limitArg = num(args.limit);
236235
const limit =
237236
limitArg !== undefined && limitArg > 0 ? Math.floor(limitArg) : READ_FILE_DEFAULT_MAX_LINES;
237+
signal.throwIfAborted();
238+
const page = await archive.readBinaryPage(occurrenceId, offset, limit);
239+
signal.throwIfAborted();
240+
if (page !== undefined) return page;
241+
const text = await archive.readAuthorizedPayload(occurrenceId);
238242
const result = await readBytesBounded(
239243
new TextEncoder().encode(text),
240244
offset,
@@ -344,7 +348,10 @@ async function grepArchive(
344348
if (occ.gap === true) continue;
345349
let payload: string;
346350
try {
347-
payload = await archive.readAuthorizedPayload(occ.occurrenceId);
351+
payload =
352+
occ.binary !== undefined
353+
? JSON.stringify({ ...occ.binary, contentHash: occ.contentHash })
354+
: await archive.readAuthorizedPayload(occ.occurrenceId);
348355
} catch (err) {
349356
if (signal.aborted) throw err;
350357
continue;

src/session/assemble-runtime.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
type CompactionArchive,
6161
} from "./compaction-archive.js";
6262
import path from "node:path";
63+
import { captureSyntheticToolResult } from "./synthetic-tool-result.js";
6364
import {
6465
loadProjectTrust,
6566
isPluginTrusted,
@@ -415,6 +416,12 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
415416
getLiveFleetCount: wiring.getLiveFleetCount,
416417
},
417418
);
419+
const archive = wiring.evidenceArchiveHolder?.current;
420+
const decide = d.decide.bind(d);
421+
d.decide = async (event, ...rest) => {
422+
await captureSyntheticToolResult(archive, event);
423+
return decide(event, ...rest);
424+
};
418425
directorHolder.instance = d;
419426
return d;
420427
},

src/session/compaction-archive-schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export const ArchiveOccurrence = type({
1919
"lifecycle?": ToolRecordingLifecycle,
2020
"provenance?": "string",
2121
"gap?": "boolean",
22+
"binary?": {
23+
encoding: "'raw'",
24+
contentType: "string",
25+
name: "string",
26+
byteLength: "number.integer >= 0",
27+
},
2228
});
2329
export type ArchiveOccurrence = typeof ArchiveOccurrence.infer;
2430

src/session/compaction-archive.test.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -996,10 +996,13 @@ describe("wrapCompactorWithCompletenessGate", () => {
996996

997997
await archive.recordAuthorizedPayload({
998998
kind: "attachment",
999-
payload: JSON.stringify({
1000-
contentHash: hashAuthorizedBytes(pdfBytes),
1001-
mimeType: "application/pdf",
1002-
}),
999+
payload: pdfBytes,
1000+
binary: {
1001+
encoding: "raw",
1002+
contentType: "application/pdf",
1003+
name: "document.pdf",
1004+
byteLength: pdfBytes.byteLength,
1005+
},
10031006
});
10041007
const allowed = await wrapped.apply(turns, ctx);
10051008
expect(allowed.record.reason).toBe("compact");
@@ -1041,17 +1044,23 @@ describe("wrapCompactorWithCompletenessGate", () => {
10411044

10421045
await archive.recordAuthorizedPayload({
10431046
kind: "attachment",
1044-
payload: JSON.stringify({
1045-
contentHash: hashAuthorizedBytes(bytes),
1046-
mimeType: "application/octet-stream",
1047-
}),
1047+
payload: bytes,
1048+
binary: {
1049+
encoding: "raw",
1050+
contentType: "application/octet-stream",
1051+
name: "media",
1052+
byteLength: bytes.byteLength,
1053+
},
10481054
});
10491055
await archive.recordAuthorizedPayload({
10501056
kind: "attachment",
1051-
payload: JSON.stringify({
1052-
contentHash: hashAuthorizedBytes(bytes),
1053-
mimeType: "application/octet-stream",
1054-
}),
1057+
payload: bytes,
1058+
binary: {
1059+
encoding: "raw",
1060+
contentType: "application/octet-stream",
1061+
name: "media",
1062+
byteLength: bytes.byteLength,
1063+
},
10551064
});
10561065
for (const type of ["video", "audio"] as const) {
10571066
const turns: import("@intx/types/runtime").ConversationTurn[] = [

0 commit comments

Comments
 (0)