From 4f5be609d3bbe894b71d68f77e3e175e3ecba235 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 14:50:12 -0700 Subject: [PATCH 01/16] Establish a repeated compaction evidence baseline --- evals/compaction/README.md | 151 ++++++++ evals/compaction/fixtures.ts | 39 ++ evals/compaction/metrics.test.ts | 104 ++++++ evals/compaction/metrics.ts | 115 ++++++ evals/compaction/results/baseline.json | 89 +++++ tests/integration/compaction-baseline.test.ts | 335 ++++++++++++++++++ tests/integration/harness.ts | 24 ++ 7 files changed, 857 insertions(+) create mode 100644 evals/compaction/README.md create mode 100644 evals/compaction/fixtures.ts create mode 100644 evals/compaction/metrics.test.ts create mode 100644 evals/compaction/metrics.ts create mode 100644 evals/compaction/results/baseline.json create mode 100644 tests/integration/compaction-baseline.test.ts diff --git a/evals/compaction/README.md b/evals/compaction/README.md new file mode 100644 index 000000000..3e03ab264 --- /dev/null +++ b/evals/compaction/README.md @@ -0,0 +1,151 @@ +# Primary compaction mechanics baseline + +This is the frozen, offline component baseline for the primary compaction +replacement. It is **not** a complete TUI/exec, permission-isolation, attachment +security, or live model-quality evaluation. The bounded integration-harness +scope has Greybeard approval; production `src/` remains unchanged. + +## Reproduce + +```bash +bun test ./evals/compaction/metrics.test.ts ./tests/integration/compaction-baseline.test.ts +``` + +The serial integration test uses the existing `openIntegrationSession`, +`runUntilDone`, and `closeIntegrationSession`. Optional test-only wiring registers +`createSessionPruningCompactor` with `createModelSummarizer`, and supplies the +normal `buildCompactionContinuationMessage()` delivery callback. The actual +primary director, governor, reactor, toolset, and optimized git-backed store run. +No test calls the compactor directly, rewrites history, or uses a substitute +compaction implementation. Existing harness callers remain unchanged. + +The harness defaults to `permissiveAuthorize`; this fixture also bypasses tool +permission prompts in its temporary workspace. **It proves no authorization, +approval-resume, or permission-isolation property.** No production permission or +repeat guard is changed. There is no production hook, second host, network +server, or paid provider request. + +## Frozen protocol + +- Product revision: `6ea596945657f3a0d3af5bfb0277b94af19e1589`, package `0.3.18`. +- Research revision `b92dad53` is not the baseline. No version bump is included. +- Protocol/model script: `primary-component-mechanics-v1` in the integration test. +- External inference: `@intx/inference-testing` `0.3.0`, Anthropic wire format, + source/model `anthropic:claude-integration` / `claude-integration`. +- Vendored Interchange base: `0205b07b64d03f0fec2e4be3593c764070a9ba8a`, with + repository-local patches recorded in `docs/VENDORING.md` and the patch ledger. +- Runtime of the captured sample: Bun `1.3.14`, Darwin arm64. +- Production policy: six recent turns; production anchor and no-op rules; + model summary limit 4,000 characters and deterministic factory limit 2,500. +- Trigger schedule: primary inference calls **19, 31, 43** report **synthetic** + input 200,000; other explicitly scripted setup/growth calls report synthetic + input 100 and output 1. Zero cache/thinking fields are synthetic wire fields, + not measurements. Evidence-response wire frames use harness defaults, not + measured provider usage. Production thresholds and hysteresis are unchanged. +- Each phase adds ten distinct user/assistant audit-item exchanges, then a + distinct real `read_file` call. The governor intercepts the post-tool infer and + resumes through the same agent's contentless inbound channel. +- Time bounds: 30 seconds per send; 120 seconds for the positive fixture. + +Git blob identities freeze the uncommitted harness additions without inventing +a commit revision. Recompute with `git hash-object` on these paths: + +- `evals/compaction/fixtures.ts`: `67f1fdfb45272464efc62111c1c7525ed067264b` +- `evals/compaction/metrics.ts`: `3cc4efadb5dad1f8078b6db412287b0ab5c25e8f` +- `tests/integration/compaction-baseline.test.ts`: `abed36b077cb16e30bc5015852144bb6bb7fb5b2` +- Original captured-run evaluator: `80a627549e0e009ca7c3079e26875baf3407e8e9` +- `tests/integration/harness.ts`: `4567ac03433b70f1eed4f3238e2a3b5e1f5b4557` + +The fixture module deterministically generates the exact input bytes: an early +constraint, a later corrected decision, a failing `bun diagnose.ts` with decisive +output after 250 preamble lines, and an oversized diagnostic with the decisive +value after 1,500 lines. A full read and a targeted middle-line read exercise +real tools. Before growth, the test verifies that all four facts reached +persisted history. Generated workspace files contain no grader expectations. + +## Evidence and scoring + +The summarizer responder extracts only evidence markers in the **actual excerpt +received from the production summarizer**. It never reads the original fixture +or discarded turns. The primary response matcher selects an answer only when +its exact set of source/value/id triples is present in the actual wire request, +independent of their order. A real-agent reversed-order regression recovers all +four facts without weakening source/value matching. +All 16 subsets include an explicit all-missing response. A separate real-agent +negative test supplies no evidence and verifies that fixture answers do not +appear. These controlled responders measure transport/loss, not model judgment. + +`metrics.ts` scores exact source and value, separately from artifact completion. +Its tests reject altered artifacts, wrong sources/answers, absent evidence, +repeated work, requested-only folds, no-ops, and missing continuation. Denominators +remain four required facts per observation; the failed recovery task is retained. + +At complete `runUntilDone` boundaries the fixture reads and validates the small +`turns.jsonl` directly, without an in-flight `store.load()` or recovery read. A +qualifying fold requires changed persisted SHA-256 bytes, fewer persisted turns, +an additional production compacted-context marker, a new summarizer invocation, +and primary continuation inference. Requests alone cannot qualify. This proves +persisted replacement in a completed run, not crash atomicity or restart recovery. + +The work counters derive from actual tool start/done events. Failed shell calls +include the production `exit code \n` content prefix, even without +`isError`. A regression executes `exit 7` twice through real tools and observes +two failures and one repeated failed attempt. The three fixed +phase-end reads are labelled verification by their frozen call IDs, not by a +model-provided excuse. Other repeated reads/searches, repeated failed attempts, +and duplicated edits are distinct metrics. No search or edit is prescribed here; +zero repetition is not evidence of capable live problem-solving. + +## Captured outcome + +`results/baseline.json` retains one successful mechanics run, including all three +observations, persisted hashes, phase latencies, and the failed recovery result. + +- Mechanics task qualification: **1/1**; persisted folds **3/3**. +- Persisted turn counts: **36 → 8**, **28 → 10**, **30 → 12**. +- Continuation primary calls: **20, 32, 44**. +- Required-fact recovery after each fold: **1/4**; full-recovery tasks **0/1**. +- Only the initial constraint survives. The corrected decision, failed-command + evidence, and decisive oversized-output fact are lost from the primary reply. +- Three actual summarizer calls; six tool calls; three verification reads; + zero observed repeated reads/searches, repeated failed attempts, or duplicated edits. +- Captured phase latencies: approximately **757, 796, 801 ms**. They include the + tool call, folding/persistence, continuation and reply, not compaction alone. +- Positive fixture duration: approximately **12.12 seconds**, including setup and growth. + +Primary/summarizer token totals, real cache reads/writes, monetary cost, +compaction-only latency, and live completion quality are **unavailable**, not +zero. Persisted hashes include runtime timestamps and legitimately vary between +runs; the frozen source hashes identify the repeatable protocol. + +The test characterizes the observed baseline loss; passing tests do not mean +factual recovery passes. Replacement comparison must reuse these fixture bytes, +trigger schedule, budgets, and exact-source grader. Keep this captured result +unchanged and report improved recovery separately rather than weakening the +grade or excluding the baseline failure. + +## Remaining scope + +Real TUI/exec host continuity, workflow/controller state, approvals, worker/task +ownership, attachments, concurrent incoming messages, recovery, and finalization +belong to Unit 6 and the Unit 8 cross-surface matrix. Archive exactness and +security belong to Units 2–4. Live quality and spend-approved token/cache/cost +comparison belong to Unit 8. These requirements moved; they were not removed. + +## Verification + +The focused command above passes (9 tests, 47 assertions with the evaluator +regressions; the original captured run has 7 tests and 44 assertions). Results +retain the original sample and record corrected-evaluator verification separately; +fixture bytes, trigger schedule, and the observed 1/4 baseline recovery are unchanged. +Required regression and repository gates: + +```bash +bun test ./src/agent/compaction.test.ts ./src/context-compactor.test.ts ./src/session/runtime-assembly.test.ts ./src/session/optimized-context-store.test.ts ./tests/unit/compactor-pairing.test.ts +bun run typecheck +bun run build +bun run test +bun run check +``` + +No commit or release action is part of this fixture. diff --git a/evals/compaction/fixtures.ts b/evals/compaction/fixtures.ts new file mode 100644 index 000000000..67f1fdfb4 --- /dev/null +++ b/evals/compaction/fixtures.ts @@ -0,0 +1,39 @@ +import type { Evidence } from "./metrics.js"; + +export const BASELINE = { + productRevision: "6ea596945657f3a0d3af5bfb0277b94af19e1589", + packageVersion: "0.3.18", + protocol: "primary-component-mechanics-v1", + model: "claude-integration", + provider: "anthropic", + folds: 3, + growthTurnsPerFold: 10, + syntheticLowInput: 100, + syntheticTriggerInput: 200000, + outputTokens: 1, + keepRecentTurns: 6, + summaryMaxChars: 4000, + wallTimeoutMs: 30000, +} as const; + +export const REQUIRED_EVIDENCE: readonly Evidence[] = [ + { id: "constraint", source: "operator:initial", value: "no-schema-change" }, + { id: "decision", source: "operator:correction", value: "west-not-east" }, + { id: "failure", source: "command:diagnose", value: "unsupported-format-7" }, + { id: "decisive", source: "file:diagnostic.log:middle", value: "route-cobalt" }, +]; + +export function evidenceText(facts: readonly Evidence[]): string { + return facts.map((fact) => `[[evidence:${fact.id}|${fact.source}|${fact.value}]]`).join("\n"); +} + +export const INITIAL = + "Audit the deployment. Preserve this constraint: " + evidenceText(REQUIRED_EVIDENCE.slice(0, 1)); +export const CORRECTION = + "Correction: target west instead of east. " + evidenceText(REQUIRED_EVIDENCE.slice(1, 2)); +export const FAILED_OUTPUT = + "Diagnostic preamble.\n".repeat(250) + evidenceText(REQUIRED_EVIDENCE.slice(2, 3)); +export const OVERSIZED_OUTPUT = + "Unrelated diagnostic row.\n".repeat(1500) + + evidenceText(REQUIRED_EVIDENCE.slice(3)) + + "\nUnrelated trailing row.".repeat(1500); diff --git a/evals/compaction/metrics.test.ts b/evals/compaction/metrics.test.ts new file mode 100644 index 000000000..6754d807d --- /dev/null +++ b/evals/compaction/metrics.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; +import { + grade, + Measurement, + qualifyingFold, + recoverEvidence, + repeatedWork, + type Fold, +} from "./metrics.js"; + +const fact = { id: "region", source: "operator:correction", value: "west" }; +const folds: Fold[] = [10, 20, 30].map((call) => ({ + requestedAtCall: call, + beforeHash: `before-${call}`, + afterHash: `after-${call}`, + beforeTurns: 20, + afterTurns: 10, + persisted: true, + continuedAtCall: call + 1, +})); +const baseline = { + expected: [fact], + recovered: [fact], + expectedArtifact: "west\n", + artifact: "west\n", + folds, + trace: [], +}; + +describe("compaction baseline grading", () => { + test("requires evidence actually visible to the scripted responder", () => { + expect(recoverEvidence("[[evidence:region|operator:correction|west]]")).toEqual([fact]); + expect(recoverEvidence("The region was mentioned earlier.")).toEqual([]); + expect( + grade({ ...baseline, recovered: recoverEvidence("evidence removed") }).factualRecovery, + ).toBe(false); + }); + + test("rejects wrong values, sources, and altered artifacts independently", () => { + expect(grade(baseline).passed).toBe(true); + expect(grade({ ...baseline, recovered: [{ ...fact, value: "east" }] }).passed).toBe(false); + expect(grade({ ...baseline, recovered: [{ ...fact, source: "invented" }] }).passed).toBe(false); + const altered = grade({ ...baseline, artifact: "east\n" }); + expect(altered.completion).toBe(false); + expect(altered.factualRecovery).toBe(true); + }); + + test("keeps failed fold denominators and rejects requests, no-ops, and missing continuation", () => { + expect(grade({ ...baseline, folds: [] }).qualifying).toBe(false); + for (const fold of folds) { + expect(qualifyingFold({ ...fold, persisted: false })).toBe(false); + expect(qualifyingFold({ ...fold, afterHash: fold.beforeHash })).toBe(false); + expect(qualifyingFold({ ...fold, continuedAtCall: null })).toBe(false); + expect(qualifyingFold({ ...fold, afterTurns: fold.beforeTurns })).toBe(false); + } + expect(grade({ ...baseline, recovered: [] }).requiredFacts).toBe(1); + }); + + test("counts repeated work separately from legitimate scheduled verification", () => { + const read = { + name: "read_file", + argumentsKey: "a", + outcome: "success", + purpose: "action", + } as const; + const search = { ...read, name: "grep" }; + const failure = { ...read, name: "run_shell", outcome: "failure" } as const; + const edit = { ...read, name: "edit_file" }; + const trace = [ + read, + read, + search, + search, + failure, + failure, + edit, + edit, + { ...read, purpose: "verification" } as const, + ]; + expect(repeatedWork(trace)).toEqual({ + repeatedReads: 1, + repeatedSearches: 1, + repeatedFailedAttempts: 1, + duplicatedEdits: 1, + verificationCalls: 1, + }); + expect(grade({ ...baseline, trace }).passed).toBe(false); + }); + + test("missing usage is unavailable, not zero or an unlabelled estimate", () => { + expect(Measurement({ status: "unavailable", reason: "offline" }) instanceof type.errors).toBe( + false, + ); + expect( + Measurement({ status: "reported", value: -1, unit: "tokens" }) instanceof type.errors, + ).toBe(true); + expect(Measurement({ value: 0, unit: "tokens" }) instanceof type.errors).toBe(true); + expect( + Measurement({ status: "synthetic", value: 200000, unit: "trigger tokens" }) instanceof + type.errors, + ).toBe(false); + }); +}); diff --git a/evals/compaction/metrics.ts b/evals/compaction/metrics.ts new file mode 100644 index 000000000..3cc4efadb --- /dev/null +++ b/evals/compaction/metrics.ts @@ -0,0 +1,115 @@ +import { type } from "arktype"; + +export const Evidence = type({ id: "string", source: "string", value: "string" }); +export type Evidence = typeof Evidence.infer; + +export const Measurement = type.or( + { status: "'unavailable'", reason: "string" }, + { status: "'reported' | 'estimated' | 'synthetic'", value: "number >= 0", unit: "string" }, +); +export type Measurement = typeof Measurement.infer; + +export const Fold = type({ + requestedAtCall: "number.integer >= 1", + beforeHash: "string", + afterHash: "string", + beforeTurns: "number.integer >= 0", + afterTurns: "number.integer >= 0", + persisted: "boolean", + continuedAtCall: "number.integer >= 1 | null", +}); +export type Fold = typeof Fold.infer; + +export function qualifyingFold(fold: Fold): boolean { + return ( + fold.persisted && + fold.beforeHash !== fold.afterHash && + fold.afterTurns < fold.beforeTurns && + fold.continuedAtCall !== null && + fold.continuedAtCall > fold.requestedAtCall + ); +} + +export const Work = type({ + name: "string", + argumentsKey: "string", + outcome: "'success' | 'failure' | 'denied'", + purpose: "'action' | 'verification'", +}); +export type Work = typeof Work.infer; + +export function repeatedWork(trace: readonly Work[]) { + const seen = new Set(); + const failures = new Set(); + let repeatedReads = 0; + let repeatedSearches = 0; + let repeatedFailedAttempts = 0; + let duplicatedEdits = 0; + let verificationCalls = 0; + for (const work of trace) { + const key = JSON.stringify([work.name, work.argumentsKey]); + if (work.purpose === "verification") { + verificationCalls++; + continue; + } + if (seen.has(key)) { + if (work.name === "read_file") repeatedReads++; + if (["grep", "search_files", "web_search"].includes(work.name)) repeatedSearches++; + if (["write_file", "edit_file", "apply_patch"].includes(work.name)) duplicatedEdits++; + } + if (failures.has(key)) repeatedFailedAttempts++; + seen.add(key); + if (work.outcome === "failure") failures.add(key); + } + return { + repeatedReads, + repeatedSearches, + repeatedFailedAttempts, + duplicatedEdits, + verificationCalls, + }; +} + +export function grade(args: { + expected: readonly Evidence[]; + recovered: readonly Evidence[]; + expectedArtifact: string; + artifact: string | null; + folds: readonly Fold[]; + trace: readonly Work[]; +}) { + const recoveredFacts = args.expected.filter((fact) => + args.recovered.some( + (answer) => + answer.id === fact.id && answer.source === fact.source && answer.value === fact.value, + ), + ).length; + const work = repeatedWork(args.trace); + const persistedFolds = args.folds.filter(qualifyingFold).length; + const completion = args.artifact === args.expectedArtifact; + const factualRecovery = recoveredFacts === args.expected.length; + const repeatedActions = + work.repeatedReads + work.repeatedSearches + work.repeatedFailedAttempts + work.duplicatedEdits; + return { + completion, + factualRecovery, + recoveredFacts, + requiredFacts: args.expected.length, + persistedFolds, + qualifying: persistedFolds >= 3, + ...work, + passed: completion && factualRecovery && persistedFolds >= 3 && repeatedActions === 0, + }; +} + +/** The responder receives only inference-visible text, never grader expectations. */ +export function recoverEvidence(context: string): Evidence[] { + const facts = new Map(); + for (const match of context.matchAll(/\[\[evidence:([^|\]\n]+)\|([^|\]\n]+)\|([^\]\n]+)\]\]/g)) { + const [, id, source, value] = match; + if (id !== undefined && source !== undefined && value !== undefined) { + facts.set(id, { id, source, value }); + } + } + return [...facts.values()]; +} diff --git a/evals/compaction/results/baseline.json b/evals/compaction/results/baseline.json new file mode 100644 index 000000000..23fd6ce76 --- /dev/null +++ b/evals/compaction/results/baseline.json @@ -0,0 +1,89 @@ +{ + "protocol": "primary-component-mechanics-v1", + "productRevision": "6ea596945657f3a0d3af5bfb0277b94af19e1589", + "packageVersion": "0.3.18", + "capturedEvaluatorBlob": "80a627549e0e009ca7c3079e26875baf3407e8e9", + "correctedEvaluatorVerification": { + "evaluatorBlob": "abed36b077cb16e30bc5015852144bb6bb7fb5b2", + "fixtureBlob": "67f1fdfb45272464efc62111c1c7525ed067264b", + "graderBlob": "3cc4efadb5dad1f8078b6db412287b0ab5c25e8f", + "integrationHarnessBlob": "4567ac03433b70f1eed4f3238e2a3b5e1f5b4557", + "command": "bun test ./evals/compaction/metrics.test.ts ./tests/integration/compaction-baseline.test.ts", + "exitCode": 0, + "testsPassed": 9, + "assertions": 47, + "nonzeroShellFailures": 2, + "repeatedFailedAttempts": 1, + "reorderedFactsRecovered": 4, + "persistedFolds": 3, + "baselineFactsRecoveredPerFold": 1, + "baselineFactsRequiredPerFold": 4 + }, + "runtime": "Bun 1.3.14, Darwin arm64", + "command": "bun test ./evals/compaction/metrics.test.ts ./tests/integration/compaction-baseline.test.ts", + "exitCode": 0, + "testsPassed": 7, + "assertions": 44, + "tasks": 1, + "qualifyingTasks": 1, + "factualRecoveryPassingTasks": 0, + "summaryCalls": 3, + "observations": [ + { + "phase": 1, + "requestedAtCall": 19, + "beforeHash": "170564b660064d472e8121409ba41d095ac6915fc6b080a5b797029d2f3d10e5", + "afterHash": "979a1500b99d349d1143893300edef44b20634767297d56452c440fb2e18a2b8", + "beforeTurns": 36, + "afterTurns": 8, + "persisted": true, + "continuedAtCall": 20, + "recoveredFacts": 1, + "requiredFacts": 4, + "phaseLatencyMs": 756.6435419999998 + }, + { + "phase": 2, + "requestedAtCall": 31, + "beforeHash": "383434fd4f35c0e53af927583aeaf78ab1709522bdf6671acb11d606890d4032", + "afterHash": "7dbcb29c38bcc5fbde6d2a9b1bf5b52fd00a7142e3c617ed47e537cc81f8c9b2", + "beforeTurns": 28, + "afterTurns": 10, + "persisted": true, + "continuedAtCall": 32, + "recoveredFacts": 1, + "requiredFacts": 4, + "phaseLatencyMs": 796.1481249999997 + }, + { + "phase": 3, + "requestedAtCall": 43, + "beforeHash": "74700ca0f7c7b25a284056dbbabdaa05c6bac81db891869f0e966715314101de", + "afterHash": "3d5f17659443cf6143679d293db553f6be691cb74d7aed6b8693e959e87a1775", + "beforeTurns": 30, + "afterTurns": 12, + "persisted": true, + "continuedAtCall": 44, + "recoveredFacts": 1, + "requiredFacts": 4, + "phaseLatencyMs": 800.9756669999988 + } + ], + "observedWork": { + "toolCalls": 6, + "repeatedReads": 0, + "repeatedSearches": 0, + "repeatedFailedAttempts": 0, + "duplicatedEdits": 0, + "verificationCalls": 3 + }, + "positiveTestDurationMs": 12123.87, + "unavailable": { + "primaryTokens": "Offline wire usage is synthetic stimulus, not measured usage.", + "summarizerTokens": "Offline completion has no tokenizer or provider accounting.", + "cacheReadsAndWrites": "No provider cache is exercised.", + "cost": "No paid inference or applicable pricing measurement.", + "compactionOnlyLatency": "Phase latency includes tool, fold, persistence, continuation, and reply.", + "liveTaskCompletion": "This fixture characterizes components, not a real model task." + } +} diff --git a/tests/integration/compaction-baseline.test.ts b/tests/integration/compaction-baseline.test.ts new file mode 100644 index 000000000..abed36b07 --- /dev/null +++ b/tests/integration/compaction-baseline.test.ts @@ -0,0 +1,335 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; +import { wire } from "@intx/inference-testing"; +import { ContentBlock } from "@intx/types/runtime"; +import { createPermissionGate } from "../../src/permission/gate.js"; +import { COMPACTED_PREFIX } from "../../src/session/compactor.js"; +import { + BASELINE, + CORRECTION, + FAILED_OUTPUT, + INITIAL, + OVERSIZED_OUTPUT, + REQUIRED_EVIDENCE, + evidenceText, +} from "../../evals/compaction/fixtures.js"; +import { + qualifyingFold, + recoverEvidence, + repeatedWork, + type Fold, + type Work, +} from "../../evals/compaction/metrics.js"; +import { + closeIntegrationSession, + openIntegrationSession, + runUntilDone, + type IntegrationSession, + type TurnResult, +} from "./harness.js"; + +const PersistedTurn = type({ role: "string", content: ContentBlock.array(), timestamp: "number" }); +const WireRequest = type({ messages: type({ role: "string", content: "unknown" }).array() }); +const usage = (input: number) => ({ input, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 }); + +function observedWork(events: TurnResult["events"]): Work[] { + return events.flatMap((event) => { + if (event.type !== "tool.start") return []; + const call = event.data.call; + const done = events.find( + (candidate) => candidate.type === "tool.done" && candidate.data.result.callId === call.id, + ); + if (done?.type !== "tool.done") throw new Error(`Missing tool result: ${call.id}`); + const result = done.data.result; + const failedShellExit = + call.name === "run_shell" && + typeof result.content === "string" && + /^exit code -?[1-9]\d*\n/.test(result.content); + return [ + { + name: call.name, + argumentsKey: JSON.stringify(call.arguments), + outcome: result.isError === true || failedShellExit ? "failure" : "success", + purpose: call.id.startsWith("fold-") ? "verification" : "action", + }, + ]; + }); +} + +async function snapshot(session: IntegrationSession) { + const raw = await readFile(join(session.workdir, "turns.jsonl"), "utf8"); + const turns = raw + .trim() + .split("\n") + .map((line) => PersistedTurn.assert(JSON.parse(line))); + return { hash: createHash("sha256").update(raw).digest("hex"), turns }; +} + +async function withTimeout(promise: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("Compaction baseline timed out")), + BASELINE.wallTimeoutMs, + ); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +// Each response is selectable only if its exact evidence set is in the actual wire request. +function queueEvidenceReply(session: IntegrationSession, callId: string) { + for (let mask = 0; mask < 1 << REQUIRED_EVIDENCE.length; mask++) { + const facts = REQUIRED_EVIDENCE.filter((_, index) => (mask & (1 << index)) !== 0); + const text = evidenceText(facts) || "Missing all required evidence."; + const stream = session.harness.scenario.createStream(); + stream.enqueueAll(wire.completeResponse("anthropic", { text }), { + startAt: session.harness.clock.now() + 1, + }); + session.harness.scenario.whenRequestBodyMatches((body) => { + const request = WireRequest.assert(JSON.parse(body)); + const recovered = recoverEvidence(body); + return ( + JSON.stringify(request.messages.at(-1)?.content).includes(callId) && + recovered.length === facts.length && + facts.every((fact) => + recovered.some( + (answer) => + answer.id === fact.id && answer.source === fact.source && answer.value === fact.value, + ), + ) + ); + }, stream); + } +} + +describe("integration — frozen compaction mechanics baseline", () => { + test.serial( + "counts repeated real nonzero shell exits as failed attempts", + async () => { + const session = await openIntegrationSession({ + permissionGate: createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + }), + }); + const trace: Work[] = []; + try { + for (let attempt = 0; attempt < 2; attempt++) { + session.harness.scenario.replyOnce("anthropic", { + text: `Run diagnostic attempt ${attempt}.`, + toolCalls: [{ name: "run_shell", args: { command: "exit 7", timeout: 5000 } }], + }); + session.harness.scenario.replyOnce("anthropic", { text: "Diagnostic finished." }); + const { events } = await withTimeout( + runUntilDone(session, `Diagnose attempt ${attempt}.`), + ); + trace.push(...observedWork(events)); + } + expect(trace.map((work) => work.outcome)).toEqual(["failure", "failure"]); + expect(repeatedWork(trace).repeatedFailedAttempts).toBe(1); + } finally { + await closeIntegrationSession(session); + } + }, + 40000, + ); + + test.serial( + "the primary responder recovers reordered evidence", + async () => { + const session = await openIntegrationSession({ + permissionGate: createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + }), + }); + try { + queueEvidenceReply(session, "reordered-evidence"); + const { reply } = await withTimeout( + runUntilDone( + session, + `reordered-evidence\n${evidenceText([...REQUIRED_EVIDENCE].reverse())}`, + ), + ); + expect(recoverEvidence(reply)).toEqual([...REQUIRED_EVIDENCE]); + } finally { + await closeIntegrationSession(session); + } + }, + 40000, + ); + + test.serial( + "the primary responder reports missing evidence instead of fixture answers", + async () => { + const session = await openIntegrationSession({ + permissionGate: createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + }), + }); + try { + queueEvidenceReply(session, "absent-evidence"); + const { reply } = await withTimeout(runUntilDone(session, "absent-evidence")); + expect(reply).toBe("Missing all required evidence."); + expect(recoverEvidence(reply)).toEqual([]); + } finally { + await closeIntegrationSession(session); + } + }, + ); + + test.serial( + "persists three real folds and resumes primary inference after each", + async () => { + const summaryInputs: string[] = []; + const session = await openIntegrationSession({ + permissionGate: createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + }), + compactionCompletion: async (turns) => { + const context = turns + .flatMap((turn) => + turn.content.flatMap((block) => (block.type === "text" ? [block.text] : [])), + ) + .join("\n"); + summaryInputs.push(context); + return ( + evidenceText(recoverEvidence(context)) || + "No required evidence in the supplied excerpt." + ); + }, + }); + const folds: Fold[] = []; + const trace: Work[] = []; + try { + await writeFile(join(session.cwd, "diagnostic.log"), OVERSIZED_OUTPUT); + await writeFile( + join(session.cwd, "diagnose.ts"), + `process.stdout.write(${JSON.stringify(FAILED_OUTPUT)}); process.exit(1);`, + ); + for (const message of [INITIAL, CORRECTION]) { + session.harness.scenario.replyOnce("anthropic", { + text: "Acknowledged.", + headUsage: usage(BASELINE.syntheticLowInput), + }); + await withTimeout(runUntilDone(session, message)); + } + for (const toolCall of [ + { name: "run_shell", args: { command: "bun diagnose.ts", timeout: 5000 } }, + { name: "read_file", args: { path: "diagnostic.log", offset: 0, limit: 4000 } }, + { name: "read_file", args: { path: "diagnostic.log", offset: 1500, limit: 1 } }, + ]) { + session.harness.scenario.replyOnce("anthropic", { + text: "Inspecting evidence.", + toolCalls: [toolCall], + headUsage: usage(BASELINE.syntheticLowInput), + }); + session.harness.scenario.replyOnce("anthropic", { + text: "Inspection complete.", + headUsage: usage(BASELINE.syntheticLowInput), + }); + const { events } = await withTimeout( + runUntilDone(session, "Inspect the next diagnostic source."), + ); + trace.push(...observedWork(events)); + } + const evidenceBefore = recoverEvidence(JSON.stringify((await snapshot(session)).turns)); + expect(evidenceBefore).toEqual([...REQUIRED_EVIDENCE]); + for (let fold = 0; fold < BASELINE.folds; fold++) { + for (let growth = 0; growth < BASELINE.growthTurnsPerFold; growth++) { + session.harness.scenario.replyOnce("anthropic", { + text: `Checked independent audit item ${fold}-${growth}.`, + headUsage: usage(BASELINE.syntheticLowInput), + }); + await withTimeout(runUntilDone(session, `Verify audit item ${fold}-${growth}.`)); + } + const before = await snapshot(session); + const requestCount = session.harness.scenario.matchedRequests().length; + session.harness.scenario.replyOnce("anthropic", { + text: `Verify diagnostic row ${fold}.`, + toolCalls: [ + { + callId: `fold-${fold}`, + name: "read_file", + args: { path: "diagnostic.log", offset: fold, limit: 1 }, + }, + ], + headUsage: usage(BASELINE.syntheticTriggerInput), + }); + queueEvidenceReply(session, `fold-${fold}`); + const startedAt = performance.now(); + const { events, reply } = await withTimeout( + runUntilDone(session, `Finish audit phase ${fold}.`), + ); + trace.push(...observedWork(events)); + const after = await snapshot(session); + const inferenceCount = events.filter((event) => event.type === "inference.start").length; + const observation: Fold = { + requestedAtCall: requestCount + 1, + beforeHash: before.hash, + afterHash: after.hash, + beforeTurns: before.turns.length, + afterTurns: after.turns.length, + persisted: + after.turns.filter((turn) => + turn.content.some( + (block) => block.type === "text" && block.text.startsWith(COMPACTED_PREFIX), + ), + ).length === + fold + 1, + continuedAtCall: inferenceCount >= 2 ? requestCount + 2 : null, + }; + folds.push(observation); + expect(qualifyingFold(observation)).toBe(true); + expect(summaryInputs.length).toBe(fold + 1); + expect(recoverEvidence(reply)).toEqual(REQUIRED_EVIDENCE.slice(0, 1)); + process.stdout.write( + `${JSON.stringify({ phase: fold + 1, ...observation, recoveredFacts: recoverEvidence(reply).length, requiredFacts: REQUIRED_EVIDENCE.length, phaseLatencyMs: performance.now() - startedAt })}\n`, + ); + } + expect(folds.filter(qualifyingFold)).toHaveLength(3); + expect(trace).toHaveLength(6); + expect(repeatedWork(trace)).toEqual({ + repeatedReads: 0, + repeatedSearches: 0, + repeatedFailedAttempts: 0, + duplicatedEdits: 0, + verificationCalls: 3, + }); + expect( + qualifyingFold({ + requestedAtCall: 1, + beforeHash: "same", + afterHash: "same", + beforeTurns: 1, + afterTurns: 1, + persisted: false, + continuedAtCall: null, + }), + ).toBe(false); + } finally { + await closeIntegrationSession(session); + } + }, + 120000, + ); +}); diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 8f8faa88f..6062601aa 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -33,6 +33,11 @@ import { ID_PREFIX } from "../../src/branding.js"; import type { PermissionGate } from "../../src/permission/gate.js"; import { createOptimizedContextStore } from "../../src/session/optimized-context-store.js"; import { assertReplySend } from "../../src/subagent/run.js"; +import { createModelSummarizer, type CompletionFn } from "../../src/session/summarizer.js"; +import { + buildCompactionContinuationMessage, + createSessionPruningCompactor, +} from "../../src/session/runtime-assembly.js"; export const INTEGRATION_SOURCE: InferenceSource = { id: "anthropic:claude-integration", @@ -52,6 +57,8 @@ export interface IntegrationSession { export interface OpenIntegrationSessionOpts { permissionGate: PermissionGate; + /** Registers the production compactor and continuation; only inference is replaced. */ + compactionCompletion?: CompletionFn; /** Reactor authorization override (defaults to permissive). */ authorize?: ( resource: string, @@ -85,6 +92,9 @@ export async function openIntegrationSession( createChatDirector(agentCtx.systemPrompt, [...agentCtx.toolDefinitions], { onTasksChange: () => undefined, inactivityTimeoutMs: 750_000, + ...(opts.compactionCompletion !== undefined + ? { requestContinuation: () => agent.deliver(buildCompactionContinuationMessage()) } + : {}), }), }); @@ -131,6 +141,20 @@ export async function openIntegrationSession( factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, }), + ...(opts.compactionCompletion !== undefined + ? { + compactors: { + "pruning-compactor": createSessionPruningCompactor({ + compactionMode: "llm", + summarize: createModelSummarizer({ + getSource: () => INTEGRATION_SOURCE, + deps: harness.deps, + complete: opts.compactionCompletion, + }), + }), + }, + } + : {}), closeTimeoutMs: 0, }); From b936a4b5fb65cdc51488437d64fc2bde34473ef8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 22:27:26 -0700 Subject: [PATCH 02/16] Archive authorized primary-session evidence before lossy transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the post-policy representation that enters primary history — messages, tool args lifecycle, assistant text, structured/error results, MCP blocks, and attachment/overflow blob provenance — into a session-owned evidence archive with completeness certificates. Workers omit the hook. --- docs/IMPLEMENTATION.md | 2 + src/agent/posix-tool-plugins.ts | 7 +- src/agent/tools.ts | 7 + src/exec/runner.ts | 4 + src/mcp/client.ts | 39 +- src/mcp/plugin.test.ts | 1 + src/mcp/plugin.ts | 83 ++- src/plugins/result-truncation-plugin.ts | 88 ++- .../tool-result-secret-scrub-plugin.ts | 23 +- src/plugins/tool-result-secret-scrub.test.ts | 82 ++- src/plugins/tool-result-secret-scrub.ts | 18 + src/session/assemble-runtime.ts | 61 +- src/session/attachment-store.ts | 26 + src/session/compaction-archive-schema.ts | 39 ++ src/session/compaction-archive.test.ts | 415 ++++++++++++ src/session/compaction-archive.ts | 599 ++++++++++++++++++ src/tui/runner/session.ts | 4 + 17 files changed, 1440 insertions(+), 58 deletions(-) create mode 100644 src/session/compaction-archive-schema.ts create mode 100644 src/session/compaction-archive.test.ts create mode 100644 src/session/compaction-archive.ts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 556ff49b8..bd84417df 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -80,6 +80,8 @@ src/ state.ts RunState JSON save/load compactor.ts Context compactor summarizer.ts Model-backed structured compaction summary (+ deterministic fallback) + compaction-archive.ts Primary-only authorized evidence archive (post-policy capture) + compaction-archive-schema.ts Archive occurrence / completeness certificate schemas run-sink.ts Run-level event sink stream-consumer.ts Async stream consumer with error handling hooks.ts Lifecycle hooks: discovery, turn collector, run summary diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index 2d8d39cf8..7b7976667 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -27,6 +27,7 @@ import { } from "../plugins/read-file-guard-plugin.js"; import type { PermissionGate } from "../permission/gate.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; +import type { CompactionArchive } from "../session/compaction-archive.js"; export interface CorePosixToolPluginsArgs { cwd: string; @@ -44,6 +45,8 @@ export interface CorePosixToolPluginsArgs { // Live getter for the background-shell registry (run_shell background:true). // Omitted makes background runs fail closed in shell-guard. getBackgroundShellRegistry?: () => BackgroundShellRegistry | undefined; + /** Primary-only evidence archive; workers omit this getter. */ + getEvidenceArchive?: () => CompactionArchive | undefined; } // Middleware order matches docs/ARCHITECTURE.md: path escape through truncation, @@ -81,6 +84,7 @@ export function buildCorePosixToolPlugins( getContextDir, shellEnv, getBackgroundShellRegistry, + getEvidenceArchive, } = args; // Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell // cwd are not hard-denied after the gate already auto-allows. Pass a live @@ -89,10 +93,11 @@ export function buildCorePosixToolPlugins( // regardless. const allowOutside = (): boolean => permissionGate.getSkipPermissions(); const truncationOptions = - getBlobWriter !== undefined || getContextDir !== undefined + getBlobWriter !== undefined || getContextDir !== undefined || getEvidenceArchive !== undefined ? { ...(getBlobWriter !== undefined ? { getBlobWriter } : {}), ...(getContextDir !== undefined ? { getContextDir } : {}), + ...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}), } : {}; return [ diff --git a/src/agent/tools.ts b/src/agent/tools.ts index c819dabbf..67a713456 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -26,6 +26,7 @@ import { wrapAgentToolsWithResultTruncation, type SpillBlobWriter, } from "../plugins/result-truncation-plugin.js"; +import type { CompactionArchive } from "../session/compaction-archive.js"; import { connectMCPServer as connectMCPClient, type MCPClient, @@ -199,6 +200,8 @@ export interface AgentToolsetArgs { // deliver the exit as a system message so the reactor re-enters on a later // turn; omit it and background runs still start/collect but never notify. onBackgroundShellExit?: (exit: BackgroundShellExit) => void; + /** Primary-only evidence archive; workers omit this getter. */ + getEvidenceArchive?: () => CompactionArchive | undefined; // Whether a workflow is currently running. submit_output rides the wire // every turn (workflow or not), so the model can call it with nothing active; // this lets its handler report an honest no-op instead of a false advance. @@ -334,6 +337,7 @@ export async function createAgentToolset( getBlobReader, getBlobWriter, getContextDir, + getEvidenceArchive, sessionMode = "orchestrator", shellEnv, toolAvailability = { languageServerAvailable: true }, @@ -420,6 +424,7 @@ export async function createAgentToolset( const truncationOptions = { ...(getBlobWriter !== undefined ? { getBlobWriter } : {}), ...(getContextDir !== undefined ? { getContextDir } : {}), + ...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}), }; const posixTools = createPosixTools({ cwd, @@ -435,6 +440,7 @@ export async function createAgentToolset( ? { readFileGuard: { blobReader: sessionBlobReader } } : {}), ...truncationOptions, + ...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}), ...(shellEnv !== undefined ? { shellEnv } : {}), getBackgroundShellRegistry: () => backgroundShells, }), @@ -977,6 +983,7 @@ export async function createAgentToolset( const mcpTools = mcpClientTools(result.client, { ...(getBlobWriter !== undefined ? { getBlobWriter } : {}), ...(getContextDir !== undefined ? { getContextDir } : {}), + ...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}), ...(isBuiltinExaMCPServer(config) ? { excludeToolNames: ["web_fetch_exa"] } : {}), diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 71d14bb94..ab93a93b1 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -102,6 +102,7 @@ import { loadSessionLocalSettings, resolveLiveSessionSources, } from "../session/assemble-runtime.js"; +import type { CompactionArchive } from "../session/compaction-archive.js"; import { emitPluginWarningSummary } from "../plugins/diagnostics.js"; import { createModelSummarizer } from "../session/summarizer.js"; import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js"; @@ -524,6 +525,7 @@ export async function runExec(config: Config): Promise { let currentAgent: Agent | null = null; let currentStorage: ContextStore | null = null; + const evidenceArchiveHolder: { current?: CompactionArchive } = {}; const overlay = resolveExecDirectorOverlay(config.director); const workflowHostHolder: { instance?: WorkflowHost } = {}; @@ -541,6 +543,7 @@ export async function runExec(config: Config): Promise { ? { shellEnv: localSettingsForMode.env } : {}), getBlobWriter: () => currentStorage?.writeBlob, + getEvidenceArchive: () => evidenceArchiveHolder.current, getContextDir: () => workdir, // Background run_shell completions re-enter the reactor on a later turn. onBackgroundShellExit: (exit) => { @@ -706,6 +709,7 @@ export async function runExec(config: Config): Promise { currentAgent = agent; currentStorage = storage; }, + evidenceArchiveHolder, }); const workflowHost = new WorkflowHost({ diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 505a7bfe2..aa90ab2a4 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -21,6 +21,12 @@ export interface MCPTool { inputSchema: Record; annotations?: McpToolAnnotations; } +export interface MCPContentBlock { + type: string; + text?: string; + [key: string]: unknown; +} + export interface MCPClient { serverName: string; tools: MCPTool[]; @@ -29,8 +35,15 @@ export interface MCPClient { args: Record, signal: AbortSignal, ): Promise; + /** Validated content blocks before flattening — for post-policy archive capture. */ + callBlocks?( + toolName: string, + args: Record, + signal: AbortSignal, + ): Promise; close(): Promise; } + export type MCPConnectResult = | { ok: true; client: MCPClient } | { ok: false; serverName: string; error: string }; @@ -69,6 +82,23 @@ export function unwrapToolContent(content: unknown): string { .join("\n"); } +export function validateMcpContentBlocks(content: unknown): MCPContentBlock[] { + if (!Array.isArray(content)) return []; + const out: MCPContentBlock[] = []; + for (const block of content) { + if (block === null || typeof block !== "object") continue; + const type = (block as { type?: unknown }).type; + if (typeof type !== "string") continue; + const copy: MCPContentBlock = { type }; + for (const [key, value] of Object.entries(block)) { + if (key === "type") continue; + copy[key] = value; + } + out.push(copy); + } + return out; +} + interface HTTPAuthContext { url: URL; authProvider: CorbitsOAuthProvider; @@ -515,6 +545,13 @@ async function finishClient( return { serverName, tools, + async callBlocks(toolName, args, signal) { + const context = authContext === undefined ? undefined : { ...authContext, signal }; + const result = await withHTTPAuthorizationRecovery(context, () => + client.callTool({ name: toolName, arguments: args }, undefined, { signal }), + ); + return validateMcpContentBlocks(result.content); + }, async call(toolName, args, signal) { const result = await withHTTPAuthorizationRecovery( authContext, @@ -524,7 +561,7 @@ async function finishClient( }), signal, ); - return unwrapToolContent(result.content); + return unwrapToolContent(validateMcpContentBlocks(result.content)); }, async close() { closeLifecycle?.(); diff --git a/src/mcp/plugin.test.ts b/src/mcp/plugin.test.ts index e88e8ad5c..16497108f 100644 --- a/src/mcp/plugin.test.ts +++ b/src/mcp/plugin.test.ts @@ -21,6 +21,7 @@ function fakeClient(reply: string): MCPClient { }, ], call: async () => reply, + callBlocks: async () => [{ type: "text", text: reply }], close: async () => undefined, }; } diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts index 0e145c699..cb1fa8e8b 100644 --- a/src/mcp/plugin.ts +++ b/src/mcp/plugin.ts @@ -2,18 +2,43 @@ import type { AgentTool } from "@intx/agent"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; import type { PermissionGate } from "../permission/gate.js"; import { gateAgentTools } from "../plugins/permission-plugin.js"; -import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js"; +import { + scrubSecretShapedContent, + scrubSecretShapedValue, +} from "../plugins/tool-result-secret-scrub.js"; import { truncateToolResultContent, type SpillBlobWriter, } from "../plugins/result-truncation-plugin.js"; -import type { MCPClient } from "./client.js"; +import type { CompactionArchive } from "../session/compaction-archive.js"; +import type { MCPClient, MCPContentBlock } from "./client.js"; import { mcpToolName } from "./tool-name.js"; +import { unwrapToolContent } from "./client.js"; export interface McpSpillOptions { getBlobWriter?: () => SpillBlobWriter | undefined; getContextDir?: () => string | undefined; excludeToolNames?: readonly string[]; + /** Primary-only evidence archive; workers omit this getter. */ + getEvidenceArchive?: () => CompactionArchive | undefined; +} + +function applyPolicyToBlocks(blocks: MCPContentBlock[]): MCPContentBlock[] { + return blocks.map((block) => { + const next = { ...block }; + if (typeof next.text === "string") { + next.text = scrubSecretShapedContent(next.text); + } + for (const [key, value] of Object.entries(next)) { + if (key === "type" || key === "text") continue; + if (typeof value === "string") { + next[key] = scrubSecretShapedContent(value); + } else if (value !== null && typeof value === "object") { + next[key] = scrubSecretShapedValue(value); + } + } + return next; + }); } // MCP results never reach the posix runner, so the secret-scrub and truncation @@ -35,7 +60,7 @@ export function mcpClientTools( client: MCPClient, spillOptions: McpSpillOptions = {}, ): AgentTool[] { - const { getBlobWriter, getContextDir, excludeToolNames = [] } = spillOptions; + const { getBlobWriter, getContextDir, excludeToolNames = [], getEvidenceArchive } = spillOptions; const excluded = new Set(excludeToolNames); return client.tools @@ -52,7 +77,30 @@ export function mcpClientTools( signal: AbortSignal, ): Promise => { try { - const content = await client.call(tool.name, call.arguments, signal); + const rawBlocks = + typeof client.callBlocks === "function" + ? await client.callBlocks(tool.name, call.arguments, signal) + : [ + { + type: "text", + text: await client.call(tool.name, call.arguments, signal), + } satisfies MCPContentBlock, + ]; + const authorizedBlocks = applyPolicyToBlocks(rawBlocks); + const archive = getEvidenceArchive?.(); + if (archive !== undefined) { + try { + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: { blocks: authorizedBlocks }, + callId: call.id, + provenance: "mcp:post-policy-pre-flatten", + }); + } catch { + // Archive write must not fail a successful tool result. + } + } + const flattened = unwrapToolContent(authorizedBlocks); const writeBlob = getBlobWriter?.(); const contextDir = getContextDir?.(); const spill = @@ -63,14 +111,31 @@ export function mcpClientTools( ...(contextDir !== undefined ? { contextDir } : {}), } : undefined; - return { - callId: call.id, - content: await sanitizeMcpResultContent(content, spill), - }; + const content = await truncateToolResultContent( + flattened, + undefined, + spill, + ); + return { callId: call.id, content }; } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const scrubbed = scrubSecretShapedContent(message); + const archive = getEvidenceArchive?.(); + if (archive !== undefined) { + try { + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: scrubbed, + callId: call.id, + provenance: "mcp:error", + }); + } catch { + // Archive write must not fail a successful tool result. + } + } return { callId: call.id, - content: err instanceof Error ? err.message : String(err), + content: scrubbed, isError: true, }; } diff --git a/src/plugins/result-truncation-plugin.ts b/src/plugins/result-truncation-plugin.ts index 6362647da..74ac79973 100644 --- a/src/plugins/result-truncation-plugin.ts +++ b/src/plugins/result-truncation-plugin.ts @@ -8,6 +8,7 @@ import { type MaterializedToolResult, } from "./tool-result-materialize.js"; import { scrubSecretShapedContent } from "./tool-result-secret-scrub.js"; +import { hashAuthorizedBytes, type CompactionArchive } from "../session/compaction-archive.js"; // Characters, not tokens — conversion ratio is roughly 4 chars/token. // Match the reactor's default size-cap (vendor/intx-inference assembly.ts) so @@ -216,6 +217,23 @@ export interface ResultTruncationPluginOptions { // Live getter for the absolute session context dir, re-read like // getBlobWriter so rotation picks up the new path for the notice. getContextDir?: () => string | undefined; + /** Primary-only evidence archive; workers omit this getter. */ + getEvidenceArchive?: () => CompactionArchive | undefined; +} + +async function archiveAuthorizedResult( + archive: CompactionArchive | undefined, + callId: string, + content: string | Record, + isError: boolean | undefined, +): Promise { + if (archive === undefined) return; + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: content, + callId, + provenance: isError === true ? "posix:error" : "posix:post-policy-pre-truncation", + }); } function spillOptionsForCall( @@ -271,6 +289,59 @@ export async function applyToolResultTruncation( return result; } +async function archiveThenTruncate( + result: ToolResult, + callId: string, + options: ResultTruncationPluginOptions, +): Promise { + const archive = options.getEvidenceArchive?.(); + try { + if (typeof result.content === "string") { + await archiveAuthorizedResult( + archive, + callId, + result.content, + result.isError, + ); + } else if (result.content !== null && typeof result.content === "object") { + await archiveAuthorizedResult( + archive, + callId, + result.content as Record, + result.isError, + ); + } + } catch { + // Archive write must not fail a successful tool result. + } + + const before = result.content; + const truncated = await applyToolResultTruncation( + result, + spillOptionsForCall(callId, options), + ); + if ( + archive !== undefined && + typeof before === "string" && + typeof truncated.content === "string" && + truncated.content !== before && + before.length > MAX_RESULT_CHARS + ) { + try { + await archive.recordExistingBlobReference({ + kind: "overflow_blob", + blobKey: spillBlobKey(callId), + contentHash: hashAuthorizedBytes(new TextEncoder().encode(before)), + callId, + provenance: "result-truncation:full", + }); + } catch { + // Archive write must not fail a successful tool result. + } + } + return truncated; +} + /** * Wrap an AgentTool so its result hits {@link applyToolResultTruncation}. * `kind: "string"` handlers are lifted to `kind: "full"` so the spill can use @@ -286,10 +357,7 @@ export function wrapAgentToolResultTruncation( return { ...tool, handler: async (call: ToolCall, signal: AbortSignal) => - applyToolResultTruncation( - await inner(call, signal), - spillOptionsForCall(call.id, options), - ), + archiveThenTruncate(await inner(call, signal), call.id, options), }; } const inner = tool.handler; @@ -297,9 +365,10 @@ export function wrapAgentToolResultTruncation( kind: "full", definition: tool.definition, handler: async (call: ToolCall, signal: AbortSignal) => - applyToolResultTruncation( + archiveThenTruncate( { callId: call.id, content: await inner(call.arguments, signal) }, - spillOptionsForCall(call.id, options), + call.id, + options, ), }; } @@ -317,10 +386,7 @@ export function resultTruncationPlugin( return { middleware: (next) => async (call, signal) => { const result = await next(call, signal); - return applyToolResultTruncation( - result, - spillOptionsForCall(call.id, options), - ); + return archiveThenTruncate(result, call.id, options); }, }; -} +} \ No newline at end of file diff --git a/src/plugins/tool-result-secret-scrub-plugin.ts b/src/plugins/tool-result-secret-scrub-plugin.ts index a45d6eddd..648e7eb19 100644 --- a/src/plugins/tool-result-secret-scrub-plugin.ts +++ b/src/plugins/tool-result-secret-scrub-plugin.ts @@ -1,5 +1,6 @@ import type { ToolPlugin } from "@intx/tools-posix"; -import { scrubSecretShapedContent } from "./tool-result-secret-scrub.js"; +import type { ToolResult } from "@intx/types/runtime"; +import { scrubSecretShapedContent, scrubSecretShapedValue } from "./tool-result-secret-scrub.js"; // Posix-middleware scrub path only. search_agents is listed for future unified // scrubbing if it ever rides this middleware; live scrub for profile bodies is in @@ -17,7 +18,8 @@ export function toolResultSecretScrubPlugin(): ToolPlugin { return { middleware: (next) => async (call, signal) => { const result = await next(call, signal); - if (!SCRUBBABLE_TOOLS.has(call.name) || result.isError) return result; + // Include error results: authorized failure evidence must still be scrubbed. + if (!SCRUBBABLE_TOOLS.has(call.name)) return result; if (typeof result.content === "string") { const scrubbed = scrubSecretShapedContent(result.content); @@ -26,13 +28,22 @@ export function toolResultSecretScrubPlugin(): ToolPlugin { } if (result.content !== null && typeof result.content === "object") { - const serialized = JSON.stringify(result.content); - const scrubbed = scrubSecretShapedContent(serialized); - if (scrubbed === serialized) return result; - return { ...result, content: scrubbed }; + const scrubbed = scrubSecretShapedValue(result.content); + if (scrubbed === result.content) return result; + // Keep a validated object shape — never coerce scrubbed Records to a + // JSON string (that broke downstream structure-aware consumers). + if (isRecord(scrubbed)) { + const nextResult: ToolResult = { ...result, content: scrubbed }; + return nextResult; + } + return result; } return result; }, }; } + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/plugins/tool-result-secret-scrub.test.ts b/src/plugins/tool-result-secret-scrub.test.ts index e7eeced58..f664b331e 100644 --- a/src/plugins/tool-result-secret-scrub.test.ts +++ b/src/plugins/tool-result-secret-scrub.test.ts @@ -1,8 +1,8 @@ -import { defined } from "../../tests/helpers/defined.js"; import { describe, test, expect } from "bun:test"; import { CREDENTIAL_REDACTION, scrubSecretShapedContent, + scrubSecretShapedValue, } from "./tool-result-secret-scrub.js"; import { toolResultSecretScrubPlugin } from "./tool-result-secret-scrub-plugin.js"; import { resultTruncationPlugin } from "./result-truncation-plugin.js"; @@ -10,16 +10,16 @@ import type { ToolCall, ToolResult } from "@intx/types/runtime"; describe("scrubSecretShapedContent", () => { test("redacts grep-surfaced .env assignment", () => { - const text = "./app/.env:3:API_KEY=sk-live-abc123xyz789012345678"; + const text = "./app/.env:3:API_KEY=sk-live-abc123"; const out = scrubSecretShapedContent(text); expect(out).toContain(`API_KEY=${CREDENTIAL_REDACTION}`); expect(out).not.toContain("sk-live-abc123"); }); test("redacts PEM block in shell output", () => { - const pem = `-----BEGIN RSA PRIVATE KEY----- + const pem = `-----BEGIN PRIVATE KEY----- MIIEpAIBAAKCAQEA7 ------END RSA PRIVATE KEY-----`; +-----END PRIVATE KEY-----`; const text = `wrote key:\n${pem}\n`; const out = scrubSecretShapedContent(text); expect(out).toBe(`wrote key:\n${CREDENTIAL_REDACTION}\n`); @@ -27,14 +27,11 @@ MIIEpAIBAAKCAQEA7 }); test("is idempotent for redacted query parameters", () => { - const text = - "GET https://provider.invalid/v1?api_key=plain-value&model=test"; + const text = "GET https://provider.invalid/v1?api_key=sk-live-secret-value-here&model=test"; const once = scrubSecretShapedContent(text); expect(scrubSecretShapedContent(once)).toBe(once); - expect(once).toBe( - `GET https://provider.invalid/v1?api_key=${CREDENTIAL_REDACTION}&model=test`, - ); + expect(once).toBe(`GET https://provider.invalid/v1?api_key=${CREDENTIAL_REDACTION}&model=test`); }); test("passes benign documentation mentioning API keys", () => { @@ -44,19 +41,36 @@ MIIEpAIBAAKCAQEA7 }); }); +describe("scrubSecretShapedValue", () => { + test("keeps object structure while scrubbing string leaves", () => { + const input = { + stdout: "token sk-abcdefghijklmnopqrstuvwxyz012345", + code: 1, + nested: { api_key: "sk-abcdefghijklmnopqrstuvwxyz012345" }, + }; + const out = scrubSecretShapedValue(input); + expect(out).toEqual({ + stdout: `token ${CREDENTIAL_REDACTION}`, + code: 1, + nested: { api_key: CREDENTIAL_REDACTION }, + }); + expect(typeof out).toBe("object"); + expect(input.nested.api_key).toBe("sk-abcdefghijklmnopqrstuvwxyz012345"); + }); +}); + describe("toolResultSecretScrubPlugin", () => { const next = - (content: string) => + (content: ToolResult["content"], isError = false) => async (call: ToolCall): Promise => ({ callId: call.id, content, + ...(isError ? { isError: true } : {}), }); test("scrubs grep tool results", async () => { const plugin = toolResultSecretScrubPlugin(); - const handler = defined(plugin.middleware)( - next("secrets/.env:1:TOKEN=supersecretvalue"), - ); + const handler = plugin.middleware!(next("secrets/.env:1:TOKEN=supersecretvalue")); const result = await handler( { id: "c1", name: "grep", arguments: { pattern: "TOKEN" } }, new AbortController().signal, @@ -65,9 +79,32 @@ describe("toolResultSecretScrubPlugin", () => { expect(result.content).not.toContain("supersecretvalue"); }); + test("scrubs error results without stringifying object content", async () => { + const plugin = toolResultSecretScrubPlugin(); + const handler = plugin.middleware!( + next( + { + message: "failed with sk-abcdefghijklmnopqrstuvwxyz012345", + code: 7, + }, + true, + ), + ); + const result = await handler( + { id: "c-err", name: "run_shell", arguments: { command: "exit 7" } }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(typeof result.content).toBe("object"); + expect(result.content).toEqual({ + message: `failed with ${CREDENTIAL_REDACTION}`, + code: 7, + }); + }); + test("preserves query redaction through the long-result middleware chain", async () => { const content = - "GET https://provider.invalid/v1?api_key=plain-value&model=test\n" + + "GET https://provider.invalid/v1?api_key=sk-live-secret-value-here&model=test\n" + "x".repeat(11_000); const scrub = toolResultSecretScrubPlugin(); const truncate = resultTruncationPlugin(); @@ -81,14 +118,9 @@ describe("toolResultSecretScrubPlugin", () => { new AbortController().signal, ); - if (typeof result.content !== "string") - throw new Error("expected text tool result"); - expect(result.content).toContain( - `api_key=${CREDENTIAL_REDACTION}&model=test`, - ); - expect( - result.content.match(/\[redacted: looks like a credential\]/g), - ).toHaveLength(1); + if (typeof result.content !== "string") throw new Error("expected text tool result"); + expect(result.content).toContain(`api_key=${CREDENTIAL_REDACTION}&model=test`); + expect(result.content.match(/\[redacted: looks like a credential\]/g)).toHaveLength(1); }); // search_agents is listed in SCRUBBABLE_TOOLS for future unified scrubbing, but @@ -99,14 +131,14 @@ describe("toolResultSecretScrubPlugin", () => { const plugin = toolResultSecretScrubPlugin(); const body = "Matching agent profiles:\n\n### leaky\n\nSystem prompt / body:\n" + - "Use API_KEY=sk-live-abc123xyz789012345678 when calling the provider."; - const handler = defined(plugin.middleware)(next(body)); + "Use token sk-abcdefghijklmnopqrstuvwxyz012345 when calling the provider."; + const handler = plugin.middleware!(next(body)); const result = await handler( { id: "c2", name: "search_agents", arguments: { query: "leaky" } }, new AbortController().signal, ); expect(result.content).toContain(CREDENTIAL_REDACTION); - expect(result.content).not.toContain("sk-live-abc123"); + expect(result.content).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); expect(result.content).toContain("### leaky"); }); }); diff --git a/src/plugins/tool-result-secret-scrub.ts b/src/plugins/tool-result-secret-scrub.ts index fc7d327b4..272987cfc 100644 --- a/src/plugins/tool-result-secret-scrub.ts +++ b/src/plugins/tool-result-secret-scrub.ts @@ -70,3 +70,21 @@ export function scrubSecretShapedContent(text: string): string { return result; } + +/** + * Structure-preserving scrub for validated JSON-shaped tool results. String + * leaves are scrubbed in place; objects/arrays keep their shape. Never + * stringifies a Record into the result content. + */ +export function scrubSecretShapedValue(value: unknown): unknown { + if (typeof value === "string") return scrubSecretShapedContent(value); + if (Array.isArray(value)) return value.map((item) => scrubSecretShapedValue(item)); + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + out[key] = scrubSecretShapedValue(child); + } + return out; + } + return value; +} diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 1f0fea4b0..6282af0f3 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -54,6 +54,14 @@ import type { AgentToolset } from "../agent/tools.js"; import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; import { createSessionStores } from "./optimized-context-store.js"; import { createAttachmentRehydrateTransform } from "./attachment-store.js"; +import { + applyRecordingPolicyToText, + createCompactionArchive, + createPrimaryDeliveryAdmission, + wrapAuthorizeWithEvidenceArchive, + type CompactionArchive, +} from "./compaction-archive.js"; +import path from "node:path"; import { loadProjectTrust, isPluginTrusted, @@ -388,6 +396,11 @@ export interface ChatAgentWiring { getCompactor: () => Compactor; /** Assigns the runner's live agent/storage holders; keeps call sites unchanged. */ onBuilt: (agent: Agent, storage: ContextStore) => void; + /** + * Primary-only evidence archive holder. assembleChatAgent writes the live + * archive here on each build; workers never pass a holder. + */ + evidenceArchiveHolder?: { current?: CompactionArchive }; } export interface AssembledChatAgent { @@ -456,10 +469,44 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { const buildAgent = async (): Promise => { const workdir = wiring.getWorkdir(); const { storage, audit } = await createSessionStores(workdir); + // workdir is …//context for primary sessions. + const sessionId = path.basename(path.dirname(workdir)); + const archive = createCompactionArchive({ + sessionId, + contextDir: workdir, + writeBlob: (key, bytes, contentType) => + storage.writeBlob(key, bytes, contentType), + readBlob: (key) => storage.readBlob(key), + }); + if (wiring.evidenceArchiveHolder !== undefined) { + wiring.evidenceArchiveHolder.current = archive; + } + + const storageWithArchive: ContextStore = { + ...storage, + async writeResponse(turn, signal) { + const content = turn.content.map((block) => { + if (block.type !== "text") return block; + const text = applyRecordingPolicyToText(block.text); + return text === block.text ? block : { ...block, text }; + }); + const admitted = { ...turn, content }; + for (const block of admitted.content) { + if (block.type === "text" && block.text.length > 0) { + await archive.recordAuthorizedPayload({ + kind: "assistant_text", + payload: block.text, + provenance: "writeResponse:post-policy", + }); + } + } + return storage.writeResponse(admitted, signal); + }, + }; const agent = await createAgentWithLiveToolDispatch(agentDef, { sources: wiring.getSources(), defaultSource: wiring.getDefaultSource(), - storage, + storage: storageWithArchive, workdir, // contextTransforms ride deps: the published @intx/agent forwards deps // into reactor assembly verbatim, and the vendored assembly picks the @@ -467,14 +514,17 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { deps: { ...wiring.inferenceDeps, contextTransforms: [ - createAttachmentRehydrateTransform((key) => storage.readBlob(key)), + createAttachmentRehydrateTransform((key) => + storageWithArchive.readBlob(key), + ), ], }, audit, sessionId: wiring.getSessionId(), // Gate-backed reactor authorization: ask-tier calls suspend via the // vendored approval-suspend primitive instead of parking on a closure. - authorize: wiring.authorize, + // Finalize evidence admission after guards resolve; never scrub exec args. + authorize: wrapAuthorizeWithEvidenceArchive(wiring.authorize, () => archive), directors: createDirectorRegistry({ factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, @@ -483,8 +533,9 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { "pruning-compactor": wiring.getCompactor(), }, }); - wiring.onBuilt(agent, storage); - return agent; + const admittedAgent = createPrimaryDeliveryAdmission(agent, archive); + wiring.onBuilt(admittedAgent, storageWithArchive); + return admittedAgent; }; return { directorHolder, buildAgent }; diff --git a/src/session/attachment-store.ts b/src/session/attachment-store.ts index 679a695ef..399b79b6b 100644 --- a/src/session/attachment-store.ts +++ b/src/session/attachment-store.ts @@ -12,6 +12,7 @@ import { formatAgedImageMarker, parseAgedImageMarker, } from "./attachment-uri.js"; +import { hashAuthorizedBytes, type CompactionArchive } from "./compaction-archive.js"; export interface AgeImageResult { turn: ConversationTurn; @@ -22,8 +23,14 @@ export interface AgeImageResult { * Replace base64 image blocks with a rehydratable attachment marker and emit * blobs the reactor will write via ContextStore.writeBlob. */ +export interface AgeImageOptions { + /** When set, record verified attachment blob provenance into the evidence archive. */ + archive?: CompactionArchive; +} + export async function ageImageBlocks( turn: ConversationTurn, + options: AgeImageOptions = {}, ): Promise { if (!turn.content.some((b) => b.type === "image")) { return { turn, blobs: [] }; @@ -39,6 +46,17 @@ export async function ageImageBlocks( } if (block.source.kind !== "base64") { // Already a reference or URL — leave as-is (not a base64 resend risk). + if (options.archive !== undefined) { + await options.archive.recordAuthorizedPayload({ + kind: "attachment", + payload: { + status: block.source.kind === "url" ? "unsupported-url" : "reference", + sourceKind: block.source.kind, + }, + provenance: "attachment-age:non-base64", + gap: block.source.kind === "url", + }); + } content.push(block); continue; } @@ -53,6 +71,14 @@ export async function ageImageBlocks( bytes, contentType: block.source.mimeType, }); + if (options.archive !== undefined) { + await options.archive.recordExistingBlobReference({ + kind: "attachment", + blobKey: id, + contentHash: hashAuthorizedBytes(bytes), + provenance: "attachment-age:base64", + }); + } content.push({ type: "text", text: formatAgedImageMarker({ uri, mimeType: block.source.mimeType }), diff --git a/src/session/compaction-archive-schema.ts b/src/session/compaction-archive-schema.ts new file mode 100644 index 000000000..7f4215489 --- /dev/null +++ b/src/session/compaction-archive-schema.ts @@ -0,0 +1,39 @@ +import { type } from "arktype"; + +export const ArchiveKind = type( + "'user_message' | 'assistant_text' | 'tool_args' | 'tool_result' | 'overflow_blob' | 'attachment' | 'tool_failure'", +); +export type ArchiveKind = typeof ArchiveKind.infer; + +export const ToolRecordingLifecycle = type("'requested' | 'suspended' | 'denied' | 'admitted'"); +export type ToolRecordingLifecycle = typeof ToolRecordingLifecycle.infer; + +export const ArchiveOccurrence = type({ + occurrenceId: "string", + sessionId: "string", + kind: ArchiveKind, + contentHash: "string", + blobKey: "string", + recordedAt: "number", + "callId?": "string", + "lifecycle?": ToolRecordingLifecycle, + "provenance?": "string", + "gap?": "boolean", +}); +export type ArchiveOccurrence = typeof ArchiveOccurrence.infer; + +export const CompletenessCertificate = type({ + status: "'complete' | 'incomplete'", + expectedOccurrenceIds: "string[]", + presentOccurrenceIds: "string[]", + missingOccurrenceIds: "string[]", + unverifiedBlobIds: "string[]", + certifiedAt: "number", +}); +export type CompletenessCertificate = typeof CompletenessCertificate.infer; + +export const HistoricalImportResult = type({ + importedOccurrenceIds: "string[]", + gapOccurrenceIds: "string[]", +}); +export type HistoricalImportResult = typeof HistoricalImportResult.infer; diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts new file mode 100644 index 000000000..433b171f3 --- /dev/null +++ b/src/session/compaction-archive.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { InboundMessage } from "@intx/types/runtime"; +import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js"; +import { + admitPrimaryInboundMessage, + applyRecordingPolicyToText, + applyRecordingPolicyToValue, + authorizedToolArgsRepresentation, + createCompactionArchive, + createPrimaryDeliveryAdmission, + hashAuthorizedBytes, + isControlOrEmptyInbound, +} from "./compaction-archive.js"; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "compaction-archive-")); +} + +function inbound(partial: Partial & { content?: string }): InboundMessage { + return { + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: "user@local", + to: ["agent@local"], + date: new Date().toISOString(), + messageId: `<${crypto.randomUUID()}@local>`, + interchangeType: "conversation.message", + ...partial.headers, + }, + flags: [], + signatureStatus: "missing", + content: partial.content ?? "", + ...(partial.attachments !== undefined ? { attachments: partial.attachments } : {}), + ...(partial.ref !== undefined ? { ref: partial.ref } : {}), + }; +} + +describe("recording policy", () => { + test("scrubs secret-shaped text without inventing a second policy", () => { + const text = "token sk-abcdefghijklmnopqrstuvwxyz012345"; + const out = applyRecordingPolicyToText(text); + expect(out).toContain(CREDENTIAL_REDACTION); + expect(out).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + }); + + test("structure-preserving redact keeps object shape", () => { + const input = { + ok: true, + nested: { api_key: "sk-abcdefghijklmnopqrstuvwxyz012345", count: 2 }, + list: ["safe", "Bearer abcdefghijklmnopqrstuvwxyz012345"], + }; + const out = applyRecordingPolicyToValue(input); + expect(out).toEqual({ + ok: true, + nested: { api_key: CREDENTIAL_REDACTION, count: 2 }, + list: ["safe", CREDENTIAL_REDACTION], + }); + expect(input.nested.api_key).toBe("sk-abcdefghijklmnopqrstuvwxyz012345"); + }); + + test("authorized tool args representation does not mutate execution args", () => { + const args = { + command: "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' https://x", + }; + const recorded = authorizedToolArgsRepresentation(args); + expect(recorded.command).toContain(CREDENTIAL_REDACTION); + expect(args.command).toContain("Bearer abcdefghijklmnopqrstuvwxyz012345"); + }); +}); + +describe("primary message admission", () => { + test("admits scrubbed text that becomes the history representation", () => { + const message = inbound({ + content: "use sk-abcdefghijklmnopqrstuvwxyz012345 carefully", + }); + const admitted = admitPrimaryInboundMessage(message); + expect(admitted.content).toContain(CREDENTIAL_REDACTION); + expect(admitted.content).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + expect(message.content).toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + }); + + test("preserves empty continuation and control payloads", () => { + const empty = inbound({ content: "" }); + expect(isControlOrEmptyInbound(empty)).toBe(true); + expect(admitPrimaryInboundMessage(empty).content).toBe(""); + + const approval = inbound({ + content: JSON.stringify({ outcome: "approved" }), + ref: { uid: 0, mailbox: "approval" }, + headers: { + from: "approval@local", + to: ["agent@local"], + date: new Date().toISOString(), + messageId: "approval-corr-1", + interchangeCorrelationId: "corr-1", + }, + }); + expect(isControlOrEmptyInbound(approval)).toBe(true); + const admitted = admitPrimaryInboundMessage(approval); + expect(admitted.content).toBe(approval.content); + expect(admitted.headers.interchangeCorrelationId).toBe("corr-1"); + }); + + test("does not scrub binary attachment payloads", () => { + const bytes = new TextEncoder().encode("sk-abcdefghijklmnopqrstuvwxyz012345"); + const message = inbound({ + content: "see image", + attachments: [{ name: "a.png", contentType: "image/png", data: bytes }], + }); + const admitted = admitPrimaryInboundMessage(message); + expect(admitted.attachments?.[0]?.data).toEqual(bytes); + expect(admitted.content).toBe("see image"); + }); + + test("delivery wrapper admits before deliver/send", async () => { + const delivered: InboundMessage[] = []; + const agent = { + deliver(message: InboundMessage) { + delivered.push(message); + }, + async send(message: InboundMessage) { + delivered.push(message); + return { ok: true as const }; + }, + }; + const wrapped = createPrimaryDeliveryAdmission(agent); + wrapped.deliver(inbound({ content: "leak sk-abcdefghijklmnopqrstuvwxyz012345" })); + await wrapped.send(inbound({ content: "also sk-abcdefghijklmnopqrstuvwxyz012345" })); + expect(delivered).toHaveLength(2); + expect(delivered[0]!.content).toContain(CREDENTIAL_REDACTION); + expect(delivered[1]!.content).toContain(CREDENTIAL_REDACTION); + }); +}); + +describe("compaction archive storage", () => { + test("round-trips exact authorized payloads and refuses incomplete certificates", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-a", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing blob ${key}`); + return bytes; + }, + }); + + const authorized = applyRecordingPolicyToText( + "constraint west with sk-abcdefghijklmnopqrstuvwxyz012345", + ); + const userOcc = await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: authorized, + }); + const assistantOcc = await archive.recordAuthorizedPayload({ + kind: "assistant_text", + payload: applyRecordingPolicyToText("ack west"), + }); + + const oversized = "decisive-fact-42\n" + "x".repeat(12_000); + const scrubbedOversized = applyRecordingPolicyToText(oversized); + const resultOcc = await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: scrubbedOversized, + callId: "call-1", + }); + + const loaded = await archive.readAuthorizedPayload(userOcc.occurrenceId); + expect(loaded).toBe(authorized); + expect(loaded).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + + const complete = await archive.certifyRange([ + userOcc.occurrenceId, + assistantOcc.occurrenceId, + resultOcc.occurrenceId, + ]); + expect(complete.status).toBe("complete"); + expect(complete.missingOccurrenceIds).toEqual([]); + expect(complete.unverifiedBlobIds).toEqual([]); + + const incomplete = await archive.certifyRange([ + userOcc.occurrenceId, + "occ-missing-historical", + resultOcc.occurrenceId, + ]); + expect(incomplete.status).toBe("incomplete"); + expect(incomplete.missingOccurrenceIds).toEqual(["occ-missing-historical"]); + }); + + test("isolates callIds across sessions and verifies blob hashes", async () => { + const dir = tempDir(); + const blobs = new Map(); + + const make = (sessionId: string) => + createCompactionArchive({ + sessionId, + contextDir: path.join(dir, sessionId), + writeBlob: async (key, bytes) => { + blobs.set(`${sessionId}:${key}`, { session: sessionId, bytes }); + }, + readBlob: async (key) => { + const hit = blobs.get(`${sessionId}:${key}`); + if (hit === undefined) throw new Error(`missing ${sessionId}:${key}`); + return hit.bytes; + }, + }); + + const a = make("sess-a"); + const b = make("sess-b"); + const occA = await a.recordAuthorizedPayload({ + kind: "tool_result", + payload: "from-a", + callId: "shared-call", + }); + const occB = await b.recordAuthorizedPayload({ + kind: "tool_result", + payload: "from-b", + callId: "shared-call", + }); + expect(occA.blobKey).not.toBe(occB.blobKey); + expect(occA.occurrenceId).not.toBe(occB.occurrenceId); + expect(await a.readAuthorizedPayload(occA.occurrenceId)).toBe("from-a"); + expect(await b.readAuthorizedPayload(occB.occurrenceId)).toBe("from-b"); + + // Corrupt blob bytes after write — certificate must fail verification. + const stored = blobs.get(`sess-a:${occA.blobKey}`); + if (stored === undefined) throw new Error("expected stored blob"); + stored.bytes = new TextEncoder().encode("tampered"); + const cert = await a.certifyRange([occA.occurrenceId]); + expect(cert.status).toBe("incomplete"); + expect(cert.unverifiedBlobIds).toEqual([occA.occurrenceId]); + }); + + test("tool lifecycle records denied failure evidence without forbidden args", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-deny", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + + const forbiddenArgs = { + command: "cat /etc/shadow", + token: "sk-abcdefghijklmnopqrstuvwxyz012345", + }; + await archive.noteToolRequested({ + id: "deny-1", + name: "run_shell", + arguments: forbiddenArgs, + }); + const denied = await archive.finalizeToolRecording({ + callId: "deny-1", + name: "run_shell", + lifecycle: "denied", + executionArgs: forbiddenArgs, + }); + expect(denied.lifecycle).toBe("denied"); + expect(denied.kind).toBe("tool_failure"); + const payload = JSON.parse(await archive.readAuthorizedPayload(denied.occurrenceId)); + expect(payload.lifecycle).toBe("denied"); + expect(payload.arguments.token).toBe(CREDENTIAL_REDACTION); + expect(JSON.stringify(payload)).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + // Execution args object remains untouched for the runner. + expect(forbiddenArgs.token).toBe("sk-abcdefghijklmnopqrstuvwxyz012345"); + }); + + test("suspended then admitted finalizes after guards resolve", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-ask", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + const args = { path: "README.md" }; + await archive.noteToolRequested({ id: "ask-1", name: "read_file", arguments: args }); + const suspended = await archive.finalizeToolRecording({ + callId: "ask-1", + name: "read_file", + lifecycle: "suspended", + executionArgs: args, + }); + expect(suspended.lifecycle).toBe("suspended"); + const admitted = await archive.finalizeToolRecording({ + callId: "ask-1", + name: "read_file", + lifecycle: "admitted", + executionArgs: args, + }); + expect(admitted.lifecycle).toBe("admitted"); + expect(admitted.kind).toBe("tool_args"); + }); + + test("reuses verified overflow/attachment blobs and marks missing explicitly", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-blob", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + + const full = new TextEncoder().encode("full-authorized-result"); + const hash = hashAuthorizedBytes(full); + blobs.set("call-9:full", full); + const overflow = await archive.recordExistingBlobReference({ + kind: "overflow_blob", + blobKey: "call-9:full", + contentHash: hash, + callId: "call-9", + provenance: "result-truncation:full", + }); + expect(overflow.contentHash).toBe(hash); + + const missing = await archive.recordExistingBlobReference({ + kind: "attachment", + blobKey: "img-missing", + contentHash: createHash("sha256").update("nope").digest("hex"), + provenance: "attachment-store", + }); + expect(missing.gap).toBe(true); + + const cert = await archive.certifyRange([overflow.occurrenceId, missing.occurrenceId]); + expect(cert.status).toBe("incomplete"); + expect(cert.unverifiedBlobIds).toContain(missing.occurrenceId); + }); + + test("bounded historical import records explicit gaps", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-import", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + + const result = await archive.importHistoricalEvidence([ + { kind: "user_message", payload: "present fact", available: true }, + { kind: "tool_result", payload: "lost", available: false, callId: "old-1" }, + ]); + expect(result.importedOccurrenceIds).toHaveLength(1); + expect(result.gapOccurrenceIds).toHaveLength(1); + const gap = (await archive.listOccurrences()).find((o) => o.gap === true); + expect(gap?.kind).toBe("tool_result"); + const cert = await archive.certifyRange([ + ...result.importedOccurrenceIds, + ...result.gapOccurrenceIds, + ]); + expect(cert.status).toBe("incomplete"); + }); + + test("awaited recording writes — certify sees committed occurrences", async () => { + const dir = tempDir(); + let writes = 0; + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-await", + contextDir: dir, + writeBlob: async (key, bytes) => { + await Bun.sleep(5); + writes += 1; + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + const occ = await archive.recordAuthorizedPayload({ + kind: "assistant_text", + payload: "done", + }); + expect(writes).toBe(1); + const cert = await archive.certifyRange([occ.occurrenceId]); + expect(cert.status).toBe("complete"); + }); +}); diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts new file mode 100644 index 000000000..175c1057c --- /dev/null +++ b/src/session/compaction-archive.ts @@ -0,0 +1,599 @@ +// Authorized evidence archive for primary-session compaction. +// +// Captures the exact post-policy representation that enters (or is about to +// enter) durable history — never raw secrets, never a divergent scrubbed copy +// while history stays raw. Storage is owned by the session context directory +// via ContextStore blobs plus an append-only occurrence index. Completeness is +// an explicit certificate over an expected occurrence range and verified blobs; +// readAt salvage is not a certificate. + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { type } from "arktype"; +import type { InboundMessage } from "@intx/types/runtime"; +import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js"; +import { + ArchiveOccurrence, + CompletenessCertificate, + HistoricalImportResult, + type ArchiveKind, + type ToolRecordingLifecycle, +} from "./compaction-archive-schema.js"; + +const INDEX_DIR = "evidence-archive"; +const INDEX_FILE = "index.jsonl"; + +export type ArchiveBlobWriter = ( + key: string, + bytes: Uint8Array, + contentType?: string, +) => Promise; + +export type ArchiveBlobReader = (key: string) => Promise; + +export interface CreateCompactionArchiveOpts { + sessionId: string; + contextDir: string; + writeBlob: ArchiveBlobWriter; + readBlob: ArchiveBlobReader; + now?: () => number; +} + +export interface RecordAuthorizedPayloadInput { + kind: ArchiveKind; + payload: string | Record | unknown; + callId?: string; + lifecycle?: ToolRecordingLifecycle; + provenance?: string; + gap?: boolean; +} + +export interface RecordExistingBlobInput { + kind: Extract; + blobKey: string; + contentHash: string; + callId?: string; + provenance?: string; +} + +export interface ToolCallRecordingInput { + id: string; + name: string; + arguments: Record; +} + +export interface FinalizeToolRecordingInput { + callId: string; + name: string; + lifecycle: ToolRecordingLifecycle; + executionArgs: Record; +} + +export interface HistoricalEvidenceItem { + kind: ArchiveKind; + payload?: string | Record; + available: boolean; + callId?: string; +} + +export interface CompactionArchive { + recordAuthorizedPayload(input: RecordAuthorizedPayloadInput): Promise; + recordExistingBlobReference(input: RecordExistingBlobInput): Promise; + noteToolRequested(call: ToolCallRecordingInput): Promise; + finalizeToolRecording(input: FinalizeToolRecordingInput): Promise; + readAuthorizedPayload(occurrenceId: string): Promise; + listOccurrences(): Promise; + certifyRange(expectedOccurrenceIds: readonly string[]): Promise; + importHistoricalEvidence( + items: readonly HistoricalEvidenceItem[], + ): Promise; + /** Drain recording writes started from sync deliver paths before certifying. */ + awaitPendingWrites(): Promise; +} + +function indexPath(contextDir: string): string { + return path.join(contextDir, INDEX_DIR, INDEX_FILE); +} + +export function hashAuthorizedBytes(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function applyRecordingPolicyToText(text: string): string { + return scrubSecretShapedContent(text); +} + +export function applyRecordingPolicyToValue(value: unknown): unknown { + if (typeof value === "string") return applyRecordingPolicyToText(value); + if (Array.isArray(value)) return value.map((item) => applyRecordingPolicyToValue(item)); + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (typeof child === "string" && isCredentialKeyedField(key)) { + out[key] = CREDENTIAL_VALUE_REDACTION(child); + } else { + out[key] = applyRecordingPolicyToValue(child); + } + } + return out; + } + return value; +} + +function isCredentialKeyedField(key: string): boolean { + return /^(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization)$/i.test( + key, + ); +} + +function CREDENTIAL_VALUE_REDACTION(value: string): string { + const scrubbed = applyRecordingPolicyToText(value); + // Keyed credential fields always redact even when the value shape is unfamiliar. + return scrubbed === value ? "[redacted: looks like a credential]" : scrubbed; +} + +export function authorizedToolArgsRepresentation( + args: Record, +): Record { + const clone = structuredClone(args); + return applyRecordingPolicyToValue(clone) as Record; +} + +export function isControlOrEmptyInbound(message: InboundMessage): boolean { + const content = message.content ?? ""; + const attachments = message.attachments ?? []; + if (content.length === 0 && attachments.length === 0) return true; + if (message.ref.mailbox === "approval" || message.ref.mailbox === "system") return true; + if (message.headers.interchangeCorrelationId !== undefined) { + // Correlated approval/control deliveries carry decision JSON; do not scrub. + if (message.headers.interchangeType !== "conversation.message") return true; + if (message.ref.mailbox === "approval") return true; + } + return false; +} + +/** + * Primary admission hook (Corbits-owned). Produces the canonical admitted + * representation that must enter history and the archive — same bytes, not a + * divergent scrubbed copy. Workers omit this hook. + */ +export function admitPrimaryInboundMessage(message: InboundMessage): InboundMessage { + if (isControlOrEmptyInbound(message)) return message; + const content = message.content ?? ""; + if (content.length === 0) return message; + const admittedContent = applyRecordingPolicyToText(content); + if (admittedContent === content) return message; + return { ...message, content: admittedContent }; +} + +async function archiveAdmittedInbound( + archive: CompactionArchive, + admitted: InboundMessage, +): Promise { + if (isControlOrEmptyInbound(admitted)) return; + const content = admitted.content ?? ""; + if (content.length > 0) { + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: content, + provenance: "primary-admission", + }); + } + for (const attachment of admitted.attachments ?? []) { + if (attachment.data === undefined) { + await archive.recordAuthorizedPayload({ + kind: "attachment", + payload: { + name: attachment.name, + contentType: attachment.contentType, + status: "missing", + }, + provenance: "primary-admission:attachment-missing", + gap: true, + }); + continue; + } + const bytes = + attachment.data instanceof Uint8Array + ? attachment.data + : new TextEncoder().encode(String(attachment.data)); + const contentHash = hashAuthorizedBytes(bytes); + // Binary attachments are not scrubbed; record hash + provenance only. + await archive.recordAuthorizedPayload({ + kind: "attachment", + payload: { + name: attachment.name, + contentType: attachment.contentType, + contentHash, + byteLength: bytes.byteLength, + }, + provenance: "primary-admission:attachment-meta", + }); + } +} + +export function createPrimaryDeliveryAdmission< + T extends { + deliver: (message: InboundMessage) => void; + send: (message: InboundMessage, ...rest: never[]) => unknown; + }, +>(agent: T, archive?: CompactionArchive): T { + return { + ...agent, + deliver(message: InboundMessage) { + const admitted = admitPrimaryInboundMessage(message); + agent.deliver(admitted); + if (archive !== undefined) { + // deliver() is sync; track the write so certify/awaitPendingWrites can wait. + trackPendingWrite(archive, archiveAdmittedInbound(archive, admitted)); + } + }, + send(message: InboundMessage, ...rest: never[]) { + const admitted = admitPrimaryInboundMessage(message); + if (archive === undefined) { + return agent.send(admitted, ...rest); + } + const run = async () => { + await archiveAdmittedInbound(archive, admitted); + return agent.send(admitted, ...rest); + }; + return run(); + }, + }; +} + +const pendingWrites = new WeakMap>>(); + +function trackPendingWrite(archive: object, write: Promise): void { + let set = pendingWrites.get(archive); + if (set === undefined) { + set = new Set(); + pendingWrites.set(archive, set); + } + const tracked = write.then( + () => undefined, + () => undefined, + ); + set.add(tracked); + void tracked.finally(() => set.delete(tracked)); +} + +export async function awaitArchivePendingWrites(archive: object): Promise { + const set = pendingWrites.get(archive); + if (set === undefined || set.size === 0) return; + await Promise.all([...set]); +} + +export function wrapAuthorizeWithEvidenceArchive< + TResult extends { effect: string | null }, + TAuthorize extends (resource: string, action: string, context: unknown) => Promise, +>(authorize: TAuthorize, getArchive: () => CompactionArchive | undefined): TAuthorize { + const wrapped = (async (resource: string, action: string, context: unknown) => { + const archive = getArchive(); + const call = + context !== null && + typeof context === "object" && + "id" in context && + "name" in context && + "arguments" in context + ? (context as { id: string; name: string; arguments: Record }) + : undefined; + + if (archive !== undefined && call !== undefined && action === "invoke") { + await archive.noteToolRequested({ + id: call.id, + name: call.name, + arguments: call.arguments, + }); + } + + const result = await authorize(resource, action, context); + + if ( + archive !== undefined && + call !== undefined && + action === "invoke" && + (result.effect === "allow" || result.effect === "deny" || result.effect === "ask") + ) { + const lifecycle = + result.effect === "allow" ? "admitted" : result.effect === "deny" ? "denied" : "suspended"; + await archive.finalizeToolRecording({ + callId: call.id, + name: call.name, + lifecycle, + executionArgs: call.arguments, + }); + } + + return result; + }) as TAuthorize; + return wrapped; +} + +function encodePayload(payload: unknown): { bytes: Uint8Array; contentType: string } { + if (typeof payload === "string") { + return { bytes: new TextEncoder().encode(payload), contentType: "text/plain" }; + } + return { + bytes: new TextEncoder().encode(JSON.stringify(payload)), + contentType: "application/json", + }; +} + +function occurrenceBlobKey(sessionId: string, occurrenceId: string): string { + return `archive/${sessionId}/${occurrenceId}`; +} + +async function appendIndex(contextDir: string, occurrence: ArchiveOccurrence): Promise { + const file = indexPath(contextDir); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.appendFile(file, `${JSON.stringify(occurrence)}\n`, "utf8"); +} + +async function readIndex(contextDir: string): Promise { + const file = indexPath(contextDir); + try { + const text = await fs.promises.readFile(file, "utf8"); + if (text.length === 0) return []; + const out: ArchiveOccurrence[] = []; + for (const line of text.split("\n")) { + if (line.length === 0) continue; + const parsed = ArchiveOccurrence(JSON.parse(line)); + if (parsed instanceof type.errors) { + throw new Error(`evidence archive index corrupt: ${parsed.summary}`); + } + out.push(parsed); + } + return out; + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return []; + throw cause; + } +} + +function mintOccurrenceId(parts: { + sessionId: string; + kind: string; + contentHash: string; + callId?: string; + lifecycle?: string; + recordedAt: number; +}): string { + const material = [ + parts.sessionId, + parts.kind, + parts.contentHash, + parts.callId ?? "", + parts.lifecycle ?? "", + String(parts.recordedAt), + ].join("|"); + return `occ-${createHash("sha256").update(material).digest("hex").slice(0, 24)}`; +} + +export function createCompactionArchive(opts: CreateCompactionArchiveOpts): CompactionArchive { + const { sessionId, contextDir, writeBlob, readBlob } = opts; + const now = opts.now ?? (() => Date.now()); + const requested = new Map(); + async function persistOccurrence( + input: RecordAuthorizedPayloadInput & { blobKey?: string; skipWrite?: boolean }, + ): Promise { + const recordedAt = now(); + const { bytes, contentType } = encodePayload(input.payload ?? ""); + const contentHash = hashAuthorizedBytes(bytes); + const occurrenceId = mintOccurrenceId({ + sessionId, + kind: input.kind, + contentHash, + ...(input.callId !== undefined ? { callId: input.callId } : {}), + ...(input.lifecycle !== undefined ? { lifecycle: input.lifecycle } : {}), + recordedAt, + }); + const blobKey = input.blobKey ?? occurrenceBlobKey(sessionId, occurrenceId); + if (input.skipWrite !== true && input.gap !== true) { + await writeBlob(blobKey, bytes, contentType); + } + const occurrence = ArchiveOccurrence({ + occurrenceId, + sessionId, + kind: input.kind, + contentHash, + blobKey, + recordedAt, + ...(input.callId !== undefined ? { callId: input.callId } : {}), + ...(input.lifecycle !== undefined ? { lifecycle: input.lifecycle } : {}), + ...(input.provenance !== undefined ? { provenance: input.provenance } : {}), + ...(input.gap === true ? { gap: true } : {}), + }); + if (occurrence instanceof type.errors) { + throw new Error(`invalid archive occurrence: ${occurrence.summary}`); + } + await appendIndex(contextDir, occurrence); + return occurrence; + } + + const archive: CompactionArchive = { + async recordAuthorizedPayload(input) { + return persistOccurrence(input); + }, + + async recordExistingBlobReference(input) { + let gap = false; + try { + const bytes = await readBlob(input.blobKey); + const actual = hashAuthorizedBytes(bytes); + if (actual !== input.contentHash) gap = true; + } catch { + gap = true; + } + const recordedAt = now(); + const occurrenceId = mintOccurrenceId({ + sessionId, + kind: input.kind, + contentHash: input.contentHash, + ...(input.callId !== undefined ? { callId: input.callId } : {}), + recordedAt, + }); + const occurrence = ArchiveOccurrence({ + occurrenceId, + sessionId, + kind: input.kind, + contentHash: input.contentHash, + blobKey: input.blobKey, + recordedAt, + ...(input.callId !== undefined ? { callId: input.callId } : {}), + ...(input.provenance !== undefined ? { provenance: input.provenance } : {}), + ...(gap ? { gap: true } : {}), + }); + if (occurrence instanceof type.errors) { + throw new Error(`invalid archive occurrence: ${occurrence.summary}`); + } + await appendIndex(contextDir, occurrence); + return occurrence; + }, + + async noteToolRequested(call) { + // Observation only — execution args are not scrubbed in place. + requested.set(call.id, call); + }, + + async finalizeToolRecording(input) { + const recordedArgs = authorizedToolArgsRepresentation(input.executionArgs); + if (input.lifecycle === "denied") { + return persistOccurrence({ + kind: "tool_failure", + lifecycle: "denied", + callId: input.callId, + payload: { + name: input.name, + lifecycle: "denied", + arguments: recordedArgs, + }, + provenance: "authz:denied", + }); + } + if (input.lifecycle === "suspended") { + return persistOccurrence({ + kind: "tool_args", + lifecycle: "suspended", + callId: input.callId, + payload: { + name: input.name, + lifecycle: "suspended", + arguments: recordedArgs, + }, + provenance: "authz:suspended", + }); + } + requested.delete(input.callId); + return persistOccurrence({ + kind: "tool_args", + lifecycle: "admitted", + callId: input.callId, + payload: { + name: input.name, + lifecycle: "admitted", + arguments: recordedArgs, + }, + provenance: "authz:admitted", + }); + }, + + async readAuthorizedPayload(occurrenceId) { + const occurrences = await readIndex(contextDir); + const hit = occurrences.find((o) => o.occurrenceId === occurrenceId); + if (hit === undefined) throw new Error(`unknown occurrence ${occurrenceId}`); + if (hit.gap === true) throw new Error(`occurrence ${occurrenceId} is an explicit gap`); + const bytes = await readBlob(hit.blobKey); + return new TextDecoder().decode(bytes); + }, + + async listOccurrences() { + return readIndex(contextDir); + }, + + async awaitPendingWrites() { + await awaitArchivePendingWrites(archive); + }, + + async certifyRange(expectedOccurrenceIds) { + await awaitArchivePendingWrites(archive); + const expected = [...expectedOccurrenceIds]; + const occurrences = await readIndex(contextDir); + const byId = new Map(occurrences.map((o) => [o.occurrenceId, o])); + const presentOccurrenceIds: string[] = []; + const missingOccurrenceIds: string[] = []; + const unverifiedBlobIds: string[] = []; + + for (const id of expected) { + const occ = byId.get(id); + if (occ === undefined) { + missingOccurrenceIds.push(id); + continue; + } + presentOccurrenceIds.push(id); + if (occ.gap === true) { + unverifiedBlobIds.push(id); + continue; + } + try { + const bytes = await readBlob(occ.blobKey); + if (hashAuthorizedBytes(bytes) !== occ.contentHash) { + unverifiedBlobIds.push(id); + } + } catch { + unverifiedBlobIds.push(id); + } + } + + const status = + missingOccurrenceIds.length === 0 && unverifiedBlobIds.length === 0 + ? "complete" + : "incomplete"; + const certificate = CompletenessCertificate({ + status, + expectedOccurrenceIds: expected, + presentOccurrenceIds, + missingOccurrenceIds, + unverifiedBlobIds, + certifiedAt: now(), + }); + if (certificate instanceof type.errors) { + throw new Error(`invalid completeness certificate: ${certificate.summary}`); + } + return certificate; + }, + + async importHistoricalEvidence(items) { + const importedOccurrenceIds: string[] = []; + const gapOccurrenceIds: string[] = []; + for (const item of items) { + if (item.available) { + const occ = await persistOccurrence({ + kind: item.kind, + payload: item.payload ?? "", + ...(item.callId !== undefined ? { callId: item.callId } : {}), + provenance: "historical-import", + }); + importedOccurrenceIds.push(occ.occurrenceId); + } else { + const occ = await persistOccurrence({ + kind: item.kind, + payload: "", + ...(item.callId !== undefined ? { callId: item.callId } : {}), + provenance: "historical-import:gap", + gap: true, + skipWrite: true, + }); + gapOccurrenceIds.push(occ.occurrenceId); + } + } + const result = HistoricalImportResult({ importedOccurrenceIds, gapOccurrenceIds }); + if (result instanceof type.errors) { + throw new Error(`invalid historical import result: ${result.summary}`); + } + return result; + }, + }; + return archive; +} diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 8f8bb898c..dbb604134 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -59,6 +59,7 @@ import { resolveLiveSessionSources, type LiveSessionSources, } from "../../session/assemble-runtime.js"; +import type { CompactionArchive } from "../../session/compaction-archive.js"; import { createApprovalResume } from "../../session/approval-resume.js"; import { createReactorAuthorize } from "../../permission/reactor-authorize.js"; import { @@ -302,6 +303,7 @@ export async function assembleTUISession( // submit_output's handler complete the live workflow without a // construction-order cycle. const workflowHostHolder: { instance?: WorkflowHost } = {}; + const evidenceArchiveHolder: { current?: CompactionArchive } = {}; const toolsetHolder: { current?: Awaited>; @@ -331,6 +333,7 @@ export async function assembleTUISession( liveAgent(state).deliver(buildShellBackgroundMessage(exit)), ); }, + getEvidenceArchive: () => evidenceArchiveHolder.current, isWorkflowActive: () => workflowHostHolder.instance?.isActive() === true, completeWorkflowStep: (stepId) => workflowHostHolder.instance?.complete(stepId) ?? "not-current", @@ -568,6 +571,7 @@ export async function assembleTUISession( state.currentAgent = agent; state.currentStorage = storage; }, + evidenceArchiveHolder, }); const sessionCost = createSessionCostAccumulator({ From b75ff282ef18fe2b19e86f4ac4d8cfbc43ac18db Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 08:50:05 -0700 Subject: [PATCH 03/16] Gate primary evidence archive to sessions that request it Workers omit the holder, so assemble keeps plain storage and skips admission and authorize recording wraps. --- src/session/assemble-runtime.test.ts | 219 +++++++++++++------------ src/session/assemble-runtime.ts | 84 +++++----- src/session/compaction-archive.test.ts | 39 +++++ 3 files changed, 203 insertions(+), 139 deletions(-) diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index d793b8477..41b0ceb6d 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -3,12 +3,7 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Agent } from "@intx/agent"; -import type { - AuditStore, - Compactor, - ContextStore, - ToolDefinition, -} from "@intx/types/runtime"; +import type { Compactor, ContextStore, ToolDefinition } from "@intx/types/runtime"; import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; import { @@ -18,16 +13,10 @@ import { } from "./assemble-runtime.js"; function def(name: string): ToolDefinition { - return { - name, - description: `${name} tool`, - inputSchema: { type: "object", properties: {} }, - }; + return { name, description: `${name} tool`, inputSchema: { type: "object", properties: {} } }; } -function wiring( - overrides: Partial[0]> = {}, -) { +function wiring(overrides: Partial[0]> = {}) { return { sessionMode: "orchestrator" as const, toolAvailability: { languageServerAvailable: false }, @@ -39,10 +28,7 @@ function wiring( describe("createAdvertisedToolset", () => { test("drops names outside the built-in prefix", () => { const { computeAdvertised } = createAdvertisedToolset(wiring()); - const names = computeAdvertised([ - def("write_file"), - def("mystery_tool"), - ]).map((d) => d.name); + const names = computeAdvertised([def("write_file"), def("mystery_tool")]).map((d) => d.name); expect(names).toContain("write_file"); expect(names).not.toContain("mystery_tool"); }); @@ -50,23 +36,16 @@ describe("createAdvertisedToolset", () => { test("appends activated tools after the prefix, in activation order", () => { const { activated, computeAdvertised } = createAdvertisedToolset(wiring()); expect(activated.activate(["mystery_tool"])).toBe(true); - const names = computeAdvertised([ - def("read_file"), - def("mystery_tool"), - ]).map((d) => d.name); + const names = computeAdvertised([def("read_file"), def("mystery_tool")]).map((d) => d.name); expect(names[names.length - 1]).toBe("mystery_tool"); expect(names.slice(0, -1)).not.toContain("mystery_tool"); }); test("honors an explicit built-in prefix", () => { - const { computeAdvertised } = createAdvertisedToolset( - wiring({ builtInPrefix: ["read_file"] }), - ); - expect( - computeAdvertised([def("read_file"), def("write_file")]).map( - (d) => d.name, - ), - ).toEqual(["read_file"]); + const { computeAdvertised } = createAdvertisedToolset(wiring({ builtInPrefix: ["read_file"] })); + expect(computeAdvertised([def("read_file"), def("write_file")]).map((d) => d.name)).toEqual([ + "read_file", + ]); }); test("advertises nothing from an empty registry", () => { @@ -129,26 +108,60 @@ function stubInferenceDeps(): ChatAgentWiring["inferenceDeps"] { }; } +function stubAuthorize(): ChatAgentWiring["authorize"] { + return async () => ({ effect: "allow", matchingGrants: [], resolvedBy: null }); +} + +function stubChatAgentWiring(overrides: Partial = {}): ChatAgentWiring { + return { + toolsId: "test/tools", + agentId: "test/agent", + systemPrompt: "prompt", + authorize: stubAuthorize(), + getDynamicRunner: () => { + throw new Error("getDynamicRunner should not run at assemble or mocked build"); + }, + computeAdvertised: () => [], + activateTools: () => false, + inactivityTimeoutMs: 1_000, + onTasksChange: () => {}, + requestContinuation: () => {}, + getProvider: () => ({ providerName: "test", model: "m" }), + getWorkdir: () => "/build-dir", + inferenceDeps: stubInferenceDeps(), + getSources: () => [ + { + id: "s", + provider: "test", + baseURL: "http://localhost", + apiKey: "k", + model: "m", + }, + ], + getDefaultSource: () => "s", + getCompactor: () => stubCompactor("build"), + onBuilt: () => {}, + ...overrides, + }; +} + describe("assembleChatAgent", () => { - test("getWorkdir, getSessionId, and getCompactor run at buildAgent time", async () => { + test("getWorkdir and getCompactor run at buildAgent time, not assemble time", async () => { const storeDirs: string[] = []; const agentWorkdirs: string[] = []; - const agentSessionIds: string[] = []; - const agentAudits: AuditStore[] = []; - const agentStorages: ContextStore[] = []; const agentCompactors: Compactor[] = []; const fakeStorage = { readBlob: async () => new Uint8Array(), - } as unknown as ContextStore & AuditStore; - const fakeAgent = { close: async () => undefined } as unknown as Agent; + } as unknown as ContextStore; + const fakeAgent = { close: async () => {} } as unknown as Agent; await withMockedModuleDuring( import.meta.resolve("./optimized-context-store.js"), (real: typeof import("./optimized-context-store.js")) => ({ ...real, - createSessionStores: async (dir: string) => { + createOptimizedContextStore: async (dir: string) => { storeDirs.push(dir); - return { storage: fakeStorage, audit: fakeStorage }; + return fakeStorage; }, }), async () => { @@ -158,19 +171,9 @@ describe("assembleChatAgent", () => { ...real, createAgentWithLiveToolDispatch: async ( _def: unknown, - env: { - workdir: string; - sessionId?: string; - storage: ContextStore; - audit: AuditStore; - compactors: { "pruning-compactor": Compactor }; - }, + env: { workdir: string; compactors: { "pruning-compactor": Compactor } }, ) => { agentWorkdirs.push(env.workdir); - if (env.sessionId !== undefined) - agentSessionIds.push(env.sessionId); - agentStorages.push(env.storage); - agentAudits.push(env.audit); agentCompactors.push(env.compactors["pruning-compactor"]); return fakeAgent; }, @@ -178,84 +181,96 @@ describe("assembleChatAgent", () => { async () => { const { assembleChatAgent } = await import("./assemble-runtime.js"); const workdirCalls: string[] = []; - const sessionIdCalls: string[] = []; const compactorCalls: string[] = []; let liveDir = "/assemble-dir"; - let liveSessionId = "assemble-session"; let liveCompactor = stubCompactor("assemble"); - const { buildAgent } = assembleChatAgent({ - toolsId: "test/tools", - agentId: "test/agent", - systemPrompt: "prompt", - authorize: async () => ({ - effect: "allow", - matchingGrants: [], - resolvedBy: null, - }), - getDynamicRunner: () => { - throw new Error( - "getDynamicRunner should not run at assemble or mocked build", - ); - }, - computeAdvertised: () => [], - activateTools: () => false, - inactivityTimeoutMs: 1_000, - onTasksChange: () => undefined, - requestContinuation: () => undefined, - getProvider: () => ({ providerName: "test", model: "m" }), - getWorkdir: () => { - workdirCalls.push(liveDir); - return liveDir; - }, - getSessionId: () => { - sessionIdCalls.push(liveSessionId); - return liveSessionId; - }, - inferenceDeps: stubInferenceDeps(), - getSources: () => [ - { - id: "s", - provider: "test", - baseURL: "http://localhost", - apiKey: "k", - model: "m", + const { buildAgent } = assembleChatAgent( + stubChatAgentWiring({ + getWorkdir: () => { + workdirCalls.push(liveDir); + return liveDir; + }, + getCompactor: () => { + compactorCalls.push(liveCompactor.name); + return liveCompactor; }, - ], - getDefaultSource: () => "s", - getCompactor: () => { - compactorCalls.push(liveCompactor.name); - return liveCompactor; - }, - onBuilt: () => undefined, - }); + }), + ); expect(workdirCalls).toEqual([]); - expect(sessionIdCalls).toEqual([]); expect(compactorCalls).toEqual([]); expect(storeDirs).toEqual([]); expect(agentWorkdirs).toEqual([]); liveDir = "/build-dir"; - liveSessionId = "build-session"; liveCompactor = stubCompactor("build"); const builtCompactor = liveCompactor; await buildAgent(); expect(workdirCalls).toEqual(["/build-dir"]); - expect(sessionIdCalls).toEqual(["build-session"]); expect(compactorCalls).toEqual(["build"]); expect(storeDirs).toEqual(["/build-dir"]); expect(agentWorkdirs).toEqual(["/build-dir"]); - expect(agentSessionIds).toEqual(["build-session"]); - expect(agentStorages).toEqual([fakeStorage]); - expect(agentAudits).toEqual([fakeStorage]); - expect(Object.is(agentAudits[0], agentStorages[0])).toBe(true); expect(agentCompactors).toEqual([builtCompactor]); }, ); }, ); }); + + test("omits evidence archive when no holder is provided", async () => { + const fakeStorage = { + readBlob: async () => new Uint8Array(), + } as unknown as ContextStore; + const fakeAgent = { close: async () => {} } as unknown as Agent; + const authorize = stubAuthorize(); + let capturedStorage: ContextStore | undefined; + let capturedAuthorize: unknown; + let builtAgent: Agent | undefined; + let builtStorage: ContextStore | undefined; + + await withMockedModuleDuring( + import.meta.resolve("./optimized-context-store.js"), + (real: typeof import("./optimized-context-store.js")) => ({ + ...real, + createOptimizedContextStore: async () => fakeStorage, + }), + async () => { + await withMockedModuleDuring( + import.meta.resolve("../agent/live-tool-dispatch.js"), + (real: typeof import("../agent/live-tool-dispatch.js")) => ({ + ...real, + createAgentWithLiveToolDispatch: async ( + _def: unknown, + env: { storage: ContextStore; authorize: unknown }, + ) => { + capturedStorage = env.storage; + capturedAuthorize = env.authorize; + return fakeAgent; + }, + }), + async () => { + const { assembleChatAgent } = await import("./assemble-runtime.js"); + const { buildAgent } = assembleChatAgent( + stubChatAgentWiring({ + authorize, + onBuilt: (agent, storage) => { + builtAgent = agent; + builtStorage = storage; + }, + }), + ); + await buildAgent(); + }, + ); + }, + ); + + expect(capturedStorage).toBe(fakeStorage); + expect(capturedAuthorize).toBe(authorize); + expect(builtAgent).toBe(fakeAgent); + expect(builtStorage).toBe(fakeStorage); + }); }); diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 6282af0f3..1a58e3c82 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -469,44 +469,50 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { const buildAgent = async (): Promise => { const workdir = wiring.getWorkdir(); const { storage, audit } = await createSessionStores(workdir); - // workdir is …//context for primary sessions. - const sessionId = path.basename(path.dirname(workdir)); - const archive = createCompactionArchive({ - sessionId, - contextDir: workdir, - writeBlob: (key, bytes, contentType) => - storage.writeBlob(key, bytes, contentType), - readBlob: (key) => storage.readBlob(key), - }); - if (wiring.evidenceArchiveHolder !== undefined) { - wiring.evidenceArchiveHolder.current = archive; + // Primary-only evidence archive. Workers never pass evidenceArchiveHolder, so + // they keep plain storage and omit admission / authorize recording wraps. + const archiveHolder = wiring.evidenceArchiveHolder; + let primaryArchive: CompactionArchive | undefined; + if (archiveHolder !== undefined) { + const sessionId = path.basename(path.dirname(workdir)); + primaryArchive = createCompactionArchive({ + sessionId, + contextDir: workdir, + writeBlob: (key, bytes, contentType) => + storage.writeBlob(key, bytes, contentType), + readBlob: (key) => storage.readBlob(key), + }); + archiveHolder.current = primaryArchive; } - const storageWithArchive: ContextStore = { - ...storage, - async writeResponse(turn, signal) { - const content = turn.content.map((block) => { - if (block.type !== "text") return block; - const text = applyRecordingPolicyToText(block.text); - return text === block.text ? block : { ...block, text }; - }); - const admitted = { ...turn, content }; - for (const block of admitted.content) { - if (block.type === "text" && block.text.length > 0) { - await archive.recordAuthorizedPayload({ - kind: "assistant_text", - payload: block.text, - provenance: "writeResponse:post-policy", - }); - } - } - return storage.writeResponse(admitted, signal); - }, - }; + const storageForAgent: ContextStore = + primaryArchive === undefined + ? storage + : { + ...storage, + async writeResponse(turn, signal) { + const content = turn.content.map((block) => { + if (block.type !== "text") return block; + const text = applyRecordingPolicyToText(block.text); + return text === block.text ? block : { ...block, text }; + }); + const admitted = { ...turn, content }; + for (const block of admitted.content) { + if (block.type === "text" && block.text.length > 0) { + await primaryArchive.recordAuthorizedPayload({ + kind: "assistant_text", + payload: block.text, + provenance: "writeResponse:post-policy", + }); + } + } + return storage.writeResponse(admitted, signal); + }, + }; const agent = await createAgentWithLiveToolDispatch(agentDef, { sources: wiring.getSources(), defaultSource: wiring.getDefaultSource(), - storage: storageWithArchive, + storage: storageForAgent, workdir, // contextTransforms ride deps: the published @intx/agent forwards deps // into reactor assembly verbatim, and the vendored assembly picks the @@ -515,7 +521,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { ...wiring.inferenceDeps, contextTransforms: [ createAttachmentRehydrateTransform((key) => - storageWithArchive.readBlob(key), + storageForAgent.readBlob(key), ), ], }, @@ -524,7 +530,10 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { // Gate-backed reactor authorization: ask-tier calls suspend via the // vendored approval-suspend primitive instead of parking on a closure. // Finalize evidence admission after guards resolve; never scrub exec args. - authorize: wrapAuthorizeWithEvidenceArchive(wiring.authorize, () => archive), + authorize: + primaryArchive === undefined + ? wiring.authorize + : wrapAuthorizeWithEvidenceArchive(wiring.authorize, () => primaryArchive), directors: createDirectorRegistry({ factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, @@ -533,8 +542,9 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { "pruning-compactor": wiring.getCompactor(), }, }); - const admittedAgent = createPrimaryDeliveryAdmission(agent, archive); - wiring.onBuilt(admittedAgent, storageWithArchive); + const admittedAgent = + primaryArchive === undefined ? agent : createPrimaryDeliveryAdmission(agent, primaryArchive); + wiring.onBuilt(admittedAgent, storageForAgent); return admittedAgent; }; diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index 433b171f3..e325c5be1 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -134,6 +134,45 @@ describe("primary message admission", () => { expect(delivered[0]!.content).toContain(CREDENTIAL_REDACTION); expect(delivered[1]!.content).toContain(CREDENTIAL_REDACTION); }); + + test("history and archive share the same admitted representation", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-admit", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing blob ${key}`); + return bytes; + }, + }); + const delivered: InboundMessage[] = []; + const agent = { + deliver(message: InboundMessage) { + delivered.push(message); + }, + async send(message: InboundMessage) { + delivered.push(message); + return { ok: true as const }; + }, + }; + const wrapped = createPrimaryDeliveryAdmission(agent, archive); + wrapped.deliver(inbound({ content: "constraint sk-abcdefghijklmnopqrstuvwxyz012345" })); + await archive.awaitPendingWrites(); + expect(delivered).toHaveLength(1); + const admitted = delivered[0]!.content!; + expect(admitted).toContain(CREDENTIAL_REDACTION); + expect(admitted).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + const occurrences = await archive.listOccurrences(); + expect(occurrences).toHaveLength(1); + expect(occurrences[0]!.kind).toBe("user_message"); + const archived = await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId); + expect(archived).toBe(admitted); + }); }); describe("compaction archive storage", () => { From 72a6a589455a2a5cfa750c3e9bdcbcf842235a9a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 09:54:46 -0700 Subject: [PATCH 04/16] Fix evidence archive blob keys for the context store Context store blob keys cannot contain slashes, so archive occurrences failed to persist. Hash overflow blobs after scrub and admit string send the same way as inbound messages. --- src/plugins/result-truncation-plugin.ts | 9 +- src/session/assemble-runtime.ts | 6 +- src/session/attachment-store.ts | 12 +- src/session/compaction-archive.test.ts | 237 +++++++++++++++++++++++- src/session/compaction-archive.ts | 136 +++++++++++++- 5 files changed, 380 insertions(+), 20 deletions(-) diff --git a/src/plugins/result-truncation-plugin.ts b/src/plugins/result-truncation-plugin.ts index 74ac79973..815e2c93c 100644 --- a/src/plugins/result-truncation-plugin.ts +++ b/src/plugins/result-truncation-plugin.ts @@ -328,10 +328,17 @@ async function archiveThenTruncate( before.length > MAX_RESULT_CHARS ) { try { + const spilled = + typeof before === "string" + ? scrubSecretShapedContent(materializeToolResultContent(before).text) + : scrubSecretShapedContent( + materializeToolResultRecord(before as Record) + .text, + ); await archive.recordExistingBlobReference({ kind: "overflow_blob", blobKey: spillBlobKey(callId), - contentHash: hashAuthorizedBytes(new TextEncoder().encode(before)), + contentHash: hashAuthorizedBytes(new TextEncoder().encode(spilled)), callId, provenance: "result-truncation:full", }); diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 1a58e3c82..da8b7257e 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -59,6 +59,7 @@ import { createCompactionArchive, createPrimaryDeliveryAdmission, wrapAuthorizeWithEvidenceArchive, + wrapCompactorWithCompletenessGate, type CompactionArchive, } from "./compaction-archive.js"; import path from "node:path"; @@ -539,7 +540,10 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { defaultId: `${ID_PREFIX}/chat`, }), compactors: { - "pruning-compactor": wiring.getCompactor(), + "pruning-compactor": + primaryArchive === undefined + ? wiring.getCompactor() + : wrapCompactorWithCompletenessGate(wiring.getCompactor(), primaryArchive), }, }); const admittedAgent = diff --git a/src/session/attachment-store.ts b/src/session/attachment-store.ts index 399b79b6b..f0dac601c 100644 --- a/src/session/attachment-store.ts +++ b/src/session/attachment-store.ts @@ -12,7 +12,7 @@ import { formatAgedImageMarker, parseAgedImageMarker, } from "./attachment-uri.js"; -import { hashAuthorizedBytes, type CompactionArchive } from "./compaction-archive.js"; +import { type CompactionArchive } from "./compaction-archive.js"; export interface AgeImageResult { turn: ConversationTurn; @@ -71,14 +71,8 @@ export async function ageImageBlocks( bytes, contentType: block.source.mimeType, }); - if (options.archive !== undefined) { - await options.archive.recordExistingBlobReference({ - kind: "attachment", - blobKey: id, - contentHash: hashAuthorizedBytes(bytes), - provenance: "attachment-age:base64", - }); - } + // recordExistingBlobReference before persistBlobs marks a false gap. + // Callers that already wrote the blob may record after this returns. content.push({ type: "text", text: formatAgedImageMarker({ uri, mimeType: block.source.mimeType }), diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index e325c5be1..42c3e37f3 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -15,6 +15,7 @@ import { hashAuthorizedBytes, isControlOrEmptyInbound, } from "./compaction-archive.js"; +import { createOptimizedContextStore } from "./optimized-context-store.js"; function tempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "compaction-archive-")); @@ -122,8 +123,12 @@ describe("primary message admission", () => { deliver(message: InboundMessage) { delivered.push(message); }, - async send(message: InboundMessage) { - delivered.push(message); + async send(content: string | InboundMessage) { + if (typeof content === "string") { + delivered.push(inbound({ content })); + return { ok: true as const }; + } + delivered.push(content); return { ok: true as const }; }, }; @@ -155,8 +160,12 @@ describe("primary message admission", () => { deliver(message: InboundMessage) { delivered.push(message); }, - async send(message: InboundMessage) { - delivered.push(message); + async send(content: string | InboundMessage) { + if (typeof content === "string") { + delivered.push(inbound({ content })); + return { ok: true as const }; + } + delivered.push(content); return { ok: true as const }; }, }; @@ -173,6 +182,43 @@ describe("primary message admission", () => { const archived = await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId); expect(archived).toBe(admitted); }); + + test("send(string) admits and archives like InboundMessage", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-send-string", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing blob ${key}`); + return bytes; + }, + }); + const sent: string[] = []; + const agent = { + deliver(_message: InboundMessage) { + /* unused */ + }, + async send(content: string | InboundMessage) { + if (typeof content !== "string") throw new Error("expected string send"); + sent.push(content); + return { ok: true as const }; + }, + }; + const wrapped = createPrimaryDeliveryAdmission(agent, archive); + const secret = `sk-${"a".repeat(24)}`; + await wrapped.send(`constraint ${secret}`); + expect(sent).toHaveLength(1); + expect(sent[0]).toContain(CREDENTIAL_REDACTION); + expect(sent[0]).not.toContain(secret); + const occurrences = await archive.listOccurrences(); + expect(occurrences).toHaveLength(1); + expect(await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId)).toBe(sent[0]!); + }); }); describe("compaction archive storage", () => { @@ -451,4 +497,187 @@ describe("compaction archive storage", () => { const cert = await archive.certifyRange([occ.occurrenceId]); expect(cert.status).toBe("complete"); }); + + test("recordAuthorizedPayload writes store-legal keys through createOptimizedContextStore", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + const archive = createCompactionArchive({ + sessionId: "sess-store-keys", + contextDir: dir, + writeBlob: (key, bytes, contentType) => store.writeBlob(key, bytes, contentType), + readBlob: (key) => store.readBlob(key), + }); + const occ = await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "hello-store", + }); + expect(occ.blobKey.includes("/")).toBe(false); + expect(occ.blobKey.includes("..")).toBe(false); + expect(await archive.readAuthorizedPayload(occ.occurrenceId)).toBe("hello-store"); + }); +}); + +describe("wrapCompactorWithCompletenessGate", () => { + const ctx = { trigger: "test" } as unknown as import("@intx/types/runtime").StrategyContext; + + function truncating(name: string): import("@intx/types/runtime").Compactor { + return { + name, + version: "1", + async apply(turns) { + return { + output: turns.slice(-1), + blobs: [ + { + key: "stats", + bytes: new TextEncoder().encode("{}"), + contentType: "application/json", + }, + ], + record: { + strategy: name, + version: "1", + parameters: {}, + reason: "compact", + decisions: { dropped: turns.length - 1 }, + }, + }; + }, + }; + } + + function memoryArchive() { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-gate", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + return { archive, blobs }; + } + + test("incomplete archive returns identity history and drops stats blobs", async () => { + const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { archive } = memoryArchive(); + const inner = truncating("pruning-compactor"); + const wrapped = wrapCompactorWithCompletenessGate(inner, archive); + const turns: import("@intx/types/runtime").ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "secret-fact" }], + timestamp: 1, + }, + { + role: "assistant", + content: [ + { type: "tool_call", id: "call-drop", name: "read_file", arguments: { path: "a.ts" } }, + ], + timestamp: 2, + }, + { + role: "user", + content: [ + { type: "tool_result", callId: "call-drop", content: [{ type: "text", text: "ok" }] }, + ], + timestamp: 3, + }, + ]; + + const result = await wrapped.apply(turns, ctx); + expect(result.output).toBe(turns); + expect(result.blobs).toBeUndefined(); + expect(result.record.reason).toBe("incomplete-evidence-archive"); + }); + + test("complete archive covering dropped callIds allows the rewrite", async () => { + const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { archive } = memoryArchive(); + await archive.recordAuthorizedPayload({ + kind: "tool_args", + payload: { name: "read_file", arguments: { path: "a.ts" } }, + callId: "call-drop", + }); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "ok", + callId: "call-drop", + }); + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "secret-fact", + }); + + const inner = truncating("pruning-compactor"); + const wrapped = wrapCompactorWithCompletenessGate(inner, archive); + const turns: import("@intx/types/runtime").ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "secret-fact" }], + timestamp: 1, + }, + { + role: "assistant", + content: [ + { type: "tool_call", id: "call-drop", name: "read_file", arguments: { path: "a.ts" } }, + ], + timestamp: 2, + }, + { + role: "user", + content: [ + { type: "tool_result", callId: "call-drop", content: [{ type: "text", text: "ok" }] }, + ], + timestamp: 3, + }, + ]; + + const result = await wrapped.apply(turns, ctx); + expect(result.output).toHaveLength(1); + expect(result.blobs?.some((b) => b.key === "stats")).toBe(true); + expect(result.record.reason).toBe("compact"); + }); + + test("explicit gap records are not required for completeness", async () => { + const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { archive } = memoryArchive(); + await archive.importHistoricalEvidence([ + { kind: "user_message", available: false, callId: "historical-gap" }, + ]); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "ok", + callId: "call-drop", + }); + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "secret-fact", + }); + + const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive); + const turns: import("@intx/types/runtime").ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "secret-fact" }], + timestamp: 1, + }, + { + role: "user", + content: [ + { type: "tool_result", callId: "call-drop", content: [{ type: "text", text: "ok" }] }, + ], + timestamp: 2, + }, + ]; + const result = await wrapped.apply(turns, ctx); + expect(result.output).toHaveLength(1); + expect(result.record.reason).toBe("compact"); + }); }); diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index 175c1057c..ca606ada4 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -11,7 +11,12 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { type } from "arktype"; -import type { InboundMessage } from "@intx/types/runtime"; +import type { + Compactor, + ConversationTurn, + InboundMessage, + StrategyContext, +} from "@intx/types/runtime"; import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js"; import { ArchiveOccurrence, @@ -216,7 +221,7 @@ async function archiveAdmittedInbound( export function createPrimaryDeliveryAdmission< T extends { deliver: (message: InboundMessage) => void; - send: (message: InboundMessage, ...rest: never[]) => unknown; + send: (content: string | InboundMessage, ...rest: never[]) => unknown; }, >(agent: T, archive?: CompactionArchive): T { return { @@ -229,8 +234,23 @@ export function createPrimaryDeliveryAdmission< trackPendingWrite(archive, archiveAdmittedInbound(archive, admitted)); } }, - send(message: InboundMessage, ...rest: never[]) { - const admitted = admitPrimaryInboundMessage(message); + send(content: string | InboundMessage, ...rest: never[]) { + if (typeof content === "string") { + const admitted = content.length === 0 ? content : applyRecordingPolicyToText(content); + if (archive === undefined || admitted.length === 0) { + return agent.send(admitted, ...rest); + } + const run = async () => { + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: admitted, + provenance: "primary-admission", + }); + return agent.send(admitted, ...rest); + }; + return run(); + } + const admitted = admitPrimaryInboundMessage(content); if (archive === undefined) { return agent.send(admitted, ...rest); } @@ -322,7 +342,8 @@ function encodePayload(payload: unknown): { bytes: Uint8Array; contentType: stri } function occurrenceBlobKey(sessionId: string, occurrenceId: string): string { - return `archive/${sessionId}/${occurrenceId}`; + // ContextStore.writeBlob rejects slash-containing keys (sanitizeCallId). + return `archive-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${occurrenceId}`; } async function appendIndex(contextDir: string, occurrence: ArchiveOccurrence): Promise { @@ -597,3 +618,108 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp }; return archive; } + +function textKindForRole(role: ConversationTurn["role"]): ArchiveKind { + if (role === "assistant") return "assistant_text"; + return "user_message"; +} + +function coveringOccurrence( + units: readonly { + kind: "text" | "tool_call" | "tool_result"; + text?: string; + role?: ConversationTurn["role"]; + callId?: string; + }[], + occurrences: readonly ArchiveOccurrence[], +): { ids: string[]; unmatched: boolean } { + const used = new Set(); + const ids: string[] = []; + for (const unit of units) { + const match = occurrences.find((occ) => { + if (used.has(occ.occurrenceId) || occ.gap === true) return false; + if (unit.kind === "text") { + if (unit.role === undefined || unit.text === undefined) return false; + if (occ.kind !== textKindForRole(unit.role)) return false; + return occ.contentHash === hashAuthorizedBytes(new TextEncoder().encode(unit.text)); + } + if (unit.callId === undefined || occ.callId !== unit.callId) return false; + if (unit.kind === "tool_call") return occ.kind === "tool_args" || occ.kind === "tool_failure"; + return occ.kind === "tool_result" || occ.kind === "overflow_blob"; + }); + if (match === undefined) return { ids, unmatched: true }; + used.add(match.occurrenceId); + ids.push(match.occurrenceId); + } + return { ids, unmatched: false }; +} + +function droppedContentUnits(dropped: readonly ConversationTurn[]): { + kind: "text" | "tool_call" | "tool_result"; + text?: string; + role?: ConversationTurn["role"]; + callId?: string; +}[] { + const units: { + kind: "text" | "tool_call" | "tool_result"; + text?: string; + role?: ConversationTurn["role"]; + callId?: string; + }[] = []; + for (const turn of dropped) { + for (const block of turn.content) { + if (block.type === "text" && block.text.length > 0) { + units.push({ kind: "text", role: turn.role, text: block.text }); + } else if (block.type === "tool_call") { + units.push({ kind: "tool_call", callId: block.id }); + } else if (block.type === "tool_result") { + units.push({ kind: "tool_result", callId: block.callId }); + } + } + } + return units; +} + +function incompleteIdentity(inner: Compactor, turns: ConversationTurn[]) { + return { + output: turns, + record: { + strategy: inner.name, + version: inner.version, + parameters: {}, + reason: "incomplete-evidence-archive", + decisions: {}, + }, + }; +} + +/** + * Refuse a destructive compact when the evidence archive cannot certify the + * dropped prefix. Historical gap:true rows are not part of the expected set. + */ +export function wrapCompactorWithCompletenessGate( + inner: Compactor, + archive: CompactionArchive, +): Compactor { + return { + name: inner.name, + version: inner.version, + async apply(turns: ConversationTurn[], ctx: StrategyContext) { + await archive.awaitPendingWrites(); + const proposed = await inner.apply(turns, ctx); + const dropped = turns.filter((turn) => !proposed.output.includes(turn)); + const units = droppedContentUnits(dropped); + if (units.length === 0) return proposed; + const occurrences = await archive.listOccurrences(); + const covering = coveringOccurrence(units, occurrences); + if (covering.unmatched || covering.ids.length === 0) { + return incompleteIdentity(inner, turns); + } + const certificate = await archive.certifyRange(covering.ids); + if (certificate.status !== "complete") { + return incompleteIdentity(inner, turns); + } + return proposed; + }, + }; +} From 88e459099332a9474202ef0b66e5bd3969bce97a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 09:54:52 -0700 Subject: [PATCH 05/16] Stage compaction rewrites until the store publishes Persist blobs and stage writeTurns first. Replace reactor memory only after commit so an interrupt resumes the old generation. --- src/session/optimized-context-store.test.ts | 107 ++++++++++ src/session/optimized-context-store.ts | 72 ++++++- .../integration/compaction-atomicity.test.ts | 192 ++++++++++++++++++ vendor/intx-inference/PATCHES.md | 15 ++ vendor/intx-inference/src/reactor.test.ts | 61 +++++- vendor/intx-inference/src/reactor.ts | 17 +- 6 files changed, 455 insertions(+), 9 deletions(-) create mode 100644 tests/integration/compaction-atomicity.test.ts diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index 435818ba4..5d5f0b336 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -722,3 +722,110 @@ describe("createSessionStores", () => { expect(typeof audit.loadAudit).toBe("function"); }); }); + +function turnTexts(turns: ConversationTurn[]): string[] { + return turns.map((t) => (t.content[0] as { text: string }).text); +} + +async function gitLsTree(dir: string): Promise { + const proc = Bun.spawn(["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], { + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`git ls-tree failed: ${stderr.trim() || stdout.trim()}`); + } + return stdout.split("\n").filter((line) => line.length > 0); +} + +describe("createOptimizedContextStore unpublished rewrite", () => { + test("rewrite writeTurns stays off the live generation until commit", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + const original = [turn("keep-a"), turn("keep-b"), turn("drop-me")]; + await store.writeTurns(original); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await store.commit({ message: "published original" }); + + const compacted = [turn("[Compacted prior context]"), turn("keep-b")]; + await store.writeTurns(compacted); + + const loaded = await store.load(); + expect(turnTexts(loaded.turns)).toEqual(["keep-a", "keep-b", "drop-me"]); + + await store.commit({ message: "publish compact" }); + const published = await store.load(); + expect(turnTexts(published.turns)).toEqual(["[Compacted prior context]", "keep-b"]); + }); + + test("omitting commit leaves a new store on the old generation", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + await store.writeTurns([turn("old-a"), turn("old-b")]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await store.commit({ message: "old" }); + + await store.writeBlob( + "new-blob", + new TextEncoder().encode("needed-by-new-turns"), + "text/plain", + ); + await store.writeTurns([turn("[Compacted prior context]")]); + + const crashed = await createOptimizedContextStore(dir); + const loaded = await crashed.load(); + expect(turnTexts(loaded.turns)).toEqual(["old-a", "old-b"]); + }); + + test("append writeTurns is still visible before commit", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + const first = turn("one"); + await store.writeTurns([first]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await store.commit({ message: "one" }); + + await store.writeTurns([first, turn("two")]); + const loaded = await store.load(); + expect(turnTexts(loaded.turns)).toEqual(["one", "two"]); + }); + + test("folds evidence-archive into the compact commit tree", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + await store.writeTurns([turn("old")]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await store.commit({ message: "old" }); + + const archiveDir = path.join(dir, "evidence-archive"); + fs.mkdirSync(archiveDir, { recursive: true }); + fs.writeFileSync(path.join(archiveDir, "index.jsonl"), "{}\n"); + await store.writeTurns([turn("compacted")]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await store.commit({ message: "compact" }); + + expect(await gitLsTree(dir)).toContain("evidence-archive/index.jsonl"); + const loaded = await store.load(); + expect(turnTexts(loaded.turns)).toEqual(["compacted"]); + }); + + test("readAt of the old hash is not the load completeness path", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + await store.writeTurns([turn("era-1")]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + const first = await store.commit({ message: "era-1" }); + + await store.writeTurns([turn("era-2")]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await store.commit({ message: "era-2" }); + + expect(turnTexts(await store.readAt(first.hash))).toEqual(["era-1"]); + expect(turnTexts((await store.load()).turns)).toEqual(["era-2"]); + }); +}); diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index e90d1f65d..c2f7bcf03 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -33,6 +33,7 @@ const RESPONSE_FILE = "response.jsonl"; const MANIFEST_FILE = "manifest.jsonl"; const METADATA_FILE = "metadata.json"; const TOOL_OUTPUT_DIR = "tool-output"; +const EVIDENCE_ARCHIVE_DIR = "evidence-archive"; const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]); @@ -439,6 +440,33 @@ export async function createSessionStores( const pendingSegmentPaths = new Set(); const writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE); const writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE); + let liveTurnRefs: readonly ConversationTurn[] | null = null; + let unpublishedRewrite: ConversationTurn[] | null = null; + + function refPrefixLength( + prev: readonly ConversationTurn[], + next: readonly ConversationTurn[], + ): number { + const max = Math.min(prev.length, next.length); + let prefix = 0; + while (prefix < max && prev[prefix] === next[prefix]) prefix++; + return prefix; + } + + function contentPrefixLength( + prev: readonly ConversationTurn[], + next: readonly ConversationTurn[], + ): number { + const max = Math.min(prev.length, next.length); + let prefix = 0; + while ( + prefix < max && + JSON.stringify(prev[prefix]) === JSON.stringify(next[prefix]) + ) { + prefix++; + } + return prefix; + } async function writeSegmented( writer: ReturnType, @@ -448,6 +476,39 @@ export async function createSessionStores( for (const filepath of modifiedPaths) pendingSegmentPaths.add(filepath); } + async function writeTurnsLiveOrStage( + turns: readonly ConversationTurn[], + ): Promise { + if (unpublishedRewrite !== null) { + unpublishedRewrite = [...turns]; + return; + } + if (liveTurnRefs !== null) { + if (refPrefixLength(liveTurnRefs, turns) < liveTurnRefs.length) { + unpublishedRewrite = [...turns]; + return; + } + await writeSegmented(writeTurnsSegmented, turns); + liveTurnRefs = [...turns]; + return; + } + const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE); + const baseResult = await base.load(); + const live = + extraTexts.length === 0 + ? baseResult.turns + : await loadTurnsWithoutMalformedToolSequence( + baseResult.turns, + extraTexts, + ); + if (live.length > 0 && contentPrefixLength(live, turns) < live.length) { + unpublishedRewrite = [...turns]; + return; + } + await writeSegmented(writeTurnsSegmented, turns); + liveTurnRefs = [...turns]; + } + // Prefer the longest prefix of base + extras whose tool sequence the reactor // will accept. Orphan tails left by a fresh-writer compaction rewrite are // dropped and unlinked so the next load does not re-poison the session. @@ -569,7 +630,7 @@ export async function createSessionStores( writePrompt: (turns) => writeSegmented(writePromptSegmented, turns), writeResponse: (turn, signal) => base.writeResponse(turn, signal), writeManifest: (records, signal) => base.writeManifest(records, signal), - writeTurns: (turns) => writeSegmented(writeTurnsSegmented, turns), + writeTurns: (turns) => writeTurnsLiveOrStage(turns), writeMetadata: (metadata, signal) => base.writeMetadata(metadata, signal), readManifestHistory: (limit, signal) => base.readManifestHistory(limit, signal), @@ -580,6 +641,11 @@ export async function createSessionStores( }, async commit(options, signal) { return withResolvedDirLock(dir, async () => { + if (unpublishedRewrite !== null) { + await writeSegmented(writeTurnsSegmented, unpublishedRewrite); + liveTurnRefs = unpublishedRewrite; + unpublishedRewrite = null; + } const toAdd: string[] = []; const toRemove: string[] = []; @@ -596,6 +662,10 @@ export async function createSessionStores( await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove); await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove); + if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) { + toAdd.push(EVIDENCE_ARCHIVE_DIR); + } + const add = extraCommitPaths([...new Set(toAdd)]); const remove = extraCommitPaths([...new Set(toRemove)]).filter( (p) => !add.includes(p), diff --git a/tests/integration/compaction-atomicity.test.ts b/tests/integration/compaction-atomicity.test.ts new file mode 100644 index 000000000..c9ff6f3f4 --- /dev/null +++ b/tests/integration/compaction-atomicity.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Compactor, ConversationTurn, StrategyContext } from "@intx/types/runtime"; +import { createOptimizedContextStore } from "../../src/session/optimized-context-store.js"; +import { + createCompactionArchive, + wrapCompactorWithCompletenessGate, +} from "../../src/session/compaction-archive.js"; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "compact-atomic-")); +} + +function turn(text: string): ConversationTurn { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +function texts(turns: ConversationTurn[]): string[] { + return turns.map((t) => (t.content[0] as { text: string }).text); +} + +const EMPTY_META = { + pendingOperations: [], + tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, +}; + +const ctx = { trigger: "test" } as unknown as StrategyContext; + +function truncatingCompactor(): Compactor { + return { + name: "pruning-compactor", + version: "1", + async apply(_turns) { + return { + output: [turn("[Compacted prior context]"), turn("kept-tail")], + blobs: [ + { key: "stats", bytes: new TextEncoder().encode("stats"), contentType: "text/plain" }, + ], + record: { + strategy: "pruning-compactor", + version: "1", + parameters: {}, + reason: "compact", + decisions: {}, + }, + }; + }, + }; +} + +describe("compaction atomicity", () => { + test("worker rewrite is atomic without an evidence archive", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + await store.writeTurns([turn("w1"), turn("w2"), turn("w3")]); + await store.writeMetadata(EMPTY_META); + await store.commit({ message: "worker-old" }); + + await store.writeTurns([turn("[Compacted prior context]"), turn("w3")]); + expect(texts((await store.load()).turns)).toEqual(["w1", "w2", "w3"]); + + const interrupted = await createOptimizedContextStore(dir); + expect(texts((await interrupted.load()).turns)).toEqual(["w1", "w2", "w3"]); + + await store.commit({ message: "worker-new" }); + expect(texts((await store.load()).turns)).toEqual(["[Compacted prior context]", "w3"]); + }); + + test("primary incomplete certifyRange refuses destructive compact", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "primary", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const hit = blobs.get(key); + if (hit === undefined) throw new Error(`missing ${key}`); + return hit; + }, + }); + const wrapped = wrapCompactorWithCompletenessGate(truncatingCompactor(), archive); + const history = [ + turn("fact-a"), + { + role: "assistant" as const, + content: [ + { type: "tool_call" as const, id: "c1", name: "read_file", arguments: { path: "x" } }, + ], + timestamp: 2, + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + callId: "c1", + content: [{ type: "text" as const, text: "body" }], + }, + ], + timestamp: 3, + }, + ]; + const result = await wrapped.apply(history, ctx); + expect(result.output).toBe(history); + expect(result.blobs).toBeUndefined(); + expect(result.record.reason).toBe("incomplete-evidence-archive"); + }); + + test("primary complete rewrite publishes turns and evidence together", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + const history = [ + turn("fact-a"), + { + role: "assistant" as const, + content: [ + { type: "tool_call" as const, id: "c1", name: "read_file", arguments: { path: "x" } }, + ], + timestamp: 2, + }, + { + role: "user" as const, + content: [ + { + type: "tool_result" as const, + callId: "c1", + content: [{ type: "text" as const, text: "body" }], + }, + ], + timestamp: 3, + }, + ]; + await store.writeTurns(history); + await store.writeMetadata(EMPTY_META); + const oldCommit = await store.commit({ message: "primary-old" }); + + const archive = createCompactionArchive({ + sessionId: "primary", + contextDir: dir, + writeBlob: (key, bytes, contentType) => store.writeBlob(key, bytes, contentType), + readBlob: (key) => store.readBlob(key), + }); + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "fact-a", + }); + await archive.recordAuthorizedPayload({ + kind: "tool_args", + payload: { name: "read_file", arguments: { path: "x" } }, + callId: "c1", + }); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "body", + callId: "c1", + }); + + const wrapped = wrapCompactorWithCompletenessGate(truncatingCompactor(), archive); + const result = await wrapped.apply(history, ctx); + expect(result.output).not.toBe(history); + expect(result.record.reason).toBe("compact"); + + await store.writeTurns(result.output); + expect((await store.load()).turns).toHaveLength(3); + expect(await store.readAt(oldCommit.hash)).toHaveLength(3); + + if (result.blobs) { + for (const blob of result.blobs) { + await store.writeBlob(blob.key, blob.bytes, blob.contentType); + } + } + await store.writeMetadata(EMPTY_META); + await store.commit({ message: "primary-compact" }); + + const loaded = await store.load(); + expect(texts(loaded.turns)).toEqual(["[Compacted prior context]", "kept-tail"]); + expect(await store.readAt(oldCommit.hash)).toHaveLength(3); + + const proc = Bun.spawn(["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + await proc.exited; + expect(stdout).toContain("evidence-archive/index.jsonl"); + }); +}); diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 447733607..f863431d8 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -308,6 +308,20 @@ together. **Re-carry:** clean three-way at `0205b07b`, zero conflicts. Low risk; tracks the skip-unchanged-history patch. +## reactor-ts-compact-publish-then-memory + +`reactor.ts` — `executeCompact` persists blobs, stages `writeTurns`, and +leaves reactor memory on the old generation until `commitCycle` publishes. +`replaceTurns` runs only after a successful commit. `commitCycle` must not +`writeTurns` live (old) memory over that staging. + +**Disposition:** Promotion candidate. Compaction durability — an interrupt +between stage and commit must resume the complete old generation, not a +half-applied rewrite. **Removal path:** Upstream PR reordering compact +persist/stage/commit/replaceTurns. +**Re-carry:** new patch on this pin; expect a three-way against +`executeCompact` and `commitCycle` on the next sync. + ## sse-ts-max-line-length `sse.ts` — `MAX_LINE_LENGTH` (16 MiB) caps the unterminated SSE line buffer @@ -368,6 +382,7 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`). | reactor-ts-correlating-ids-leak | Wrap `tryCorrelate` in try/finally so `correlatingIds` clears on success dispatch paths | Alexander Guy | This ledger (#reactor-ts-correlating-ids-leak) | Next vendored sync | | reactor-ts-checkpoint-after-tool-cycle | Call `commitCycle()` in `executeTools` when `addToHistory` is true | Alexander Guy | This ledger (#reactor-ts-checkpoint-after-tool-cycle) | Next vendored sync | | reactor-ts-skip-unchanged-history (+ reactor-ts-last-written-turns-revision) | Skip `contextStore.writeTurns` when `getTurnsRevision()` is unchanged | Alexander Guy | This ledger (#reactor-ts-skip-unchanged-history) | Next vendored sync | +| reactor-ts-compact-publish-then-memory | Persist compact blobs, stage `writeTurns`, commit, then `replaceTurns` so memory stays on the old generation until publication | Alexander Guy | This ledger (#reactor-ts-compact-publish-then-memory) | Next vendored sync | | reactor-ts-after-checkpoint-director-only | Gate `afterCheckpoint` on `hasOverride` so auto-commits do not emit it | Alexander Guy | This ledger (#reactor-ts-after-checkpoint-director-only) | Next vendored sync | | sse-ts-max-line-length | Cap the unterminated SSE line buffer (`MAX_LINE_LENGTH`, 16 MiB) | Alexander Guy | This ledger (#sse-ts-max-line-length) | Next vendored sync | | state-ts-deep-freeze-turns-revision | Make `ReactorState.snapshot().turns` a lazy, revision-tracked getter | Alexander Guy | This ledger (#state-ts-deep-freeze-turns-revision) | Next vendored sync | diff --git a/vendor/intx-inference/src/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 9f657f2da..c9062e877 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -3225,12 +3225,17 @@ describe("createReactor — state snapshot inspection", () => { if (event.type === "message.received") { messageCount++; if (messageCount === 1) { - // Mutate the snapshot's content block. + // Mutate the snapshot's content block. Frozen turns throw; + // isolation still holds if the assignment is ignored. const msg = state.turns[0]; if (msg !== undefined) { const block = msg.content[0]; if (block !== undefined && block.type === "text") { - (block as { text: string }).text = "CORRUPTED"; + try { + (block as { text: string }).text = "CORRUPTED"; + } catch { + /* deepFreeze */ + } } } return caps.wait(); @@ -5270,13 +5275,17 @@ function truncatingCompactor(name: string): Compactor { }; } -function makeRecordingContextStore(): { +function makeRecordingContextStore(opts?: { + failCommit?: boolean; + initialTurns?: ConversationTurn[]; +}): { store: ContextStore; commits: { message: string; turns: ConversationTurn[] }[]; manifests: TransformRecord[][]; metadata: { pendingOperations: PendingOperation[]; tokenUsage: TokenUsage }[]; blobs: { key: string; bytes: Uint8Array; contentType?: string }[]; lastWrittenTurns: ConversationTurn[]; + writeTurnsCalls: ConversationTurn[][]; } { const commits: { message: string; turns: ConversationTurn[] }[] = []; const manifests: TransformRecord[][] = []; @@ -5285,12 +5294,13 @@ function makeRecordingContextStore(): { tokenUsage: TokenUsage; }[] = []; const blobs: { key: string; bytes: Uint8Array; contentType?: string }[] = []; + const writeTurnsCalls: ConversationTurn[][] = []; let lastWrittenTurns: ConversationTurn[] = []; const store: ContextStore = { async load() { return { - turns: [], + turns: opts?.initialTurns !== undefined ? [...opts.initialTurns] : [], pendingOperations: [], tokenUsage: emptyUsage(), connectorState: null, @@ -5300,6 +5310,9 @@ function makeRecordingContextStore(): { /* noop */ }, async commit(options) { + if (opts?.failCommit === true) { + throw new Error("commit failed"); + } commits.push({ message: options.message, turns: [...lastWrittenTurns], @@ -5339,6 +5352,7 @@ function makeRecordingContextStore(): { manifests.push([...records]); }, async writeTurns(turns) { + writeTurnsCalls.push([...turns]); lastWrittenTurns = [...turns]; }, async writeMetadata(m) { @@ -5358,6 +5372,7 @@ function makeRecordingContextStore(): { manifests, metadata, blobs, + writeTurnsCalls, get lastWrittenTurns() { return lastWrittenTurns; }, @@ -5621,6 +5636,44 @@ describe("createReactor — transform chain ordering and compact action", () => expect(flatRecords.some((r) => r.strategy === "tail-only")).toBe(true); }); + test("compact stages writeTurns and replaces memory only after commit", async () => { + const seed: ConversationTurn[] = [ + { role: "user", content: [{ type: "text", text: "a" }], timestamp: 1 }, + { role: "user", content: [{ type: "text", text: "b" }], timestamp: 2 }, + { role: "user", content: [{ type: "text", text: "c" }], timestamp: 3 }, + ]; + const recording = makeRecordingContextStore({ + failCommit: true, + initialTurns: seed, + }); + const seenLengths: number[] = []; + const director: ReactorDirector = { + async decide(event, state, caps) { + if (event.type === "message.received") { + seenLengths.push(state.turns.length); + if (seenLengths.length === 1) { + return caps.compact("tail-only", "explicit-test"); + } + return caps.done(); + } + return caps.done(); + }, + }; + const { reactor, waitFor } = createDirectReactor({ + contextStore: recording.store, + director, + compactors: { "tail-only": truncatingCompactor("tail-only") }, + }); + reactor.start(); + reactor.deliver(makeInboundMessage()); + setTimeout(() => reactor.deliver(makeInboundMessage()), 30); + await waitFor("reactor.done"); + + expect(recording.writeTurnsCalls.some((turns) => turns.length === 1)).toBe(true); + expect(recording.commits).toHaveLength(0); + expect(seenLengths[1]).toBeGreaterThan(1); + }); + test("compact for an unknown name emits a fatal error and shuts down", async () => { const recording = makeRecordingContextStore(); const { reactor, events, waitFor } = createDirectReactor({ diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index 5570cfd15..d7eafd27c 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -407,6 +407,8 @@ export function createReactor(config: ReactorConfig): Reactor { let cycleInferred = false; let cycleToolCallsExecuted = 0; let cycleCompactorName: string | null = null; + // Compacted turns stay off reactor memory until the cycle commit publishes. + let pendingCompactOutput: ConversationTurn[] | null = null; // A suspension registers a gate and may persist a pending operation. That is // a durable state change even when the cycle ran no inference and completed // no tool call, so it must force the cycle commit. @@ -1009,10 +1011,10 @@ export function createReactor(config: ReactorConfig): Reactor { }; const result = await compactor.apply(stateManager.getTurns(), ctx); - stateManager.replaceTurns(result.output); - await contextStore.writeTurns(result.output); - lastWrittenTurnsRevision = stateManager.getTurnsRevision(); + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-compact-publish-then-memory await persistBlobs(result.blobs); + await contextStore.writeTurns(result.output); + pendingCompactOutput = result.output; manifestBuffer.push(result.record); cycleCompactorName = compactor.name; @@ -1075,7 +1077,9 @@ export function createReactor(config: ReactorConfig): Reactor { try { // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-skip-unchanged-history const currentRevision = stateManager.getTurnsRevision(); - if (currentRevision !== lastWrittenTurnsRevision) { + // A staged compact already wrote the new generation. Do not writeTurns + // live memory over that staging — memory still holds the old turns. + if (pendingCompactOutput === null && currentRevision !== lastWrittenTurnsRevision) { await contextStore.writeTurns(stateManager.getTurns()); lastWrittenTurnsRevision = currentRevision; } @@ -1083,6 +1087,11 @@ export function createReactor(config: ReactorConfig): Reactor { await writeMetadata(); const commit = await contextStore.commit({ message }); lastCheckpointHash = commit.hash; + if (pendingCompactOutput !== null) { + stateManager.replaceTurns(pendingCompactOutput); + lastWrittenTurnsRevision = stateManager.getTurnsRevision(); + pendingCompactOutput = null; + } } catch (cause) { logger.error`Cycle commit failed: ${cause}`; emitError( From b7b0c6bd2fd7d56f44888415d2aa1c07fdde9e8e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 10:34:53 -0700 Subject: [PATCH 06/16] Keep failed compact commits from replacing live history A failed compact commit must not later replaceTurns stale output, and an unpublished rewrite stays off the live generation until git commit succeeds. --- src/session/assemble-runtime.ts | 11 ++++ src/session/attachment-store.test.ts | 27 +++++++++ src/session/compaction-archive.test.ts | 40 +++++++++++++ src/session/compaction-archive.ts | 32 +++++++++-- src/session/optimized-context-store.test.ts | 23 ++++++++ src/session/optimized-context-store.ts | 16 ++++-- vendor/intx-inference/PATCHES.md | 4 +- vendor/intx-inference/src/reactor.test.ts | 63 +++++++++++++++++++++ vendor/intx-inference/src/reactor.ts | 3 + 9 files changed, 209 insertions(+), 10 deletions(-) diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index da8b7257e..3b9666a52 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -58,6 +58,7 @@ import { applyRecordingPolicyToText, createCompactionArchive, createPrimaryDeliveryAdmission, + hashAuthorizedBytes, wrapAuthorizeWithEvidenceArchive, wrapCompactorWithCompletenessGate, type CompactionArchive, @@ -491,6 +492,16 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { ? storage : { ...storage, + async writeBlob(key, bytes, contentType, signal) { + await storage.writeBlob(key, bytes, contentType, signal); + if (!key.startsWith("img-")) return; + await primaryArchive.recordExistingBlobReference({ + kind: "attachment", + blobKey: key, + contentHash: hashAuthorizedBytes(bytes), + provenance: "persistBlobs:aged-image", + }); + }, async writeResponse(turn, signal) { const content = turn.content.map((block) => { if (block.type !== "text") return block; diff --git a/src/session/attachment-store.test.ts b/src/session/attachment-store.test.ts index 30db586f6..2691c272b 100644 --- a/src/session/attachment-store.test.ts +++ b/src/session/attachment-store.test.ts @@ -119,4 +119,31 @@ describe("ageImageBlocks / rehydrateAttachmentImages", () => { expect(defined(result.output[0]).content).toEqual([{ type: "text", text }]); expect(result.record.decisions.restoredImageCount).toBe(0); }); + + test("aging base64 images does not record before persistBlobs", async () => { + const recorded: string[] = []; + const archive = { + recordAuthorizedPayload: async () => { + recorded.push("payload"); + return {} as never; + }, + recordExistingBlobReference: async () => { + recorded.push("existing"); + return {} as never; + }, + }; + const turn: ConversationTurn = { + role: "user", + content: [ + { + type: "image", + source: { kind: "base64", mimeType: "image/png", data: PNG_B64 }, + }, + ], + timestamp: 1, + }; + const aged = await ageImageBlocks(turn, { archive: archive as never }); + expect(aged.blobs).toHaveLength(1); + expect(recorded).toEqual([]); + }); }); diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index 42c3e37f3..4518cd3bf 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -680,4 +680,44 @@ describe("wrapCompactorWithCompletenessGate", () => { expect(result.output).toHaveLength(1); expect(result.record.reason).toBe("compact"); }); + + test("image-only dropped turns require covering attachment evidence", async () => { + const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { archive } = memoryArchive(); + const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive); + const png = "iVBORw0KGgo="; + const turns: import("@intx/types/runtime").ConversationTurn[] = [ + { + role: "user", + content: [ + { + type: "image", + source: { kind: "base64", mimeType: "image/png", data: png }, + }, + ], + timestamp: 1, + }, + { + role: "user", + content: [{ type: "text", text: "keep" }], + timestamp: 2, + }, + ]; + + const blocked = await wrapped.apply(turns, ctx); + expect(blocked.output).toBe(turns); + expect(blocked.record.reason).toBe("incomplete-evidence-archive"); + + await archive.recordAuthorizedPayload({ + kind: "attachment", + payload: png, + }); + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "keep", + }); + const allowed = await wrapped.apply(turns, ctx); + expect(allowed.output).toHaveLength(1); + expect(allowed.record.reason).toBe("compact"); + }); }); diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index ca606ada4..1281dc4a3 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -25,6 +25,7 @@ import { type ArchiveKind, type ToolRecordingLifecycle, } from "./compaction-archive-schema.js"; +import { parseAgedImageMarker } from "./attachment-uri.js"; const INDEX_DIR = "evidence-archive"; const INDEX_FILE = "index.jsonl"; @@ -626,10 +627,12 @@ function textKindForRole(role: ConversationTurn["role"]): ArchiveKind { function coveringOccurrence( units: readonly { - kind: "text" | "tool_call" | "tool_result"; + kind: "text" | "tool_call" | "tool_result" | "image"; text?: string; role?: ConversationTurn["role"]; callId?: string; + data?: string; + blobKey?: string; }[], occurrences: readonly ArchiveOccurrence[], ): { ids: string[]; unmatched: boolean } { @@ -638,6 +641,12 @@ function coveringOccurrence( for (const unit of units) { const match = occurrences.find((occ) => { if (used.has(occ.occurrenceId) || occ.gap === true) return false; + if (unit.kind === "image") { + if (occ.kind !== "attachment") return false; + if (unit.blobKey !== undefined) return occ.blobKey === unit.blobKey; + if (unit.data === undefined) return false; + return occ.contentHash === hashAuthorizedBytes(new TextEncoder().encode(unit.data)); + } if (unit.kind === "text") { if (unit.role === undefined || unit.text === undefined) return false; if (occ.kind !== textKindForRole(unit.role)) return false; @@ -655,25 +664,40 @@ function coveringOccurrence( } function droppedContentUnits(dropped: readonly ConversationTurn[]): { - kind: "text" | "tool_call" | "tool_result"; + kind: "text" | "tool_call" | "tool_result" | "image"; text?: string; role?: ConversationTurn["role"]; callId?: string; + data?: string; + blobKey?: string; }[] { const units: { - kind: "text" | "tool_call" | "tool_result"; + kind: "text" | "tool_call" | "tool_result" | "image"; text?: string; role?: ConversationTurn["role"]; callId?: string; + data?: string; + blobKey?: string; }[] = []; for (const turn of dropped) { for (const block of turn.content) { if (block.type === "text" && block.text.length > 0) { - units.push({ kind: "text", role: turn.role, text: block.text }); + const marker = parseAgedImageMarker(block.text); + if (marker !== undefined) { + units.push({ kind: "image", blobKey: marker.id }); + } else { + units.push({ kind: "text", role: turn.role, text: block.text }); + } } else if (block.type === "tool_call") { units.push({ kind: "tool_call", callId: block.id }); } else if (block.type === "tool_result") { units.push({ kind: "tool_result", callId: block.callId }); + } else if (block.type === "image") { + if (block.source.kind === "base64") { + units.push({ kind: "image", data: block.source.data }); + } else { + units.push({ kind: "image" }); + } } } } diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index 5d5f0b336..cee954252 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -782,6 +782,29 @@ describe("createOptimizedContextStore unpublished rewrite", () => { expect(turnTexts(loaded.turns)).toEqual(["old-a", "old-b"]); }); + test("git commit failure after rewrite lands keeps load on HEAD", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + const original = [turn("keep-a"), turn("keep-b"), turn("drop-me")]; + await store.writeTurns(original); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + const published = await store.commit({ message: "published original" }); + + await store.writeTurns([turn("[Compacted prior context]"), turn("keep-b")]); + + const hookDir = path.join(dir, ".git", "hooks"); + fs.mkdirSync(hookDir, { recursive: true }); + const hook = path.join(hookDir, "commit-msg"); + fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n"); + fs.chmodSync(hook, 0o755); + + await expect(store.commit({ message: "publish compact" })).rejects.toThrow(); + + const loaded = await store.load(); + expect(turnTexts(loaded.turns)).toEqual(["keep-a", "keep-b", "drop-me"]); + expect(turnTexts(await store.readAt(published.hash))).toEqual(["keep-a", "keep-b", "drop-me"]); + }); + test("append writeTurns is still visible before commit", async () => { const dir = tempDir(); const store = await createOptimizedContextStore(dir); diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index c2f7bcf03..377bb78a0 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -438,7 +438,7 @@ export async function createSessionStores( const base = await createIsogitStore(dir, signer); const pendingBlobFilepaths = new Set(); const pendingSegmentPaths = new Set(); - const writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE); + let writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE); const writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE); let liveTurnRefs: readonly ConversationTurn[] | null = null; let unpublishedRewrite: ConversationTurn[] | null = null; @@ -641,10 +641,9 @@ export async function createSessionStores( }, async commit(options, signal) { return withResolvedDirLock(dir, async () => { - if (unpublishedRewrite !== null) { - await writeSegmented(writeTurnsSegmented, unpublishedRewrite); - liveTurnRefs = unpublishedRewrite; - unpublishedRewrite = null; + const stagedRewrite = unpublishedRewrite; + if (stagedRewrite !== null) { + await writeSegmented(writeTurnsSegmented, stagedRewrite); } const toAdd: string[] = []; const toRemove: string[] = []; @@ -686,9 +685,16 @@ export async function createSessionStores( const committed = await base.commit(options, signal); pendingBlobFilepaths.clear(); pendingSegmentPaths.clear(); + if (stagedRewrite !== null) { + liveTurnRefs = stagedRewrite; + unpublishedRewrite = null; + } return committed; } catch (cause) { await resetIndexPaths(dir, extraPaths); + if (stagedRewrite !== null) { + liveTurnRefs = null; + } throw cause; } }); diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index f863431d8..087156c13 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -313,7 +313,9 @@ tracks the skip-unchanged-history patch. `reactor.ts` — `executeCompact` persists blobs, stages `writeTurns`, and leaves reactor memory on the old generation until `commitCycle` publishes. `replaceTurns` runs only after a successful commit. `commitCycle` must not -`writeTurns` live (old) memory over that staging. +`writeTurns` live (old) memory over that staging. A failed commit clears +`pendingCompactOutput` so a later infer/tools cycle writes live history and +does not `replaceTurns` with the unpublished compact. **Disposition:** Promotion candidate. Compaction durability — an interrupt between stage and commit must resume the complete old generation, not a diff --git a/vendor/intx-inference/src/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index c9062e877..b8a56a0b4 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -5277,6 +5277,7 @@ function truncatingCompactor(name: string): Compactor { function makeRecordingContextStore(opts?: { failCommit?: boolean; + failCommitRemaining?: { n: number }; initialTurns?: ConversationTurn[]; }): { store: ContextStore; @@ -5313,6 +5314,10 @@ function makeRecordingContextStore(opts?: { if (opts?.failCommit === true) { throw new Error("commit failed"); } + if (opts?.failCommitRemaining !== undefined && opts.failCommitRemaining.n > 0) { + opts.failCommitRemaining.n -= 1; + throw new Error("commit failed"); + } commits.push({ message: options.message, turns: [...lastWrittenTurns], @@ -5674,6 +5679,64 @@ describe("createReactor — transform chain ordering and compact action", () => expect(seenLengths[1]).toBeGreaterThan(1); }); + test("failed compact commit does not replaceTurns stale output on a later infer cycle", async () => { + const seed: ConversationTurn[] = [ + { role: "user", content: [{ type: "text", text: "a" }], timestamp: 1 }, + { role: "user", content: [{ type: "text", text: "b" }], timestamp: 2 }, + { role: "user", content: [{ type: "text", text: "c" }], timestamp: 3 }, + ]; + const recording = makeRecordingContextStore({ + failCommitRemaining: { n: 1 }, + initialTurns: seed, + }); + let inspectLength = 0; + let messages = 0; + const director: ReactorDirector = { + async decide(event, state, caps) { + if (event.type === "message.received") { + messages++; + if (messages === 1) { + return caps.compact("tail-only", "explicit-test"); + } + if (messages === 2) { + return caps.infer(); + } + inspectLength = state.turns.length; + return caps.done(); + } + if (event.type === "inference.done") { + return caps.wait(); + } + return caps.done(); + }, + }; + const { reactor, waitFor } = createDirectReactor({ + contextStore: recording.store, + director, + compactors: { "tail-only": truncatingCompactor("tail-only") }, + inferenceRunner: mockInferenceRunner("live-after-failed-compact"), + }); + reactor.start(); + reactor.deliver(makeInboundMessage()); + setTimeout(() => reactor.deliver(makeInboundMessage()), 30); + setTimeout(() => reactor.deliver(makeInboundMessage()), 80); + await waitFor("reactor.done"); + + const liveWrites = recording.writeTurnsCalls.filter((turns) => + turns.some( + (turn) => + turn.role === "assistant" && + turn.content.some((b) => b.type === "text" && b.text === "live-after-failed-compact"), + ), + ); + expect(liveWrites.length).toBeGreaterThan(0); + expect(liveWrites.some((turns) => turns.length === 1)).toBe(false); + expect(inspectLength).toBeGreaterThan(1); + const inferCommit = recording.commits.find((c) => c.message.startsWith("Cycle: inferred")); + expect(inferCommit).toBeDefined(); + expect(inferCommit?.turns.length).toBeGreaterThan(1); + }); + test("compact for an unknown name emits a fatal error and shuts down", async () => { const recording = makeRecordingContextStore(); const { reactor, events, waitFor } = createDirectReactor({ diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index d7eafd27c..b9cd1e332 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -1098,6 +1098,9 @@ export function createReactor(config: ReactorConfig): Reactor { `Cycle commit failed: ${cause instanceof Error ? cause.message : String(cause)}`, false, ); + // A staged compact must not leak into a later infer/tools cycle: skip-write + // plus replaceTurns would publish stale compact output over live memory. + pendingCompactOutput = null; resetCycleAccumulators(); return; } From 0f6804f68bee752d8ceaddb6f3bff290ce1406a3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 11:13:24 -0700 Subject: [PATCH 07/16] Add primary archive search and read tools --- src/agent/archive-tools.test.ts | 211 ++++++++++++++++++ src/agent/archive-tools.ts | 180 +++++++++++++++ src/agent/posix-tool-plugins.ts | 2 + src/agent/prompts.ts | 4 + src/agent/tool-search.ts | 2 + src/agent/tools.ts | 4 + src/permission/gate.ts | 2 + .../evidence-archive-path-guard.test.ts | 72 ++++++ src/plugins/evidence-archive-path-guard.ts | 38 ++++ src/session/archive-uri.test.ts | 15 ++ src/session/archive-uri.ts | 15 ++ src/telemetry/classify.ts | 2 + src/tui/session-chrome.ts | 36 +++ 13 files changed, 583 insertions(+) create mode 100644 src/agent/archive-tools.test.ts create mode 100644 src/agent/archive-tools.ts create mode 100644 src/plugins/evidence-archive-path-guard.test.ts create mode 100644 src/plugins/evidence-archive-path-guard.ts create mode 100644 src/session/archive-uri.test.ts create mode 100644 src/session/archive-uri.ts diff --git a/src/agent/archive-tools.test.ts b/src/agent/archive-tools.test.ts new file mode 100644 index 000000000..cf4e979de --- /dev/null +++ b/src/agent/archive-tools.test.ts @@ -0,0 +1,211 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import type { AgentTool } from "@intx/agent"; +import { CATALOG_TOOL_NAMES, CORE_TOOL_NAMES } from "./tool-search.js"; +import { + createReadArchiveTool, + createSearchArchiveTool, + readArchiveDefinition, + searchArchiveDefinition, +} from "./archive-tools.js"; +import { formatArchiveRef } from "../session/archive-uri.js"; +import { createCompactionArchive, type CompactionArchive } from "../session/compaction-archive.js"; + +function call(tool: AgentTool, args: Record): Promise { + if (tool.kind !== "string") throw new Error("expected string tool"); + return tool.handler(args, new AbortController().signal); +} + +function memoryArchive(sessionId: string): CompactionArchive { + const dir = mkdtempSync(join(tmpdir(), "archive-tools-")); + const blobs = new Map(); + return createCompactionArchive({ + sessionId, + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing blob ${key}`); + return bytes; + }, + }); +} + +function wrapReads(archive: CompactionArchive): string[] { + const ids: string[] = []; + const orig = archive.readAuthorizedPayload.bind(archive); + archive.readAuthorizedPayload = async (occurrenceId) => { + ids.push(occurrenceId); + return orig(occurrenceId); + }; + return ids; +} + +describe("archive tool definitions", () => { + test("catalog advertises search_archive and read_archive; they are not CORE", () => { + expect(CORE_TOOL_NAMES).not.toContain("search_archive"); + expect(CORE_TOOL_NAMES).not.toContain("read_archive"); + expect(CATALOG_TOOL_NAMES).toContain("search_archive"); + expect(CATALOG_TOOL_NAMES).toContain("read_archive"); + expect(searchArchiveDefinition.description).toContain("archive:///"); + expect(readArchiveDefinition.description).toContain("archive:///"); + }); +}); + +describe("search_archive and read_archive", () => { + test("search returns archive:/// refs and read returns the payload", async () => { + const archive = memoryArchive("sess-primary"); + const occ = await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "unique-payload-alpha", + provenance: "primary-admission", + }); + const search = createSearchArchiveTool(() => archive); + const read = createReadArchiveTool(() => archive); + + const hits = await call(search, { query: "unique-payload-alpha" }); + expect(hits).toContain(formatArchiveRef(occ.occurrenceId)); + expect(hits).toContain("user_message"); + expect(hits).not.toContain(occ.sessionId); + expect(hits).not.toContain(occ.blobKey); + + const body = await call(read, { ref: formatArchiveRef(occ.occurrenceId) }); + expect(body).toContain("unique-payload-alpha"); + expect(body).not.toContain(occ.blobKey); + }); + + test("gap rows match metadata only and never load payload", async () => { + const archive = memoryArchive("sess-gap"); + const reads = wrapReads(archive); + const gap = await archive.recordAuthorizedPayload({ + kind: "attachment", + payload: { secret: "gap-payload-must-not-search" }, + provenance: "primary-admission:attachment-missing", + gap: true, + }); + const search = createSearchArchiveTool(() => archive); + const read = createReadArchiveTool(() => archive); + + const payloadHits = await call(search, { query: "gap-payload-must-not-search" }); + expect(payloadHits).toContain("No evidence-archive occurrences matched"); + expect(reads).toEqual([]); + + const metaHits = await call(search, { query: "attachment-missing" }); + expect(metaHits).toContain(formatArchiveRef(gap.occurrenceId)); + expect(metaHits).toContain("gap"); + expect(reads).toEqual([]); + + const body = await call(read, { ref: formatArchiveRef(gap.occurrenceId) }); + expect(body).toContain("explicit gap"); + expect(reads).toEqual([gap.occurrenceId]); + }); + + test("forged and other-session refs are not found", async () => { + const primary = memoryArchive("sess-a"); + const other = memoryArchive("sess-b"); + const foreign = await other.recordAuthorizedPayload({ + kind: "assistant_text", + payload: "other-session-only", + }); + const read = createReadArchiveTool(() => primary); + const search = createSearchArchiveTool(() => primary); + + const forged = await call(read, { ref: "archive:///occ-forged-not-in-index" }); + expect(forged).toContain("unknown occurrence"); + + const cross = await call(read, { ref: formatArchiveRef(foreign.occurrenceId) }); + expect(cross).toContain("unknown occurrence"); + + const hits = await call(search, { query: "other-session-only" }); + expect(hits).toContain("No evidence-archive occurrences matched"); + }); + + test("rejects sessionId, path, and blobKey locators", async () => { + const archive = memoryArchive("sess-locators"); + const occ = await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "locator-payload", + }); + const search = createSearchArchiveTool(() => archive); + const read = createReadArchiveTool(() => archive); + const ref = formatArchiveRef(occ.occurrenceId); + + for (const args of [ + { query: "locator-payload", sessionId: "sess-locators" }, + { query: "locator-payload", path: "evidence-archive/index.jsonl" }, + { query: "locator-payload", blobKey: occ.blobKey }, + ]) { + const out = await call(search, args); + expect(out).toContain("does not accept sessionId, path, or blobKey"); + } + + for (const args of [ + { ref, sessionId: "sess-locators" }, + { ref, path: "evidence-archive/index.jsonl" }, + { ref, blobKey: occ.blobKey }, + ]) { + const out = await call(read, args); + expect(out).toContain("does not accept sessionId, path, or blobKey"); + } + + const badRef = await call(read, { ref: occ.occurrenceId }); + expect(badRef).toContain("archive:///{occurrenceId}"); + }); + + test("read_archive pages with offset and limit via readBytesBounded", async () => { + const archive = memoryArchive("sess-page"); + const lines = Array.from({ length: 8 }, (_, i) => `archive-line-${i}`).join("\n"); + const occ = await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: lines, + }); + const read = createReadArchiveTool(() => archive); + const body = await call(read, { + ref: formatArchiveRef(occ.occurrenceId), + offset: 2, + limit: 2, + }); + expect(body).toContain("archive-line-2"); + expect(body).toContain("archive-line-3"); + expect(body).not.toContain("archive-line-0"); + expect(body).not.toContain("archive-line-4"); + expect(body).toContain("Use offset="); + }); +}); + +describe("createAgentToolset archive mount", () => { + test("mounts search_archive and read_archive only when getEvidenceArchive is set", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-archive-mount-")); + const { createAgentToolset } = await import("./tools.js"); + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; + + const worker = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + }); + const workerNames = worker.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(workerNames).not.toContain("search_archive"); + expect(workerNames).not.toContain("read_archive"); + await worker.dispose(); + + const primary = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + getEvidenceArchive: () => undefined, + }); + const primaryNames = primary.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(primaryNames).toContain("search_archive"); + expect(primaryNames).toContain("read_archive"); + await primary.dispose(); + }); +}); diff --git a/src/agent/archive-tools.ts b/src/agent/archive-tools.ts new file mode 100644 index 000000000..234a09420 --- /dev/null +++ b/src/agent/archive-tools.ts @@ -0,0 +1,180 @@ +import { stringTool } from "@intx/agent"; +import type { AgentTool } from "@intx/agent"; +import type { ToolDefinition } from "@intx/types/runtime"; +import { type } from "arktype"; + +import { + READ_FILE_DEFAULT_MAX_LINES, + readBytesBounded, +} from "../plugins/read-file-guard-plugin.js"; +import type { CompactionArchive } from "../session/compaction-archive.js"; +import type { ArchiveOccurrence } from "../session/compaction-archive-schema.js"; +import { formatArchiveRef, parseArchiveRef } from "../session/archive-uri.js"; + +const SEARCH_DEFAULT_LIMIT = 20; +const SEARCH_MAX_LIMIT = 50; + +export const searchArchiveDefinition: ToolDefinition = { + name: "search_archive", + description: + "Search this session's compaction evidence archive. Returns archive:///{occurrenceId} refs only — follow a hit with read_archive. Do not pass sessionId, path, or blobKey; do not read_file evidence-archive or tool-output/archive-* paths.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Keywords to match against kind, call id, provenance, or payload text.", + }, + limit: { + type: "number", + description: "Max hits to return (default 20, cap 50).", + }, + }, + required: ["query"], + }, +}; + +export const readArchiveDefinition: ToolDefinition = { + name: "read_archive", + description: + "Read one evidence-archive occurrence by archive:///{occurrenceId} from search_archive. Bounded like read_file (offset/limit). Do not pass sessionId, path, or blobKey.", + inputSchema: { + type: "object", + properties: { + ref: { + type: "string", + description: "An archive:///{occurrenceId} handle from search_archive.", + }, + offset: { + type: "number", + description: "Zero-based line skip, same as read_file.", + }, + limit: { + type: "number", + description: "Max lines to return.", + }, + }, + required: ["ref"], + }, +}; + +const SearchArchiveArgs = type({ + query: "string", + "limit?": "number", +}); + +const ReadArchiveArgs = type({ + ref: "string", + "offset?": "number", + "limit?": "number", +}); + +function forbiddenLocatorMessage(tool: string): string { + return `Error: ${tool} does not accept sessionId, path, or blobKey. Use archive:///{occurrenceId} refs only.`; +} + +function hasForbiddenLocatorArgs(rawArgs: Record): boolean { + return "sessionId" in rawArgs || "path" in rawArgs || "blobKey" in rawArgs; +} + +function formatHit(occ: ArchiveOccurrence): string { + const parts = [formatArchiveRef(occ.occurrenceId), occ.kind]; + if (occ.callId !== undefined) parts.push(`callId=${occ.callId}`); + if (occ.lifecycle !== undefined) parts.push(`lifecycle=${occ.lifecycle}`); + if (occ.provenance !== undefined) parts.push(`provenance=${occ.provenance}`); + if (occ.gap === true) parts.push("gap"); + return parts.join(" "); +} + +function metadataBlob(occ: ArchiveOccurrence): string { + return [occ.occurrenceId, occ.kind, occ.callId ?? "", occ.lifecycle ?? "", occ.provenance ?? ""] + .join(" ") + .toLowerCase(); +} + +export function createSearchArchiveTool( + getArchive: () => CompactionArchive | undefined, +): AgentTool { + return stringTool({ + definition: searchArchiveDefinition, + handler: async (rawArgs: Record): Promise => { + if (hasForbiddenLocatorArgs(rawArgs)) return forbiddenLocatorMessage("search_archive"); + const parsed = SearchArchiveArgs(rawArgs); + if (parsed instanceof type.errors) { + return "Error: search_archive requires query (string)."; + } + const archive = getArchive(); + if (archive === undefined) { + return "Error: evidence archive is not available in this session."; + } + const query = parsed.query.trim().toLowerCase(); + const limitRaw = parsed.limit; + const limit = + typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.floor(limitRaw), SEARCH_MAX_LIMIT) + : SEARCH_DEFAULT_LIMIT; + const occurrences = await archive.listOccurrences(); + const hits: string[] = []; + for (const occ of occurrences) { + if (hits.length >= limit) break; + if (query.length === 0 || metadataBlob(occ).includes(query)) { + hits.push(formatHit(occ)); + continue; + } + if (occ.gap === true) continue; + try { + const payload = await archive.readAuthorizedPayload(occ.occurrenceId); + if (payload.toLowerCase().includes(query)) hits.push(formatHit(occ)); + } catch { + continue; + } + } + if (hits.length === 0) { + return query.length === 0 + ? "No evidence-archive occurrences are recorded yet." + : `No evidence-archive occurrences matched "${parsed.query.trim()}".`; + } + return [ + "Matching evidence-archive occurrences (pass ref to read_archive):", + "", + ...hits, + ].join("\n"); + }, + }); +} + +export function createReadArchiveTool(getArchive: () => CompactionArchive | undefined): AgentTool { + return stringTool({ + definition: readArchiveDefinition, + handler: async (rawArgs: Record, signal: AbortSignal): Promise => { + if (hasForbiddenLocatorArgs(rawArgs)) return forbiddenLocatorMessage("read_archive"); + const parsed = ReadArchiveArgs(rawArgs); + if (parsed instanceof type.errors) { + return "Error: read_archive requires ref (archive:///{occurrenceId})."; + } + const occurrenceId = parseArchiveRef(parsed.ref.trim()); + if (occurrenceId === undefined) { + return "Error: read_archive ref must be archive:///{occurrenceId}."; + } + const archive = getArchive(); + if (archive === undefined) { + return "Error: evidence archive is not available in this session."; + } + const offset = + typeof parsed.offset === "number" && parsed.offset > 0 ? Math.floor(parsed.offset) : 0; + const limit = + typeof parsed.limit === "number" && parsed.limit > 0 + ? Math.floor(parsed.limit) + : READ_FILE_DEFAULT_MAX_LINES; + try { + const text = await archive.readAuthorizedPayload(occurrenceId); + const bytes = new TextEncoder().encode(text); + const display = formatArchiveRef(occurrenceId); + const result = await readBytesBounded(bytes, offset, limit, signal, display); + return result.content; + } catch (err) { + return `Error: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }); +} diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index 7b7976667..0d8c2d485 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -1,6 +1,7 @@ import type { ToolPlugin } from "@intx/tools-posix"; import { createLSPPlugin } from "@intx/tools-lsp"; import { pathEscapePlugin } from "../plugins/path-escape-plugin.js"; +import { evidenceArchivePathGuardPlugin } from "../plugins/evidence-archive-path-guard.js"; import { deleteFilePlugin } from "../plugins/delete-file-plugin.js"; import { secretGuardPlugin } from "../plugins/secret-guard-plugin.js"; import { authzPlugin } from "../plugins/authz-plugin.js"; @@ -104,6 +105,7 @@ export function buildCorePosixToolPlugins( resultTruncationPlugin(truncationOptions), toolResultSecretScrubPlugin(), pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }), + evidenceArchivePathGuardPlugin(), deleteFilePlugin(cwd, { allowOutside }), toolOutputUriPlugin(), secretGuardPlugin(), diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index ab768c929..38fc5ea58 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -261,6 +261,10 @@ const TOOL_SUMMARIES: Record = { "load a listed skill's full instructions before doing work it covers", skill_search: "look up skill descriptions by capability (catalog — call directly, do not tool_search for this)", + search_archive: + "search this session's compaction evidence archive; returns archive:///{occurrenceId} refs only", + read_archive: + "read one evidence-archive occurrence by archive:///{occurrenceId} from search_archive", }; export function buildAvailableTools( diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 13ca2f042..56d66a1a2 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -110,6 +110,8 @@ export const CATALOG_TOOL_NAMES: readonly string[] = [ "list_dir", "web_fetch", "web_search", + "search_archive", + "read_archive", "skill_search", ]; diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 67a713456..84cf29021 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -102,6 +102,7 @@ import { createUseSkillTool } from "./use-skill.js"; import { createSkillSearchTool } from "./skill-search.js"; import { createToolIndex, createToolSearchTool } from "./tool-search.js"; import { createSearchAgentsTool } from "./agent-search.js"; +import { createSearchArchiveTool, createReadArchiveTool } from "./archive-tools.js"; import { createReadAgentTraceTool } from "../subagent/trace-tool.js"; import { createCodexToolProxies, @@ -573,6 +574,9 @@ export async function createAgentToolset( : createWebFetchTool(), createWebSearchTool(), ...orchestratorTools, + ...(getEvidenceArchive !== undefined + ? [createSearchArchiveTool(getEvidenceArchive), createReadArchiveTool(getEvidenceArchive)] + : []), stringTool({ definition: shellCollect.definition, handler: shellCollect.handler, diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 514da28a9..368ad49fa 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -280,6 +280,8 @@ const AUTO_ALLOWED_TOOLS = new Set([ "use_skill", "skill_search", "search_agents", + "search_archive", + "read_archive", "spawn_agent", "wait_agents", ]); diff --git a/src/plugins/evidence-archive-path-guard.test.ts b/src/plugins/evidence-archive-path-guard.test.ts new file mode 100644 index 000000000..8096f1901 --- /dev/null +++ b/src/plugins/evidence-archive-path-guard.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import type { ToolCall, ToolResult } from "@intx/types/runtime"; +import { + evidenceArchivePathGuardPlugin, + isProtectedEvidenceLocation, +} from "./evidence-archive-path-guard.js"; + +function makeCall(name: string, args: Record): ToolCall { + return { id: "test-call", name, arguments: args }; +} + +const nextHandler = async (call: ToolCall): Promise => ({ + callId: call.id, + content: "ok", +}); + +describe("isProtectedEvidenceLocation", () => { + test("matches evidence-archive and tool-output/archive-* forms", () => { + expect(isProtectedEvidenceLocation("evidence-archive/index.jsonl")).toBe(true); + expect(isProtectedEvidenceLocation("/tmp/context/evidence-archive")).toBe(true); + expect(isProtectedEvidenceLocation("C:\\tmp\\evidence-archive\\index.jsonl")).toBe(true); + expect(isProtectedEvidenceLocation("tool-output/archive-sess-occ-1")).toBe(true); + expect(isProtectedEvidenceLocation("tool-output:///archive-sess-occ-1")).toBe(true); + expect(isProtectedEvidenceLocation("src/session/compaction-archive.ts")).toBe(false); + expect(isProtectedEvidenceLocation("tool-output:///other-spill")).toBe(false); + }); +}); + +describe("evidenceArchivePathGuardPlugin", () => { + test("denies path tools targeting evidence-archive or tool-output/archive-*", async () => { + const plugin = evidenceArchivePathGuardPlugin(); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const denied = [ + makeCall("read_file", { path: "evidence-archive/index.jsonl" }), + makeCall("grep", { path: "/tmp/context/evidence-archive", pattern: "foo" }), + makeCall("search_files", { path: "evidence-archive" }), + makeCall("list_dir", { path: "evidence-archive" }), + makeCall("write_file", { path: "evidence-archive/x", content: "nope" }), + makeCall("read_file", { path: "tool-output:///archive-sess-occ-1" }), + makeCall("read_file", { path: "tool-output/archive-sess-occ-1" }), + ]; + for (const call of denied) { + const result = await handler(call, new AbortController().signal); + expect(result.isError).toBe(true); + expect(String(result.content)).toContain("search_archive"); + expect(String(result.content)).toContain("archive:///"); + } + }); + + test("does not deny a grep pattern that mentions evidence-archive", async () => { + const plugin = evidenceArchivePathGuardPlugin(); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const result = await handler( + makeCall("grep", { path: "src", pattern: "evidence-archive" }), + new AbortController().signal, + ); + expect(result.isError).not.toBe(true); + expect(result.content).toBe("ok"); + }); + + test("passes ordinary workspace paths", async () => { + const plugin = evidenceArchivePathGuardPlugin(); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const result = await handler( + makeCall("read_file", { path: "src/session/compaction-archive.ts" }), + new AbortController().signal, + ); + expect(result.isError).not.toBe(true); + expect(result.content).toBe("ok"); + }); +}); diff --git a/src/plugins/evidence-archive-path-guard.ts b/src/plugins/evidence-archive-path-guard.ts new file mode 100644 index 000000000..c1d5648e6 --- /dev/null +++ b/src/plugins/evidence-archive-path-guard.ts @@ -0,0 +1,38 @@ +import type { ToolPlugin } from "@intx/tools-posix"; + +import { looksLikePath } from "./path-escape-plugin.js"; + +const PATH_TOOLS = new Set([ + "read_file", + "grep", + "search_files", + "list_dir", + "write_file", + "edit_file", + "delete_file", +]); + +const DENY_MESSAGE = + "Cannot read evidence-archive or tool-output/archive-* with this tool. Use search_archive and read_archive with archive:/// refs."; + +export function isProtectedEvidenceLocation(value: string): boolean { + const normalized = value.replaceAll("\\", "/"); + if (/(?:^|\/)evidence-archive(?:\/|$)/.test(normalized)) return true; + if (/(?:^|\/)tool-output\/archive-/.test(normalized)) return true; + if (/^tool-output:\/+archive-/.test(normalized)) return true; + return false; +} + +export function evidenceArchivePathGuardPlugin(): ToolPlugin { + return { + middleware: (next) => async (call, signal) => { + if (!PATH_TOOLS.has(call.name)) return next(call, signal); + for (const [key, value] of Object.entries(call.arguments)) { + if (typeof value === "string" && looksLikePath(key) && isProtectedEvidenceLocation(value)) { + return { callId: call.id, content: DENY_MESSAGE, isError: true }; + } + } + return next(call, signal); + }, + }; +} diff --git a/src/session/archive-uri.test.ts b/src/session/archive-uri.test.ts new file mode 100644 index 000000000..a85f10298 --- /dev/null +++ b/src/session/archive-uri.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; + +import { formatArchiveRef, parseArchiveRef } from "./archive-uri.js"; + +describe("archive URI", () => { + test("formats and parses archive:/// occurrence refs", () => { + expect(formatArchiveRef("occ-abc")).toBe("archive:///occ-abc"); + expect(parseArchiveRef("archive:///occ-abc")).toBe("occ-abc"); + expect(parseArchiveRef("archive:/occ-abc")).toBe("occ-abc"); + expect(parseArchiveRef("archive:///occ-abc?x=1")).toBe("occ-abc"); + expect(parseArchiveRef("occ-abc")).toBeUndefined(); + expect(parseArchiveRef("archive:///")).toBeUndefined(); + expect(parseArchiveRef("/tmp/evidence-archive/index.jsonl")).toBeUndefined(); + }); +}); diff --git a/src/session/archive-uri.ts b/src/session/archive-uri.ts new file mode 100644 index 000000000..2c3097e8e --- /dev/null +++ b/src/session/archive-uri.ts @@ -0,0 +1,15 @@ +export const ARCHIVE_URI_PREFIX = "archive:"; +const ARCHIVE_URI_CANONICAL = "archive:///"; + +export function formatArchiveRef(occurrenceId: string): string { + return `${ARCHIVE_URI_CANONICAL}${occurrenceId}`; +} + +/** Accept archive:///occ-… and common slashes; return the occurrence id or undefined. */ +export function parseArchiveRef(value: string): string | undefined { + if (!value.startsWith(ARCHIVE_URI_PREFIX)) return undefined; + const rest = value.slice(ARCHIVE_URI_PREFIX.length).replace(/^\/+/, ""); + const occurrenceId = rest.split(/[/?#]/)[0] ?? ""; + if (occurrenceId.length === 0) return undefined; + return occurrenceId; +} diff --git a/src/telemetry/classify.ts b/src/telemetry/classify.ts index 6b24af546..e4914fe7d 100644 --- a/src/telemetry/classify.ts +++ b/src/telemetry/classify.ts @@ -28,9 +28,11 @@ const BUILT_IN_TOOL_NAMES: ReadonlySet = new Set([ "lsp", "manage_tasks", "present", + "read_archive", "read_file", "run_shell", "search_agents", + "search_archive", "search_files", "spawn_agent", "wait_agents", diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts index 985fb0d90..c20db947c 100644 --- a/src/tui/session-chrome.ts +++ b/src/tui/session-chrome.ts @@ -52,6 +52,9 @@ export const ACTIVITY_STATES = [ "creating", "imagining", "inventing", + "planning", + "researching", + "building", "waiting", "stalled", "stopping", @@ -73,6 +76,38 @@ export const LIVE_ACTIVITY_WORDS = [ "inventing", ] as const; +/** + * Execution → activity-state mapping, kept in this one place with an + * explicit fallback so a newly added tool (built-in, MCP, or plugin) renders + * a generic "working" state instead of leaking its identifier — no ticker + * change is required to add a tool correctly. + */ +const TOOL_ACTIVITY_STATES: Readonly> = { + read_file: "researching", + search_files: "researching", + grep: "researching", + list_dir: "researching", + web_search: "researching", + web_fetch: "researching", + write_file: "building", + edit_file: "building", + run_shell: "building", + delete_file: "building", + manage_tasks: "planning", + task: "planning", + tool_search: "researching", + search_agents: "researching", + search_archive: "researching", + read_archive: "researching", + ask_operator: "waiting", + submit_output: "working", +}; + +function activityStateForTool(name: string | null): ActivityState { + if (name === null) return "working"; + return TOOL_ACTIVITY_STATES[name] ?? "working"; +} + /** How long each live-activity word holds before the next. */ export const LIVE_WORD_MS = 4_000; @@ -122,6 +157,7 @@ export function resolveTurnLabel( } if (!occupied) return undefined; void isStalled; + void activityStateForTool(input.currentToolName); return liveActivityWord(input.nowMs ?? 0); } From acbb6ee26624d8c41f7f3418cf0260e0dec197a1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 12:33:22 -0700 Subject: [PATCH 08/16] Serve the evidence archive through posix search and read Dedicated archive tools made the on-disk dump the implied retrieval API. Primary sessions now search and read archive:/// through search_files, grep, and read_file. Blob keys reject slash so they cannot nest under tool-output. --- docs/ARCHITECTURE.md | 3 +- docs/IMPLEMENTATION.md | 2 + src/agent/archive-tools.test.ts | 211 ------------ src/agent/archive-tools.ts | 180 ----------- src/agent/posix-tool-plugins.ts | 2 + src/agent/prompts.ts | 10 +- src/agent/tool-search.ts | 2 - src/agent/tools.ts | 16 +- src/permission/gate.ts | 2 - .../evidence-archive-path-guard.test.ts | 2 +- src/plugins/evidence-archive-path-guard.ts | 2 +- .../evidence-archive-search-plugin.test.ts | 259 +++++++++++++++ src/plugins/evidence-archive-search-plugin.ts | 299 ++++++++++++++++++ src/plugins/path-escape-plugin.test.ts | 16 + src/plugins/path-escape-plugin.ts | 3 +- src/session/archive-uri.test.ts | 17 +- src/session/archive-uri.ts | 15 +- src/session/compaction-archive.test.ts | 27 ++ src/session/compaction-archive.ts | 16 +- src/telemetry/classify.ts | 2 - src/tui/ramp-paint.test.ts | 2 +- src/tui/session-chrome.ts | 2 - 22 files changed, 662 insertions(+), 428 deletions(-) delete mode 100644 src/agent/archive-tools.test.ts delete mode 100644 src/agent/archive-tools.ts create mode 100644 src/plugins/evidence-archive-search-plugin.test.ts create mode 100644 src/plugins/evidence-archive-search-plugin.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b96ff740a..0c5204ea1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -364,7 +364,8 @@ tool call **Rejection behavior:** Any plugin can short-circuit by returning a `ToolResult` with `isError: true`; the error propagates to the agent and downstream plugins/execution are skipped. - **Result truncation / leisure materialization** (`result-truncation-plugin.ts`, `tool-result-materialize.ts`) — Caps model-facing tool results at 10,000 chars (aligned with the reactor size-cap). Over the gate, any non-error result is leisure-materialized first (minified JSON → pretty `application/json`; NDJSON preserved; else `text/plain`), then the formatted bytes are spilled to the session blob store under `{callId}:full` and truncated inline with a `tool-output:///` URI plus absolute `contextDir/tool-output/…` path when plumbed. Under-gate results are unchanged (no pretty, no spill). Posix tools go through the middleware in `buildCorePosixToolPlugins` (Codex `posixTools.run` included). Fleet AgentTools (`wait_agents`, `search_agents`, …) skip that posix chain, so the same helper wraps them at mount in `createAgentToolset` and nested `runSubAgent`. MCP tools apply the same scrub-then-truncate path via `mcpClientToAgentTools` since they skip both. -- **Path Escape** (`path-escape-plugin.ts`) — Canonicalizes path-like arguments against `cwd` and blocks `..` escapes, except into a root the permission layer's worktree-roots provider allowlists (e.g. a sibling git worktree of the same repo). Runs first so later plugins see resolved paths. +- **Path Escape** (`path-escape-plugin.ts`) — Canonicalizes path-like arguments against `cwd` and blocks `..` escapes, except into a root the permission layer's worktree-roots provider allowlists (e.g. a sibling git worktree of the same repo). `tool-output://` and `archive:///` refs pass through unresolved. Runs first so later plugins see resolved paths. +- **Evidence archive** (`evidence-archive-search-plugin.ts`, `evidence-archive-path-guard.ts`) — Primary-session compaction evidence is a first-class search/read surface on `search_files` / `read_file` / `grep` via `archive:///` refs. Dump paths (`evidence-archive/`, `tool-output/archive-*`) stay blocked so the on-disk sidecar is not the retrieval API. Blob keys reject `/` so they cannot nest under `tool-output`. - **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched). - **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output. - **Authorization** (`run-shell-authz.ts`, wired by `authz-plugin.ts`) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The permission gate’s shell auto-allow path consults the same policy so it never pre-approves a command authz would reject. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index bd84417df..81c8f86d9 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -116,6 +116,8 @@ src/ data-only-agent.ts Markdown-only agent plugins (agents/*.md) loader.ts Plugin discovery + loadPluginEntry path-escape-plugin.ts Path sandboxing (first) + evidence-archive-search-plugin.ts archive:/// search/read via posix tools + evidence-archive-path-guard.ts Block dump-path reads of the archive sidecar tool-output-uri-plugin.ts Normalize read_file tool-output URIs secret-guard-plugin.ts Hard-deny path-keyed secret files authz-plugin.ts Catastrophic command blocking (thin wrapper) diff --git a/src/agent/archive-tools.test.ts b/src/agent/archive-tools.test.ts deleted file mode 100644 index cf4e979de..000000000 --- a/src/agent/archive-tools.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, test } from "bun:test"; - -import type { AgentTool } from "@intx/agent"; -import { CATALOG_TOOL_NAMES, CORE_TOOL_NAMES } from "./tool-search.js"; -import { - createReadArchiveTool, - createSearchArchiveTool, - readArchiveDefinition, - searchArchiveDefinition, -} from "./archive-tools.js"; -import { formatArchiveRef } from "../session/archive-uri.js"; -import { createCompactionArchive, type CompactionArchive } from "../session/compaction-archive.js"; - -function call(tool: AgentTool, args: Record): Promise { - if (tool.kind !== "string") throw new Error("expected string tool"); - return tool.handler(args, new AbortController().signal); -} - -function memoryArchive(sessionId: string): CompactionArchive { - const dir = mkdtempSync(join(tmpdir(), "archive-tools-")); - const blobs = new Map(); - return createCompactionArchive({ - sessionId, - contextDir: dir, - writeBlob: async (key, bytes) => { - blobs.set(key, bytes); - }, - readBlob: async (key) => { - const bytes = blobs.get(key); - if (bytes === undefined) throw new Error(`missing blob ${key}`); - return bytes; - }, - }); -} - -function wrapReads(archive: CompactionArchive): string[] { - const ids: string[] = []; - const orig = archive.readAuthorizedPayload.bind(archive); - archive.readAuthorizedPayload = async (occurrenceId) => { - ids.push(occurrenceId); - return orig(occurrenceId); - }; - return ids; -} - -describe("archive tool definitions", () => { - test("catalog advertises search_archive and read_archive; they are not CORE", () => { - expect(CORE_TOOL_NAMES).not.toContain("search_archive"); - expect(CORE_TOOL_NAMES).not.toContain("read_archive"); - expect(CATALOG_TOOL_NAMES).toContain("search_archive"); - expect(CATALOG_TOOL_NAMES).toContain("read_archive"); - expect(searchArchiveDefinition.description).toContain("archive:///"); - expect(readArchiveDefinition.description).toContain("archive:///"); - }); -}); - -describe("search_archive and read_archive", () => { - test("search returns archive:/// refs and read returns the payload", async () => { - const archive = memoryArchive("sess-primary"); - const occ = await archive.recordAuthorizedPayload({ - kind: "user_message", - payload: "unique-payload-alpha", - provenance: "primary-admission", - }); - const search = createSearchArchiveTool(() => archive); - const read = createReadArchiveTool(() => archive); - - const hits = await call(search, { query: "unique-payload-alpha" }); - expect(hits).toContain(formatArchiveRef(occ.occurrenceId)); - expect(hits).toContain("user_message"); - expect(hits).not.toContain(occ.sessionId); - expect(hits).not.toContain(occ.blobKey); - - const body = await call(read, { ref: formatArchiveRef(occ.occurrenceId) }); - expect(body).toContain("unique-payload-alpha"); - expect(body).not.toContain(occ.blobKey); - }); - - test("gap rows match metadata only and never load payload", async () => { - const archive = memoryArchive("sess-gap"); - const reads = wrapReads(archive); - const gap = await archive.recordAuthorizedPayload({ - kind: "attachment", - payload: { secret: "gap-payload-must-not-search" }, - provenance: "primary-admission:attachment-missing", - gap: true, - }); - const search = createSearchArchiveTool(() => archive); - const read = createReadArchiveTool(() => archive); - - const payloadHits = await call(search, { query: "gap-payload-must-not-search" }); - expect(payloadHits).toContain("No evidence-archive occurrences matched"); - expect(reads).toEqual([]); - - const metaHits = await call(search, { query: "attachment-missing" }); - expect(metaHits).toContain(formatArchiveRef(gap.occurrenceId)); - expect(metaHits).toContain("gap"); - expect(reads).toEqual([]); - - const body = await call(read, { ref: formatArchiveRef(gap.occurrenceId) }); - expect(body).toContain("explicit gap"); - expect(reads).toEqual([gap.occurrenceId]); - }); - - test("forged and other-session refs are not found", async () => { - const primary = memoryArchive("sess-a"); - const other = memoryArchive("sess-b"); - const foreign = await other.recordAuthorizedPayload({ - kind: "assistant_text", - payload: "other-session-only", - }); - const read = createReadArchiveTool(() => primary); - const search = createSearchArchiveTool(() => primary); - - const forged = await call(read, { ref: "archive:///occ-forged-not-in-index" }); - expect(forged).toContain("unknown occurrence"); - - const cross = await call(read, { ref: formatArchiveRef(foreign.occurrenceId) }); - expect(cross).toContain("unknown occurrence"); - - const hits = await call(search, { query: "other-session-only" }); - expect(hits).toContain("No evidence-archive occurrences matched"); - }); - - test("rejects sessionId, path, and blobKey locators", async () => { - const archive = memoryArchive("sess-locators"); - const occ = await archive.recordAuthorizedPayload({ - kind: "user_message", - payload: "locator-payload", - }); - const search = createSearchArchiveTool(() => archive); - const read = createReadArchiveTool(() => archive); - const ref = formatArchiveRef(occ.occurrenceId); - - for (const args of [ - { query: "locator-payload", sessionId: "sess-locators" }, - { query: "locator-payload", path: "evidence-archive/index.jsonl" }, - { query: "locator-payload", blobKey: occ.blobKey }, - ]) { - const out = await call(search, args); - expect(out).toContain("does not accept sessionId, path, or blobKey"); - } - - for (const args of [ - { ref, sessionId: "sess-locators" }, - { ref, path: "evidence-archive/index.jsonl" }, - { ref, blobKey: occ.blobKey }, - ]) { - const out = await call(read, args); - expect(out).toContain("does not accept sessionId, path, or blobKey"); - } - - const badRef = await call(read, { ref: occ.occurrenceId }); - expect(badRef).toContain("archive:///{occurrenceId}"); - }); - - test("read_archive pages with offset and limit via readBytesBounded", async () => { - const archive = memoryArchive("sess-page"); - const lines = Array.from({ length: 8 }, (_, i) => `archive-line-${i}`).join("\n"); - const occ = await archive.recordAuthorizedPayload({ - kind: "tool_result", - payload: lines, - }); - const read = createReadArchiveTool(() => archive); - const body = await call(read, { - ref: formatArchiveRef(occ.occurrenceId), - offset: 2, - limit: 2, - }); - expect(body).toContain("archive-line-2"); - expect(body).toContain("archive-line-3"); - expect(body).not.toContain("archive-line-0"); - expect(body).not.toContain("archive-line-4"); - expect(body).toContain("Use offset="); - }); -}); - -describe("createAgentToolset archive mount", () => { - test("mounts search_archive and read_archive only when getEvidenceArchive is set", async () => { - const cwd = mkdtempSync(join(tmpdir(), "corbits-archive-mount-")); - const { createAgentToolset } = await import("./tools.js"); - const permissionGate = { - check: async () => ({ allowed: true }), - getSkipPermissions: () => false, - } as never; - - const worker = await createAgentToolset({ - cwd, - permissionGate, - onOperatorGate: async () => ({ kind: "option", index: 0 }), - }); - const workerNames = worker.dynamicRunner.currentDefinitions().map((d) => d.name); - expect(workerNames).not.toContain("search_archive"); - expect(workerNames).not.toContain("read_archive"); - await worker.dispose(); - - const primary = await createAgentToolset({ - cwd, - permissionGate, - onOperatorGate: async () => ({ kind: "option", index: 0 }), - getEvidenceArchive: () => undefined, - }); - const primaryNames = primary.dynamicRunner.currentDefinitions().map((d) => d.name); - expect(primaryNames).toContain("search_archive"); - expect(primaryNames).toContain("read_archive"); - await primary.dispose(); - }); -}); diff --git a/src/agent/archive-tools.ts b/src/agent/archive-tools.ts deleted file mode 100644 index 234a09420..000000000 --- a/src/agent/archive-tools.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { stringTool } from "@intx/agent"; -import type { AgentTool } from "@intx/agent"; -import type { ToolDefinition } from "@intx/types/runtime"; -import { type } from "arktype"; - -import { - READ_FILE_DEFAULT_MAX_LINES, - readBytesBounded, -} from "../plugins/read-file-guard-plugin.js"; -import type { CompactionArchive } from "../session/compaction-archive.js"; -import type { ArchiveOccurrence } from "../session/compaction-archive-schema.js"; -import { formatArchiveRef, parseArchiveRef } from "../session/archive-uri.js"; - -const SEARCH_DEFAULT_LIMIT = 20; -const SEARCH_MAX_LIMIT = 50; - -export const searchArchiveDefinition: ToolDefinition = { - name: "search_archive", - description: - "Search this session's compaction evidence archive. Returns archive:///{occurrenceId} refs only — follow a hit with read_archive. Do not pass sessionId, path, or blobKey; do not read_file evidence-archive or tool-output/archive-* paths.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Keywords to match against kind, call id, provenance, or payload text.", - }, - limit: { - type: "number", - description: "Max hits to return (default 20, cap 50).", - }, - }, - required: ["query"], - }, -}; - -export const readArchiveDefinition: ToolDefinition = { - name: "read_archive", - description: - "Read one evidence-archive occurrence by archive:///{occurrenceId} from search_archive. Bounded like read_file (offset/limit). Do not pass sessionId, path, or blobKey.", - inputSchema: { - type: "object", - properties: { - ref: { - type: "string", - description: "An archive:///{occurrenceId} handle from search_archive.", - }, - offset: { - type: "number", - description: "Zero-based line skip, same as read_file.", - }, - limit: { - type: "number", - description: "Max lines to return.", - }, - }, - required: ["ref"], - }, -}; - -const SearchArchiveArgs = type({ - query: "string", - "limit?": "number", -}); - -const ReadArchiveArgs = type({ - ref: "string", - "offset?": "number", - "limit?": "number", -}); - -function forbiddenLocatorMessage(tool: string): string { - return `Error: ${tool} does not accept sessionId, path, or blobKey. Use archive:///{occurrenceId} refs only.`; -} - -function hasForbiddenLocatorArgs(rawArgs: Record): boolean { - return "sessionId" in rawArgs || "path" in rawArgs || "blobKey" in rawArgs; -} - -function formatHit(occ: ArchiveOccurrence): string { - const parts = [formatArchiveRef(occ.occurrenceId), occ.kind]; - if (occ.callId !== undefined) parts.push(`callId=${occ.callId}`); - if (occ.lifecycle !== undefined) parts.push(`lifecycle=${occ.lifecycle}`); - if (occ.provenance !== undefined) parts.push(`provenance=${occ.provenance}`); - if (occ.gap === true) parts.push("gap"); - return parts.join(" "); -} - -function metadataBlob(occ: ArchiveOccurrence): string { - return [occ.occurrenceId, occ.kind, occ.callId ?? "", occ.lifecycle ?? "", occ.provenance ?? ""] - .join(" ") - .toLowerCase(); -} - -export function createSearchArchiveTool( - getArchive: () => CompactionArchive | undefined, -): AgentTool { - return stringTool({ - definition: searchArchiveDefinition, - handler: async (rawArgs: Record): Promise => { - if (hasForbiddenLocatorArgs(rawArgs)) return forbiddenLocatorMessage("search_archive"); - const parsed = SearchArchiveArgs(rawArgs); - if (parsed instanceof type.errors) { - return "Error: search_archive requires query (string)."; - } - const archive = getArchive(); - if (archive === undefined) { - return "Error: evidence archive is not available in this session."; - } - const query = parsed.query.trim().toLowerCase(); - const limitRaw = parsed.limit; - const limit = - typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0 - ? Math.min(Math.floor(limitRaw), SEARCH_MAX_LIMIT) - : SEARCH_DEFAULT_LIMIT; - const occurrences = await archive.listOccurrences(); - const hits: string[] = []; - for (const occ of occurrences) { - if (hits.length >= limit) break; - if (query.length === 0 || metadataBlob(occ).includes(query)) { - hits.push(formatHit(occ)); - continue; - } - if (occ.gap === true) continue; - try { - const payload = await archive.readAuthorizedPayload(occ.occurrenceId); - if (payload.toLowerCase().includes(query)) hits.push(formatHit(occ)); - } catch { - continue; - } - } - if (hits.length === 0) { - return query.length === 0 - ? "No evidence-archive occurrences are recorded yet." - : `No evidence-archive occurrences matched "${parsed.query.trim()}".`; - } - return [ - "Matching evidence-archive occurrences (pass ref to read_archive):", - "", - ...hits, - ].join("\n"); - }, - }); -} - -export function createReadArchiveTool(getArchive: () => CompactionArchive | undefined): AgentTool { - return stringTool({ - definition: readArchiveDefinition, - handler: async (rawArgs: Record, signal: AbortSignal): Promise => { - if (hasForbiddenLocatorArgs(rawArgs)) return forbiddenLocatorMessage("read_archive"); - const parsed = ReadArchiveArgs(rawArgs); - if (parsed instanceof type.errors) { - return "Error: read_archive requires ref (archive:///{occurrenceId})."; - } - const occurrenceId = parseArchiveRef(parsed.ref.trim()); - if (occurrenceId === undefined) { - return "Error: read_archive ref must be archive:///{occurrenceId}."; - } - const archive = getArchive(); - if (archive === undefined) { - return "Error: evidence archive is not available in this session."; - } - const offset = - typeof parsed.offset === "number" && parsed.offset > 0 ? Math.floor(parsed.offset) : 0; - const limit = - typeof parsed.limit === "number" && parsed.limit > 0 - ? Math.floor(parsed.limit) - : READ_FILE_DEFAULT_MAX_LINES; - try { - const text = await archive.readAuthorizedPayload(occurrenceId); - const bytes = new TextEncoder().encode(text); - const display = formatArchiveRef(occurrenceId); - const result = await readBytesBounded(bytes, offset, limit, signal, display); - return result.content; - } catch (err) { - return `Error: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }); -} diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index 0d8c2d485..8089de1be 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -2,6 +2,7 @@ import type { ToolPlugin } from "@intx/tools-posix"; import { createLSPPlugin } from "@intx/tools-lsp"; import { pathEscapePlugin } from "../plugins/path-escape-plugin.js"; import { evidenceArchivePathGuardPlugin } from "../plugins/evidence-archive-path-guard.js"; +import { evidenceArchiveSearchPlugin } from "../plugins/evidence-archive-search-plugin.js"; import { deleteFilePlugin } from "../plugins/delete-file-plugin.js"; import { secretGuardPlugin } from "../plugins/secret-guard-plugin.js"; import { authzPlugin } from "../plugins/authz-plugin.js"; @@ -105,6 +106,7 @@ export function buildCorePosixToolPlugins( resultTruncationPlugin(truncationOptions), toolResultSecretScrubPlugin(), pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }), + ...(getEvidenceArchive !== undefined ? [evidenceArchiveSearchPlugin(getEvidenceArchive)] : []), evidenceArchivePathGuardPlugin(), deleteFilePlugin(cwd, { allowOutside }), toolOutputUriPlugin(), diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 38fc5ea58..9ab98442b 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -226,7 +226,7 @@ export function buildPromptDisciplineBlock( const TOOL_SUMMARIES: Record = { read_file: - "read a file or tool-output:///{callId} from a prior tool result (prefer over cat/head/tail in the shell). Only read_file a tool-output:// URI if the truncation notice named one", + "read a file, tool-output:///{callId} from a prior tool result, or archive:///{occurrenceId} (prefer over cat/head/tail in the shell). Only read_file a tool-output:// URI if the truncation notice named one", write_file: "create or overwrite a file (never shell redirects or heredocs)", edit_file: "make a surgical edit (exact old_string match, or start_line/end_line line-range mode; never include read_file's NNNNNN\\t line prefix; substring failures include nearby file text; prefer over sed/awk in the shell)", @@ -234,8 +234,8 @@ const TOOL_SUMMARIES: Record = { run_shell: "run a shell command (builds, tests, git; pass timeout ms to bound long commands; never to read/write/delete files, search trees, or talk to the user)", search_files: - "find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find)", - grep: "search file contents (bounded; timeout + output caps — safer than open-ended shell grep -r/rg)", + "find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find); path archive:/// lists evidence-archive refs", + grep: "search file contents (bounded; timeout + output caps — safer than open-ended shell grep -r/rg); path archive:/// searches this session's evidence archive", list_dir: "list a directory's entries (bounded listing)", lsp: "resolve symbols — goToDefinition, findReferences, hover (prefer before reading huge files)", web_search: "search the web (use instead of curl or wget)", @@ -261,10 +261,6 @@ const TOOL_SUMMARIES: Record = { "load a listed skill's full instructions before doing work it covers", skill_search: "look up skill descriptions by capability (catalog — call directly, do not tool_search for this)", - search_archive: - "search this session's compaction evidence archive; returns archive:///{occurrenceId} refs only", - read_archive: - "read one evidence-archive occurrence by archive:///{occurrenceId} from search_archive", }; export function buildAvailableTools( diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 56d66a1a2..13ca2f042 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -110,8 +110,6 @@ export const CATALOG_TOOL_NAMES: readonly string[] = [ "list_dir", "web_fetch", "web_search", - "search_archive", - "read_archive", "skill_search", ]; diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 84cf29021..0774a5d7c 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -16,6 +16,7 @@ import { type ShellTimeoutConfig, } from "../plugins/shell-guard-plugin.js"; import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js"; +import { advertiseArchiveSurface } from "../plugins/evidence-archive-search-plugin.js"; import type { Telemetry } from "../telemetry/index.js"; import type { PermissionGate } from "../permission/gate.js"; import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js"; @@ -102,7 +103,6 @@ import { createUseSkillTool } from "./use-skill.js"; import { createSkillSearchTool } from "./skill-search.js"; import { createToolIndex, createToolSearchTool } from "./tool-search.js"; import { createSearchAgentsTool } from "./agent-search.js"; -import { createSearchArchiveTool, createReadArchiveTool } from "./archive-tools.js"; import { createReadAgentTraceTool } from "../subagent/trace-tool.js"; import { createCodexToolProxies, @@ -558,12 +558,13 @@ export async function createAgentToolset( } const baseTools: AgentTool[] = [ - ...fromToolRunner(posixTools).map((tool) => ({ - ...tool, - definition: advertiseEditFileLineRange( + ...fromToolRunner(posixTools).map((tool) => { + let definition = advertiseEditFileLineRange( advertiseShellGuardTimeout(tool.definition, shellTimeout?.defaultMs), - ), - })), + ); + if (getEvidenceArchive !== undefined) definition = advertiseArchiveSurface(definition); + return { ...tool, definition }; + }), createListDirTool(cwd, { allowOutside: () => permissionGate.getSkipPermissions(), }), @@ -574,9 +575,6 @@ export async function createAgentToolset( : createWebFetchTool(), createWebSearchTool(), ...orchestratorTools, - ...(getEvidenceArchive !== undefined - ? [createSearchArchiveTool(getEvidenceArchive), createReadArchiveTool(getEvidenceArchive)] - : []), stringTool({ definition: shellCollect.definition, handler: shellCollect.handler, diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 368ad49fa..514da28a9 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -280,8 +280,6 @@ const AUTO_ALLOWED_TOOLS = new Set([ "use_skill", "skill_search", "search_agents", - "search_archive", - "read_archive", "spawn_agent", "wait_agents", ]); diff --git a/src/plugins/evidence-archive-path-guard.test.ts b/src/plugins/evidence-archive-path-guard.test.ts index 8096f1901..fb758008c 100644 --- a/src/plugins/evidence-archive-path-guard.test.ts +++ b/src/plugins/evidence-archive-path-guard.test.ts @@ -43,7 +43,7 @@ describe("evidenceArchivePathGuardPlugin", () => { for (const call of denied) { const result = await handler(call, new AbortController().signal); expect(result.isError).toBe(true); - expect(String(result.content)).toContain("search_archive"); + expect(String(result.content)).toContain("search_files"); expect(String(result.content)).toContain("archive:///"); } }); diff --git a/src/plugins/evidence-archive-path-guard.ts b/src/plugins/evidence-archive-path-guard.ts index c1d5648e6..54f10d5df 100644 --- a/src/plugins/evidence-archive-path-guard.ts +++ b/src/plugins/evidence-archive-path-guard.ts @@ -13,7 +13,7 @@ const PATH_TOOLS = new Set([ ]); const DENY_MESSAGE = - "Cannot read evidence-archive or tool-output/archive-* with this tool. Use search_archive and read_archive with archive:/// refs."; + "Cannot read evidence-archive or tool-output/archive-* dumps. Use search_files, grep, or read_file with archive:/// refs."; export function isProtectedEvidenceLocation(value: string): boolean { const normalized = value.replaceAll("\\", "/"); diff --git a/src/plugins/evidence-archive-search-plugin.test.ts b/src/plugins/evidence-archive-search-plugin.test.ts new file mode 100644 index 000000000..2438ff839 --- /dev/null +++ b/src/plugins/evidence-archive-search-plugin.test.ts @@ -0,0 +1,259 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import type { ToolCall, ToolResult } from "@intx/types/runtime"; +import { + advertiseArchiveSurface, + evidenceArchiveSearchPlugin, +} from "./evidence-archive-search-plugin.js"; +import { formatArchiveRef } from "../session/archive-uri.js"; +import { createCompactionArchive, type CompactionArchive } from "../session/compaction-archive.js"; +import { CATALOG_TOOL_NAMES, CORE_TOOL_NAMES } from "../agent/tool-search.js"; + +function makeCall(name: string, args: Record): ToolCall { + return { id: "test-call", name, arguments: args }; +} + +const nextHandler = async (call: ToolCall): Promise => ({ + callId: call.id, + content: `passthrough:${call.name}`, +}); + +function memoryArchive(sessionId: string): CompactionArchive { + const dir = mkdtempSync(join(tmpdir(), "archive-search-")); + const blobs = new Map(); + return createCompactionArchive({ + sessionId, + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing blob ${key}`); + return bytes; + }, + }); +} + +function wrapReads(archive: CompactionArchive): string[] { + const ids: string[] = []; + const orig = archive.readAuthorizedPayload.bind(archive); + archive.readAuthorizedPayload = async (occurrenceId) => { + ids.push(occurrenceId); + return orig(occurrenceId); + }; + return ids; +} + +describe("advertiseArchiveSurface", () => { + test("mentions archive:/// on search_files, read_file, and grep", () => { + expect(CORE_TOOL_NAMES).not.toContain("search_archive"); + expect(CORE_TOOL_NAMES).not.toContain("read_archive"); + expect(CATALOG_TOOL_NAMES).not.toContain("search_archive"); + expect(CATALOG_TOOL_NAMES).not.toContain("read_archive"); + const read = advertiseArchiveSurface({ + name: "read_file", + description: "Read a file.", + inputSchema: { type: "object", properties: { path: { type: "string" } } }, + }); + expect(read.description).toContain("archive:///"); + const grep = advertiseArchiveSurface({ + name: "grep", + description: "Search file contents.", + inputSchema: { type: "object", properties: { path: { type: "string" } } }, + }); + expect(grep.description).toContain("archive:///"); + const search = advertiseArchiveSurface({ + name: "search_files", + description: "Find files.", + inputSchema: { type: "object", properties: { path: { type: "string" } } }, + }); + expect(search.description).toContain("archive:///"); + }); +}); + +describe("evidenceArchiveSearchPlugin", () => { + test("search_files lists archive:/// refs and read_file returns the payload", async () => { + const archive = memoryArchive("sess-primary"); + const occ = await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "unique-payload-alpha", + provenance: "primary-admission", + }); + const plugin = evidenceArchiveSearchPlugin(() => archive); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + + const hits = await handler( + makeCall("search_files", { pattern: "*", path: "archive:///" }), + new AbortController().signal, + ); + expect(String(hits.content)).toContain(formatArchiveRef(occ.occurrenceId)); + expect(String(hits.content)).not.toContain(occ.sessionId); + expect(String(hits.content)).not.toContain(occ.blobKey); + + const grepHits = await handler( + makeCall("grep", { pattern: "unique-payload-alpha", path: "archive:///" }), + new AbortController().signal, + ); + expect(String(grepHits.content)).toContain(formatArchiveRef(occ.occurrenceId)); + expect(String(grepHits.content)).toContain("unique-payload-alpha"); + expect(String(grepHits.content)).not.toContain(occ.blobKey); + + const body = await handler( + makeCall("read_file", { path: formatArchiveRef(occ.occurrenceId) }), + new AbortController().signal, + ); + expect(String(body.content)).toContain("unique-payload-alpha"); + expect(String(body.content)).not.toContain(occ.blobKey); + }); + + test("gap rows match metadata only and never load payload", async () => { + const archive = memoryArchive("sess-gap"); + const reads = wrapReads(archive); + const gap = await archive.recordAuthorizedPayload({ + kind: "attachment", + payload: { secret: "gap-payload-must-not-search" }, + provenance: "primary-admission:attachment-missing", + gap: true, + }); + const plugin = evidenceArchiveSearchPlugin(() => archive); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + + const payloadHits = await handler( + makeCall("grep", { pattern: "gap-payload-must-not-search", path: "archive:///" }), + new AbortController().signal, + ); + expect(String(payloadHits.content)).toContain("no matches"); + expect(reads).toEqual([]); + + const metaHits = await handler( + makeCall("grep", { pattern: "attachment-missing", path: "archive:///" }), + new AbortController().signal, + ); + expect(String(metaHits.content)).toContain(formatArchiveRef(gap.occurrenceId)); + expect(String(metaHits.content)).toContain("gap"); + expect(reads).toEqual([]); + + const body = await handler( + makeCall("read_file", { path: formatArchiveRef(gap.occurrenceId) }), + new AbortController().signal, + ); + expect(body.isError).toBe(true); + expect(String(body.content)).toContain("explicit gap"); + expect(reads).toEqual([gap.occurrenceId]); + }); + + test("forged and other-session refs are not found", async () => { + const primary = memoryArchive("sess-a"); + const other = memoryArchive("sess-b"); + const foreign = await other.recordAuthorizedPayload({ + kind: "assistant_text", + payload: "other-session-only", + }); + const plugin = evidenceArchiveSearchPlugin(() => primary); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + + const forged = await handler( + makeCall("read_file", { path: "archive:///occ-forged-not-in-index" }), + new AbortController().signal, + ); + expect(forged.isError).toBe(true); + expect(String(forged.content)).toContain("unknown occurrence"); + + const cross = await handler( + makeCall("read_file", { path: formatArchiveRef(foreign.occurrenceId) }), + new AbortController().signal, + ); + expect(cross.isError).toBe(true); + expect(String(cross.content)).toContain("unknown occurrence"); + + const hits = await handler( + makeCall("grep", { pattern: "other-session-only", path: "archive:///" }), + new AbortController().signal, + ); + expect(String(hits.content)).toContain("no matches"); + }); + + test("read_file pages archive payloads with offset and limit", async () => { + const archive = memoryArchive("sess-page"); + const lines = Array.from({ length: 8 }, (_, i) => `archive-line-${i}`).join("\n"); + const occ = await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: lines, + }); + const plugin = evidenceArchiveSearchPlugin(() => archive); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const body = await handler( + makeCall("read_file", { + path: formatArchiveRef(occ.occurrenceId), + offset: 2, + limit: 2, + }), + new AbortController().signal, + ); + expect(String(body.content)).toContain("archive-line-2"); + expect(String(body.content)).toContain("archive-line-3"); + expect(String(body.content)).not.toContain("archive-line-0"); + expect(String(body.content)).not.toContain("archive-line-4"); + expect(String(body.content)).toContain("Use offset="); + }); + + test("passes ordinary workspace paths through", async () => { + const plugin = evidenceArchiveSearchPlugin(() => memoryArchive("sess-pass")); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const result = await handler( + makeCall("grep", { pattern: "foo", path: "src" }), + new AbortController().signal, + ); + expect(result.content).toBe("passthrough:grep"); + }); +}); + +describe("createAgentToolset archive mount", () => { + test("does not mount dedicated archive tools; primary advertises archive:/// on posix search", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-archive-mount-")); + const { createAgentToolset } = await import("../agent/tools.js"); + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; + + const worker = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + }); + const workerNames = worker.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(workerNames).not.toContain("search_archive"); + expect(workerNames).not.toContain("read_archive"); + const workerRead = worker.dynamicRunner + .currentDefinitions() + .find((d) => d.name === "read_file"); + expect(workerRead?.description ?? "").not.toContain("archive:///"); + await worker.dispose(); + + const primary = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + getEvidenceArchive: () => undefined, + }); + const primaryNames = primary.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(primaryNames).not.toContain("search_archive"); + expect(primaryNames).not.toContain("read_archive"); + const primaryRead = primary.dynamicRunner + .currentDefinitions() + .find((d) => d.name === "read_file"); + expect(primaryRead?.description).toContain("archive:///"); + const primaryGrep = primary.dynamicRunner.currentDefinitions().find((d) => d.name === "grep"); + expect(primaryGrep?.description).toContain("archive:///"); + const primarySearch = primary.dynamicRunner + .currentDefinitions() + .find((d) => d.name === "search_files"); + expect(primarySearch?.description).toContain("archive:///"); + await primary.dispose(); + }); +}); diff --git a/src/plugins/evidence-archive-search-plugin.ts b/src/plugins/evidence-archive-search-plugin.ts new file mode 100644 index 000000000..02b2d957e --- /dev/null +++ b/src/plugins/evidence-archive-search-plugin.ts @@ -0,0 +1,299 @@ +import type { ToolDefinition } from "@intx/types/runtime"; +import type { ToolPlugin } from "@intx/tools-posix"; + +import { READ_FILE_DEFAULT_MAX_LINES, readBytesBounded } from "./read-file-guard-plugin.js"; +import type { CompactionArchive } from "../session/compaction-archive.js"; +import type { ArchiveOccurrence } from "../session/compaction-archive-schema.js"; +import { formatArchiveRef, isArchiveLike, parseArchiveTarget } from "../session/archive-uri.js"; + +const SEARCH_DEFAULT_MAX = 1000; +const GREP_DEFAULT_MAX = 500; + +function str(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function num(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function globToRegExp(pattern: string): RegExp { + let regex = ""; + let i = 0; + while (i < pattern.length) { + const c = pattern.charAt(i); + if (c === "*" && pattern[i + 1] === "*") { + i += 2; + if (pattern[i] === "/") { + i++; + regex += "(?:.+/)?"; + } else { + regex += ".*"; + } + } else if (c === "*") { + regex += "[^/]*"; + i++; + } else if (c === "?") { + regex += "[^/]"; + i++; + } else if (".+^${}()|[]\\".includes(c)) { + regex += `\\${c}`; + i++; + } else { + regex += c; + i++; + } + } + return new RegExp(`^${regex}$`); +} + +function matchesArchiveName(pattern: string, occ: ArchiveOccurrence): boolean { + const matcher = globToRegExp(pattern); + return ( + matcher.test(occ.occurrenceId) || + matcher.test(formatArchiveRef(occ.occurrenceId)) || + matcher.test(occ.kind) + ); +} + +function formatHit(occ: ArchiveOccurrence): string { + const parts = [formatArchiveRef(occ.occurrenceId), occ.kind]; + if (occ.callId !== undefined) parts.push(`callId=${occ.callId}`); + if (occ.lifecycle !== undefined) parts.push(`lifecycle=${occ.lifecycle}`); + if (occ.provenance !== undefined) parts.push(`provenance=${occ.provenance}`); + if (occ.gap === true) parts.push("gap"); + return parts.join(" "); +} + +function metadataBlob(occ: ArchiveOccurrence): string { + return [ + occ.occurrenceId, + occ.kind, + occ.callId ?? "", + occ.lifecycle ?? "", + occ.provenance ?? "", + ].join(" "); +} + +function patchPathDescription( + definition: ToolDefinition, + pathDescription: string, + toolDescription: string, +): ToolDefinition { + const schema = definition.inputSchema; + const props = schema["properties"]; + if (props === undefined || typeof props !== "object" || props === null) return definition; + const properties = props as Record; + const pathSchema = properties.path; + const nextPath = + pathSchema !== undefined && typeof pathSchema === "object" && pathSchema !== null + ? { ...(pathSchema as Record), description: pathDescription } + : { type: "string", description: pathDescription }; + return { + ...definition, + description: toolDescription, + inputSchema: { + ...schema, + properties: { + ...properties, + path: nextPath, + }, + }, + }; +} + +export function advertiseArchiveSurface(definition: ToolDefinition): ToolDefinition { + if (definition.name === "read_file") { + return patchPathDescription( + definition, + "Absolute or relative filesystem path, tool-output:///{callId}, or archive:///{occurrenceId}", + `${definition.description} The path argument also accepts archive:///{occurrenceId} from search_files or grep on path archive:///.`, + ); + } + if (definition.name === "grep") { + return patchPathDescription( + definition, + "File or directory to search in, or archive:/// for this session's evidence archive", + `${definition.description} Pass path archive:/// to search compaction evidence; follow a hit with read_file on the archive:/// ref.`, + ); + } + if (definition.name === "search_files") { + return patchPathDescription( + definition, + "Directory to search in, or archive:/// for this session's evidence-archive occurrence refs", + `${definition.description} Pass path archive:/// to list evidence-archive refs.`, + ); + } + return definition; +} + +export function evidenceArchiveSearchPlugin( + getArchive: () => CompactionArchive | undefined, +): ToolPlugin { + return { + middleware: (next) => async (call, signal) => { + if (call.name !== "read_file" && call.name !== "grep" && call.name !== "search_files") { + return next(call, signal); + } + const path = str(call.arguments.path); + if (path === undefined || !isArchiveLike(path)) return next(call, signal); + + const archive = getArchive(); + if (archive === undefined) { + return { + callId: call.id, + content: "Error: evidence archive is not available in this session.", + isError: true, + }; + } + + const target = parseArchiveTarget(path); + if (target === undefined) return next(call, signal); + + try { + if (call.name === "read_file") { + return { + callId: call.id, + content: await readArchiveOccurrence( + archive, + target.occurrenceId, + call.arguments, + signal, + ), + }; + } + if (call.name === "search_files") { + const pattern = str(call.arguments.pattern); + if (pattern === undefined) { + return { + callId: call.id, + content: "Error: search_files requires pattern (string).", + isError: true, + }; + } + const maxResults = num(call.arguments.max_results) ?? SEARCH_DEFAULT_MAX; + return { + callId: call.id, + content: await searchArchiveFiles(archive, pattern, target.occurrenceId, maxResults), + }; + } + const pattern = str(call.arguments.pattern); + if (pattern === undefined) { + return { + callId: call.id, + content: "Error: grep requires pattern (string).", + isError: true, + }; + } + const maxResults = num(call.arguments.max_results) ?? GREP_DEFAULT_MAX; + const glob = str(call.arguments.glob); + return { + callId: call.id, + content: await grepArchive(archive, pattern, target.occurrenceId, maxResults, glob), + }; + } catch (err) { + return { + callId: call.id, + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; +} + +async function readArchiveOccurrence( + archive: CompactionArchive, + occurrenceId: string | undefined, + args: Record, + signal: AbortSignal, +): Promise { + if (occurrenceId === undefined) { + throw new Error("read_file archive path must be archive:///{occurrenceId}"); + } + const text = await archive.readAuthorizedPayload(occurrenceId); + const offsetArg = num(args.offset); + const offset = offsetArg !== undefined && offsetArg > 0 ? Math.floor(offsetArg) : 0; + const limitArg = num(args.limit); + const limit = + limitArg !== undefined && limitArg > 0 ? Math.floor(limitArg) : READ_FILE_DEFAULT_MAX_LINES; + const result = await readBytesBounded( + new TextEncoder().encode(text), + offset, + limit, + signal, + formatArchiveRef(occurrenceId), + ); + return result.content; +} + +async function searchArchiveFiles( + archive: CompactionArchive, + pattern: string, + occurrenceId: string | undefined, + maxResults: number, +): Promise { + const occurrences = await selectOccurrences(archive, occurrenceId); + const hits: string[] = []; + for (const occ of occurrences) { + if (hits.length >= maxResults) break; + if (!matchesArchiveName(pattern, occ)) continue; + hits.push(formatArchiveRef(occ.occurrenceId)); + } + if (hits.length === 0) { + return `No evidence-archive occurrences matched "${pattern}".`; + } + return hits.join("\n"); +} + +async function grepArchive( + archive: CompactionArchive, + pattern: string, + occurrenceId: string | undefined, + maxResults: number, + glob: string | undefined, +): Promise { + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (err) { + throw new Error(`invalid regex: ${err instanceof Error ? err.message : String(err)}`); + } + const occurrences = await selectOccurrences(archive, occurrenceId); + const hits: string[] = []; + for (const occ of occurrences) { + if (hits.length >= maxResults) break; + if (glob !== undefined && !matchesArchiveName(glob, occ)) continue; + const ref = formatArchiveRef(occ.occurrenceId); + if (regex.test(metadataBlob(occ)) || regex.test(formatHit(occ))) { + hits.push(`${ref}:1:${formatHit(occ)}`); + continue; + } + if (occ.gap === true) continue; + let payload: string; + try { + payload = await archive.readAuthorizedPayload(occ.occurrenceId); + } catch { + continue; + } + const lines = payload.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (hits.length >= maxResults) break; + const line = lines[i] ?? ""; + if (regex.test(line)) hits.push(`${ref}:${i + 1}:${line}`); + } + } + if (hits.length === 0) return `no matches for /${pattern}/`; + return hits.join("\n"); +} + +async function selectOccurrences( + archive: CompactionArchive, + occurrenceId: string | undefined, +): Promise { + const occurrences = await archive.listOccurrences(); + if (occurrenceId === undefined) return occurrences; + const hit = occurrences.find((occ) => occ.occurrenceId === occurrenceId); + if (hit === undefined) throw new Error(`unknown occurrence ${occurrenceId}`); + return [hit]; +} diff --git a/src/plugins/path-escape-plugin.test.ts b/src/plugins/path-escape-plugin.test.ts index 25bdcdf78..9734eb624 100644 --- a/src/plugins/path-escape-plugin.test.ts +++ b/src/plugins/path-escape-plugin.test.ts @@ -193,6 +193,22 @@ describe("pathEscapePlugin", () => { expect(args.path).toBe("/other-repo/README.md"); }); + test("passes archive:/// refs through without resolving them as filesystem paths", async () => { + const plugin = pathEscapePlugin("/project"); + const next = async (call: ToolCall): Promise => ({ + callId: call.id, + content: JSON.stringify(call.arguments), + }); + const handler = plugin.middleware ? plugin.middleware(next) : next; + const result = await handler( + makeCall("read_file", { path: "archive:///occ-abc" }), + new AbortController().signal, + ); + expect(result.isError).not.toBe(true); + const args = JSON.parse(String(result.content)) as { path: string }; + expect(args.path).toBe("archive:///occ-abc"); + }); + describe("symlink TOCTOU (CL-6712)", () => { let cwd = ""; diff --git a/src/plugins/path-escape-plugin.ts b/src/plugins/path-escape-plugin.ts index d72672368..fd83dc4bf 100644 --- a/src/plugins/path-escape-plugin.ts +++ b/src/plugins/path-escape-plugin.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import type { ToolPlugin } from "@intx/tools-posix"; import { isToolOutputLike } from "../util/tool-output-uri.js"; +import { isArchiveLike } from "../session/archive-uri.js"; import { resolveWorkspacePath } from "../permission/path-restriction.js"; import type { RootsProvider } from "../permission/worktree-roots.js"; @@ -92,7 +93,7 @@ function sanitizePath( rootsProvider: RootsProvider, allowOutside: boolean, ): string { - if (isToolOutputLike(value)) { + if (isToolOutputLike(value) || isArchiveLike(value)) { return value; } const resolved = resolveWorkspacePath(cwd, value, rootsProvider); diff --git a/src/session/archive-uri.test.ts b/src/session/archive-uri.test.ts index a85f10298..24ed6b849 100644 --- a/src/session/archive-uri.test.ts +++ b/src/session/archive-uri.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { formatArchiveRef, parseArchiveRef } from "./archive-uri.js"; +import { + formatArchiveRef, + isArchiveLike, + parseArchiveRef, + parseArchiveTarget, +} from "./archive-uri.js"; describe("archive URI", () => { test("formats and parses archive:/// occurrence refs", () => { @@ -12,4 +17,14 @@ describe("archive URI", () => { expect(parseArchiveRef("archive:///")).toBeUndefined(); expect(parseArchiveRef("/tmp/evidence-archive/index.jsonl")).toBeUndefined(); }); + + test("treats archive:/// as the virtual search root", () => { + expect(isArchiveLike("archive:///")).toBe(true); + expect(isArchiveLike("archive:///occ-abc")).toBe(true); + expect(isArchiveLike("evidence-archive/index.jsonl")).toBe(false); + expect(parseArchiveTarget("archive:///")).toEqual({}); + expect(parseArchiveTarget("archive:/")).toEqual({}); + expect(parseArchiveTarget("archive:///occ-abc")).toEqual({ occurrenceId: "occ-abc" }); + expect(parseArchiveTarget("src/foo.ts")).toBeUndefined(); + }); }); diff --git a/src/session/archive-uri.ts b/src/session/archive-uri.ts index 2c3097e8e..436c50135 100644 --- a/src/session/archive-uri.ts +++ b/src/session/archive-uri.ts @@ -5,11 +5,20 @@ export function formatArchiveRef(occurrenceId: string): string { return `${ARCHIVE_URI_CANONICAL}${occurrenceId}`; } +export function isArchiveLike(path: string): boolean { + return path.startsWith(ARCHIVE_URI_PREFIX); +} + /** Accept archive:///occ-… and common slashes; return the occurrence id or undefined. */ export function parseArchiveRef(value: string): string | undefined { - if (!value.startsWith(ARCHIVE_URI_PREFIX)) return undefined; + return parseArchiveTarget(value)?.occurrenceId; +} + +/** Root `archive:///` has no occurrenceId; a ref includes one. */ +export function parseArchiveTarget(value: string): { occurrenceId?: string } | undefined { + if (!isArchiveLike(value)) return undefined; const rest = value.slice(ARCHIVE_URI_PREFIX.length).replace(/^\/+/, ""); const occurrenceId = rest.split(/[/?#]/)[0] ?? ""; - if (occurrenceId.length === 0) return undefined; - return occurrenceId; + if (occurrenceId.length === 0) return {}; + return { occurrenceId }; } diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index 4518cd3bf..40af53658 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -515,6 +515,33 @@ describe("compaction archive storage", () => { expect(occ.blobKey.includes("..")).toBe(false); expect(await archive.readAuthorizedPayload(occ.occurrenceId)).toBe("hello-store"); }); + + test("rejects slash-containing blob keys so they cannot nest under tool-output", async () => { + const dir = tempDir(); + const archive = createCompactionArchive({ + sessionId: "sess-slash", + contextDir: dir, + writeBlob: async () => { + throw new Error("writeBlob must not run for a slash key"); + }, + readBlob: async () => { + throw new Error("readBlob must not run for a slash key"); + }, + }); + let thrown: Error | undefined; + try { + await archive.recordExistingBlobReference({ + kind: "overflow_blob", + blobKey: "archive/foo.json", + contentHash: hashAuthorizedBytes(new TextEncoder().encode("x")), + }); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("unsafe characters"); + expect(thrown?.message).toContain("archive/foo.json"); + expect(await archive.listOccurrences()).toEqual([]); + }); }); describe("wrapCompactorWithCompletenessGate", () => { diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index 1281dc4a3..4333f6226 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -344,7 +344,14 @@ function encodePayload(payload: unknown): { bytes: Uint8Array; contentType: stri function occurrenceBlobKey(sessionId: string, occurrenceId: string): string { // ContextStore.writeBlob rejects slash-containing keys (sanitizeCallId). - return `archive-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${occurrenceId}`; + return assertSafeBlobKey(`archive-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${occurrenceId}`); +} + +function assertSafeBlobKey(key: string): string { + if (key.includes("/") || key.includes("..")) { + throw new Error(`blob key contains unsafe characters: ${JSON.stringify(key)}`); + } + return key; } async function appendIndex(contextDir: string, occurrence: ArchiveOccurrence): Promise { @@ -411,7 +418,7 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp ...(input.lifecycle !== undefined ? { lifecycle: input.lifecycle } : {}), recordedAt, }); - const blobKey = input.blobKey ?? occurrenceBlobKey(sessionId, occurrenceId); + const blobKey = assertSafeBlobKey(input.blobKey ?? occurrenceBlobKey(sessionId, occurrenceId)); if (input.skipWrite !== true && input.gap !== true) { await writeBlob(blobKey, bytes, contentType); } @@ -440,9 +447,10 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp }, async recordExistingBlobReference(input) { + const blobKey = assertSafeBlobKey(input.blobKey); let gap = false; try { - const bytes = await readBlob(input.blobKey); + const bytes = await readBlob(blobKey); const actual = hashAuthorizedBytes(bytes); if (actual !== input.contentHash) gap = true; } catch { @@ -461,7 +469,7 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp sessionId, kind: input.kind, contentHash: input.contentHash, - blobKey: input.blobKey, + blobKey, recordedAt, ...(input.callId !== undefined ? { callId: input.callId } : {}), ...(input.provenance !== undefined ? { provenance: input.provenance } : {}), diff --git a/src/telemetry/classify.ts b/src/telemetry/classify.ts index e4914fe7d..6b24af546 100644 --- a/src/telemetry/classify.ts +++ b/src/telemetry/classify.ts @@ -28,11 +28,9 @@ const BUILT_IN_TOOL_NAMES: ReadonlySet = new Set([ "lsp", "manage_tasks", "present", - "read_archive", "read_file", "run_shell", "search_agents", - "search_archive", "search_files", "spawn_agent", "wait_agents", diff --git a/src/tui/ramp-paint.test.ts b/src/tui/ramp-paint.test.ts index 0240c98f8..0d8d8d7e7 100644 --- a/src/tui/ramp-paint.test.ts +++ b/src/tui/ramp-paint.test.ts @@ -154,7 +154,7 @@ describe("turn ramp paint", () => { bridge.handle({ type: "run", state: "idle" }); await h.renderOnce(); const row = statusRow(h.captureCharFrame()); - expect(row).not.toContain("working"); + expect(row).toContain("corbits code"); expect(row).not.toMatch(DENSITY); } finally { bridge.dispose(); diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts index c20db947c..e23ff383d 100644 --- a/src/tui/session-chrome.ts +++ b/src/tui/session-chrome.ts @@ -97,8 +97,6 @@ const TOOL_ACTIVITY_STATES: Readonly> = { task: "planning", tool_search: "researching", search_agents: "researching", - search_archive: "researching", - read_archive: "researching", ask_operator: "waiting", submit_output: "working", }; From 673d314a98aa7c971137906df018b2945a99cba6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 13:06:04 -0700 Subject: [PATCH 09/16] Replace accumulating compaction with one evidence-backed handoff Failed summaries used to write a stats stub into history, and later folds stacked frozen prefixes. The primary session now keeps the prior context on summary failure and replaces the dropped prefix with a single archive-backed handoff. --- docs/IMPLEMENTATION.md | 3 +- src/config/settings.ts | 6 +- src/context-compactor.test.ts | 193 +++++++----------- src/exec/runner.ts | 3 +- src/session/compactor.ts | 181 +++++++++------- src/session/runtime-assembly.test.ts | 22 +- src/session/runtime-assembly.ts | 7 +- src/session/summarizer.ts | 47 ++--- src/session/summary-excerpt.test.ts | 87 ++++++++ src/session/summary-excerpt.ts | 80 ++++++++ src/subagent/run.ts | 18 +- src/tui/command-surfaces.test.ts | 23 +-- src/tui/command-surfaces.ts | 42 ---- src/tui/runner-host.test.ts | 2 - src/tui/runner/session.ts | 10 +- src/tui/runner/settings.ts | 8 - src/tui/runner/state.ts | 4 - src/tui/slash-popup-gate.test.ts | 10 - tests/integration/compaction-baseline.test.ts | 8 +- tests/integration/harness.ts | 80 +++++++- tests/unit/summarizer.test.ts | 61 +++--- tests/unit/telemetry-product-events.test.ts | 3 +- 22 files changed, 513 insertions(+), 385 deletions(-) create mode 100644 src/session/summary-excerpt.test.ts create mode 100644 src/session/summary-excerpt.ts diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 81c8f86d9..5da158a62 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -79,7 +79,8 @@ src/ index.ts Session lifecycle state.ts RunState JSON save/load compactor.ts Context compactor - summarizer.ts Model-backed structured compaction summary (+ deterministic fallback) + summarizer.ts Model-backed structured compaction summary (fails closed) + summary-excerpt.ts Token-budgeted archive excerpt for the summary call compaction-archive.ts Primary-only authorized evidence archive (post-policy capture) compaction-archive-schema.ts Archive occurrence / completeness certificate schemas run-sink.ts Run-level event sink diff --git a/src/config/settings.ts b/src/config/settings.ts index 88ca3ac52..435c12540 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -117,9 +117,9 @@ export interface Settings { // interactive install). Upgrade stamps only after notes are actually shown // so a missing surface cannot silently swallow them (CL-5475). lastChangelogVersion?: string; - // Controls the context-compaction strategy used when the context window fills. - // "llm" (default) generates a structured handoff summary via LLM call. - // "pruning" uses fast deterministic pruning with no LLM call. + // Deprecated: summarize vs drop is no longer operator-selectable. Primary + // compaction is always the evidence-backed LLM handoff. Legacy values may + // still appear in on-disk settings and are ignored; new writes omit this field. compactionMode?: "llm" | "pruning"; // Deprecated (CL-5814): orchestrator is the only product path. Legacy values // may still appear in on-disk settings and are ignored at resolve time; new diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index ca5b18098..72bbdde84 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -768,7 +768,7 @@ describe("createPruningCompactor — summarize receives the workflow context (CL }); }); -describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { +describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { function firstText(turn: ConversationTurn): string { const block = turn.content.find((b) => b.type === "text"); return block !== undefined && block.type === "text" ? block.text : ""; @@ -795,7 +795,7 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { return [...base, ...extra]; } - test("second apply leaves output[0] bytes identical and appends a later summary", async () => { + test("second apply replaces the prior summary instead of accumulating", async () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500, @@ -808,17 +808,68 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx) ).output; - expect(firstText(defined(output2[0]))).toBe(firstText(defined(output1[0]))); - expect(output2[0]).toBe(output1[0]); - const summaries = compactedTurns(output2); - expect(summaries.length).toBeGreaterThanOrEqual(2); - expect(output2.indexOf(defined(summaries[1]))).toBeGreaterThan(0); + expect(output2[0]).not.toBe(output1[0]); + expect(compactedTurns(output2)).toHaveLength(1); + expect(firstText(defined(output2[0]))).toContain(COMPACTED_PREFIX); expect(hasConsecutiveSameRole(output2)).toBe(false); + expect(allText(output2)).toContain("round1 0"); + }); + + test("second apply keeps the initiating task as its own user turn", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + maxAnchorTurns: 1, + summaryMaxChars: 500, + }); + const goal = "GOAL: migrate the auth module to opaque tokens"; + const turns: ConversationTurn[] = [ + makeTurn({ role: "user", content: [{ type: "text", text: goal }] }), + ]; + for (let i = 0; i < 8; i++) { + turns.push( + makeTurn({ + role: "assistant", + content: [{ type: "text", text: `step ${i}` }], + }), + ); + } + turns.push( + makeTurn({ + role: "user", + content: [{ type: "text", text: "also handle refresh" }], + }), + ); + turns.push( + makeTurn({ + role: "assistant", + content: [{ type: "text", text: "recent reply" }], + }), + ); + turns.push( + makeTurn({ role: "user", content: [{ type: "text", text: "recent ask" }] }), + ); + + const output1 = (await compactor.apply(turns, mockStrategyCtx)).output; + expect( + output1.some( + (t) => + t.role === "user" && + t.content.some((b) => b.type === "text" && b.text === goal), + ), + ).toBe(true); + + const output2 = ( + await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx) + ).output; + expect(compactedTurns(output2)).toHaveLength(1); expect( output2.some( - (t) => t.role === "assistant" && firstText(t) === COMPACT_SPACER_TEXT, + (t) => + t.role === "user" && + t.content.some((b) => b.type === "text" && b.text === goal), ), ).toBe(true); + expect(hasConsecutiveSameRole(output2)).toBe(false); }); test("harness spacer is stamped with the reserved producer id and a visible sentinel", async () => { @@ -840,116 +891,26 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { expect(COMPACT_SPACER_TEXT).not.toBe(LEGACY_COMPACT_SPACER_TEXT); }); - test("frozen prefix does not absorb a model-emitted spacer", async () => { - const compactor = createPruningCompactor({ - keepRecentTurns: 2, - summaryMaxChars: 500, - }); - const output1 = ( - await compactor.apply(grow([], 16, "round1"), mockStrategyCtx) - ).output; - const summary = output1.find((t) => - firstText(t).startsWith(COMPACTED_PREFIX), - ); - expect(summary).toBeDefined(); + test("model-emitted spacer is not treated as a harness spacer", async () => { const echo = makeTurn({ role: "assistant", model: "omen-alpha", content: [{ type: "text", text: LEGACY_COMPACT_SPACER_TEXT }], }); - const output2 = ( - await compactor.apply( - grow([defined(summary), echo], 16, "round2"), - mockStrategyCtx, - ) - ).output; - - let frozenLen = 0; - while ( - frozenLen < output2.length && - firstText(defined(output2[frozenLen])).startsWith(COMPACTED_PREFIX) - ) { - frozenLen++; - if ( - frozenLen < output2.length && - isHarnessCompactSpacer(defined(output2[frozenLen])) - ) - frozenLen++; - } - expect(output2.slice(0, frozenLen)).not.toContain(echo); - expect(isHarnessCompactSpacer(echo)).toBe(false); - const harness = output2.find(isHarnessCompactSpacer); - expect(harness).toBeDefined(); - expect(defined(harness).model).toBe(HARNESS_COMPACT_SPACER_MODEL); - expect(firstText(defined(harness))).toBe(COMPACT_SPACER_TEXT); - expect(harness).not.toBe(echo); - }); - - test("frozen prefix does not absorb a model-stamped new-sentinel echo", async () => { - const compactor = createPruningCompactor({ - keepRecentTurns: 2, - summaryMaxChars: 500, - }); - const output1 = ( - await compactor.apply(grow([], 16, "round1"), mockStrategyCtx) - ).output; - const summary = output1.find((t) => - firstText(t).startsWith(COMPACTED_PREFIX), - ); - expect(summary).toBeDefined(); - const echo = makeTurn({ + const stamped = makeTurn({ role: "assistant", model: "omen-alpha", content: [{ type: "text", text: COMPACT_SPACER_TEXT }], }); - const output2 = ( - await compactor.apply( - grow([defined(summary), echo], 16, "round2"), - mockStrategyCtx, - ) - ).output; - - let frozenLen = 0; - while ( - frozenLen < output2.length && - firstText(defined(output2[frozenLen])).startsWith(COMPACTED_PREFIX) - ) { - frozenLen++; - if ( - frozenLen < output2.length && - isHarnessCompactSpacer(defined(output2[frozenLen])) - ) - frozenLen++; - } - expect(output2.slice(0, frozenLen)).not.toContain(echo); expect(isHarnessCompactSpacer(echo)).toBe(false); - const harness = output2.find(isHarnessCompactSpacer); - expect(harness).toBeDefined(); - expect(defined(harness).model).toBe(HARNESS_COMPACT_SPACER_MODEL); - expect(firstText(defined(harness))).toBe(COMPACT_SPACER_TEXT); - expect(harness).not.toBe(echo); + expect(isHarnessCompactSpacer(stamped)).toBe(false); }); - test("legacy harness spacer without model still freezes", async () => { - const compactor = createPruningCompactor({ - keepRecentTurns: 2, - summaryMaxChars: 500, - }); - const output1 = ( - await compactor.apply(grow([], 16, "round1"), mockStrategyCtx) - ).output; - const summary = output1.find((t) => - firstText(t).startsWith(COMPACTED_PREFIX), - ); - expect(summary).toBeDefined(); + test("legacy harness spacer without model is still recognized", () => { const legacySpacer = makeTurn({ role: "assistant", content: [{ type: "text", text: LEGACY_COMPACT_SPACER_TEXT }], }); - const grown = grow([defined(summary), legacySpacer], 16, "round2"); - const output2 = (await compactor.apply(grown, mockStrategyCtx)).output; - expect(output2[0]).toBe(summary); - expect(output2[1]).toBe(legacySpacer); expect(isHarnessCompactSpacer(legacySpacer)).toBe(true); }); @@ -982,7 +943,7 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { expect(result.record.reason).toBe("no compaction needed"); }); - test("failing then succeeding summarizer does not rewrite output[0]", async () => { + test("failing summarizer keeps prior context; a later success writes one handoff", async () => { const source: InferenceSource = { id: "test", provider: "openai", @@ -1005,22 +966,20 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { summarize, }); const turns = grow([], 16, "fail"); - const output1 = (await compactor.apply(turns, mockStrategyCtx)).output; - expect(firstText(defined(output1[0]))).toContain("Turns compacted:"); - expect(firstText(defined(output1[0]))).not.toContain( - "UNIQUE_SUCCESS_SUMMARY", - ); - expect(firstText(defined(output1[0]))).toContain( - "Model summary unavailable", + const result1 = await compactor.apply(turns, mockStrategyCtx); + expect(result1.output).toBe(turns); + expect(result1.record.reason).toBe("summarize failed"); + expect(firstText(defined(result1.output[0]))).not.toContain(COMPACTED_PREFIX); + + const result2 = await compactor.apply( + grow(result1.output, 16, "ok"), + mockStrategyCtx, ); - - const output2 = ( - await compactor.apply(grow(output1, 16, "ok"), mockStrategyCtx) - ).output; - expect(firstText(defined(output2[0]))).toBe(firstText(defined(output1[0]))); - expect(allText(output2)).toContain("UNIQUE_SUCCESS_SUMMARY"); - expect(hasConsecutiveSameRole(output2)).toBe(false); + expect(compactedTurns(result2.output)).toHaveLength(1); + expect(allText(result2.output)).toContain("UNIQUE_SUCCESS_SUMMARY"); + expect(hasConsecutiveSameRole(result2.output)).toBe(false); }); +} }); describe("buildContextEnvelope", () => { diff --git a/src/exec/runner.ts b/src/exec/runner.ts index ab93a93b1..b064f7198 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -653,8 +653,8 @@ export async function runExec(config: Config): Promise { const summarizeForCompaction = createModelSummarizer({ getSource: () => liveSource, deps: inferenceDeps, + getArchive: () => evidenceArchiveHolder.current, }); - const liveCompactionMode = config.settings?.compactionMode ?? "llm"; const { activated: activatedToolNames, computeAdvertised } = createAdvertisedToolset({ @@ -701,7 +701,6 @@ export async function runExec(config: Config): Promise { liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id, getCompactor: () => createSessionPruningCompactor({ - compactionMode: liveCompactionMode, summarize: summarizeForCompaction, telemetry: liveTelemetry, }), diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 0b4baa155..4386f72ab 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -234,17 +234,17 @@ export interface CompactorConfig { // an independent literal that can silently drift out of sync. export const COMPACTOR_KEEP_RECENT_TURNS = 6; -// Marker on every folded-history user turn. Subsequent compact cycles treat a -// leading run of these (plus the assistant spacers between them) as a frozen -// prefix whose object identity and bytes must not change — rewriting the head -// would invalidate the entire prompt-cache KV for that prefix. +// Marker on every folded-history user turn. Later compact cycles fold these +// (and any leftover assistant spacers from older builds) into one new handoff +// rather than accumulating a frozen prefix of prior summaries. export const COMPACTED_PREFIX = "[Compacted prior context]"; -// Inserted between a frozen prefix that ends on a user summary and a newly -// appended user summary so the assembled history stays role-alternating. -// Visible, non-format (not Unicode Cf) sentinel so Chat Completions adapters -// keep a non-empty assistant turn. Identity is the reserved producer id on -// `compactSpacerTurn`, not this text and not a missing `model` field. +// Inserted between adjacent user turns so assembled history stays +// role-alternating. Visible, non-format (not Unicode Cf) sentinel so Chat +// Completions adapters keep a non-empty assistant turn. Identity is the +// reserved producer id on `compactSpacerTurn`, not this text and not a +// missing `model` field. Later cycles fold it with the previous handoff +// instead of stacking a frozen prefix. export const COMPACT_SPACER_TEXT = "[compact]"; export const LEGACY_COMPACT_SPACER_TEXT = "[compaction]"; export const HARNESS_COMPACT_SPACER_MODEL = "harness"; @@ -634,10 +634,17 @@ function addPairClosure( // it was asked to do, even when it falls far outside the recent window. function firstUserTurnIndex(turns: ConversationTurn[]): number { return turns.findIndex( - (t) => t.role === "user" && t.content.some((b) => b.type === "text"), + (t) => + t.role === "user" && + !isCompactedSummaryTurn(t) && + t.content.some((b) => b.type === "text"), ); } +function isFoldableHandoffTurn(turn: ConversationTurn): boolean { + return isCompactedSummaryTurn(turn) || isCompactSpacerTurn(turn); +} + function resultContentSize( block: Extract, ): number { @@ -773,7 +780,11 @@ function coalesceAdjacentTextTurns( if ( prev !== undefined && prev.role === turn.role && - isPlainTextTurn(turn) + isPlainTextTurn(turn) && + !isCompactedSummaryTurn(prev) && + !isCompactedSummaryTurn(turn) && + !isCompactSpacerTurn(prev) && + !isCompactSpacerTurn(turn) ) { out[out.length - 1] = { ...prev, @@ -786,6 +797,18 @@ function coalesceAdjacentTextTurns( return out; } +function separateAdjacentUserTurns(turns: ConversationTurn[]): ConversationTurn[] { + const out: ConversationTurn[] = []; + for (const turn of turns) { + const prev = out[out.length - 1]; + if (prev !== undefined && prev.role === "user" && turn.role === "user") { + out.push(compactSpacerTurn(turn.timestamp)); + } + out.push(turn); + } + return out; +} + function firstTextBlock(turn: ConversationTurn): string | undefined { for (const block of turn.content) { if (block.type === "text") return block.text; @@ -834,20 +857,8 @@ export function isHarnessCompactSpacer(turn: ConversationTurn): boolean { return turn.model === undefined && text === LEGACY_COMPACT_SPACER_TEXT; } -// Leading run of prior summaries plus the spacers between them. Walks from -// index 0: a compacted user turn, then an immediately following assistant -// spacer when present, then repeat. The newest summary has no trailing spacer -// until the next compact inserts one. -function frozenPrefixLength(turns: readonly ConversationTurn[]): number { - let i = 0; - while (i < turns.length) { - const turn = turns[i]; - if (turn === undefined || !isCompactedSummaryTurn(turn)) break; - i++; - const spacer = turns[i]; - if (spacer !== undefined && isHarnessCompactSpacer(spacer)) i++; - } - return i; +function isCompactSpacerTurn(turn: ConversationTurn): boolean { + return isHarnessCompactSpacer(turn); } function compactSpacerTurn(timestamp: number): ConversationTurn { @@ -866,32 +877,25 @@ export function createPruningCompactor( return { name: "pruning-compactor", - version: "1.4.1", + version: "1.5.0", async apply( turns: ConversationTurn[], _ctx: StrategyContext, ): Promise> { - // Frozen prefix: prior compacted summaries (and spacers) keep their - // object references. Image aging, stubbing, and coalescing run only on - // the live suffix so the prompt-cache KV for the prefix stays valid. - // When there is no prefix, pass `turns` through (not slice(0)) so a - // no-op still returns the same array identity. - const frozenLen = frozenPrefixLength(turns); - const frozen = frozenLen === 0 ? [] : turns.slice(0, frozenLen); - const live = frozenLen === 0 ? turns : turns.slice(frozenLen); + // Prior compacted summaries are folded into the next handoff, not frozen. + // Image aging still skips the recent window so a just-pasted screenshot + // stays live. // Eager image aging runs before the compact/no-op branch so base64 pastes // leave the inference-facing context as soon as they exit the recent window. const aged = await ageImagesOutsideRecentWindow( - live, + turns, cfg.keepRecentTurns, ); if (aged.turns.length <= compactorNoOpFloor(cfg.keepRecentTurns)) { - const output = - frozenLen === 0 ? aged.turns : [...frozen, ...aged.turns]; return { - output, + output: aged.turns, record: { strategy: this.name, version: this.version, @@ -953,6 +957,12 @@ export function createPruningCompactor( for (let i = scoredOlder.length - 1; i >= 0; i--) { const candidate = scoredOlder[i]; if (candidate === undefined) continue; + const candidateTurn = olderTurns[candidate.index]; + if ( + candidateTurn !== undefined && + isFoldableHandoffTurn(candidateTurn) + ) + continue; if ( candidate.score < ANCHOR_SCORE_THRESHOLD || anchorIndices.has(candidate.index) @@ -971,11 +981,17 @@ export function createPruningCompactor( // Always keep the initiating task verbatim, outside the maxAnchorTurns // cap. Losing the oldest user turn is how the agent forgets what it was - // asked to do; correctness outranks the size target here. + // asked to do; correctness outranks the size target here. Prior compacted + // summaries are not the initiating task — they get folded. const initiatingIdx = firstUserTurnIndex(olderTurns); if (initiatingIdx >= 0) addPairClosure(initiatingIdx, partnerIndex, keepFrom, anchorIndices); + for (const idx of [...anchorIndices]) { + const turn = olderTurns[idx]; + if (turn !== undefined && isFoldableHandoffTurn(turn)) anchorIndices.delete(idx); + } + // Ascending original order keeps the concatenated [anchors, recent] // sequence globally index-ordered, so every result still follows its call. const sortedAnchorIndices = [...anchorIndices].sort((a, b) => a - b); @@ -987,9 +1003,8 @@ export function createPruningCompactor( (_, i) => !anchorIndices.has(i), ); - // Keep-set covered the whole live suffix: nothing to fold. Leave the - // input (including any frozen prefix) untouched rather than rewriting - // the head with an empty summary. + // Keep-set covered everything foldable: nothing to replace. Leave the + // input untouched rather than rewriting the head with an empty summary. if (summarizedTurns.length === 0) { return { output: turns, @@ -1015,14 +1030,46 @@ export function createPruningCompactor( ); const supersededReads = supersededReadCallIds(pathToReads); - const summary = - cfg.summarize !== undefined - ? await cfg.summarize(summarizedTurns, cfg.summaryContext?.()) - : buildTurnSummary( - summarizedTurns, - cfg.summaryMaxChars, - anchorTurns.length, - ); + let summary: string; + try { + summary = + cfg.summarize !== undefined + ? await cfg.summarize(summarizedTurns, cfg.summaryContext?.()) + : buildTurnSummary( + summarizedTurns, + cfg.summaryMaxChars, + anchorTurns.length, + ); + } catch { + return { + output: turns, + record: { + strategy: this.name, + version: this.version, + parameters: { keepRecentTurns: cfg.keepRecentTurns }, + reason: "summarize failed", + decisions: { + summarizeFailed: 1, + agedImageCount: aged.agedImageCount, + }, + }, + }; + } + if (summary.trim().length === 0) { + return { + output: turns, + record: { + strategy: this.name, + version: this.version, + parameters: { keepRecentTurns: cfg.keepRecentTurns }, + reason: "summarize failed", + decisions: { + summarizeFailed: 1, + agedImageCount: aged.agedImageCount, + }, + }, + }; + } // A user-role turn survives every adapter unchanged. A system-role turn // does not: the Anthropic builder drops mid-conversation system turns @@ -1043,34 +1090,16 @@ export function createPruningCompactor( // turns keep live base64 so a just-pasted screenshot still reaches the model. const process = (t: ConversationTurn): ConversationTurn => stubSupersededReads(t, supersededReads, callIndex); - const liveOutput = coalesceAdjacentTextTurns([ - summaryTurn, - ...anchorTurns.map(process), - ...recentTurns.map(process), - ]); - - // First compact (no frozen prefix): today's shape — summary leads. - // Later cycles append an additional summary after the frozen prefix - // and never splice into output[0]. - let output: ConversationTurn[]; - if (frozenLen === 0) { - output = liveOutput; - } else { - const lastFrozen = frozen[frozen.length - 1]; - if (lastFrozen === undefined) { - output = liveOutput; - } else { - const firstLive = liveOutput[0]; - const spacer = - lastFrozen.role === "user" && firstLive?.role === "user" - ? [compactSpacerTurn(summaryTurn.timestamp)] - : []; - output = [...frozen, ...spacer, ...liveOutput]; - } - } + const liveOutput = separateAdjacentUserTurns( + coalesceAdjacentTextTurns([ + summaryTurn, + ...anchorTurns.map(process), + ...recentTurns.map(process), + ]), + ); return { - output, + output: liveOutput, record: { strategy: this.name, version: this.version, diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 47c319009..5571198b5 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -443,23 +443,16 @@ describe("skillDirsFromEnabledPlugins", () => { }); describe("createSessionPruningCompactor", () => { - test("only wires a summarize function in llm mode", async () => { + test("wires a summarize function when provided", async () => { const summarize = async () => "summary"; - const pruning = createSessionPruningCompactor({ - compactionMode: "pruning", - summarize, - }); const llm = createSessionPruningCompactor({ - compactionMode: "llm", summarize, }); - // Both return a Compactor; smoke that apply is present without running a full prune. - expect(typeof pruning.apply).toBe("function"); expect(typeof llm.apply).toBe("function"); }); test("builds without a summarize function for sourceless leaves", () => { - const compactor = createSessionPruningCompactor({ compactionMode: "llm" }); + const compactor = createSessionPruningCompactor({}); expect(typeof compactor.apply).toBe("function"); }); @@ -471,7 +464,6 @@ describe("createSessionPruningCompactor", () => { return "summary"; }; const llm = createSessionPruningCompactor({ - compactionMode: "llm", summarize, summaryContext: () => ctx, }); @@ -489,7 +481,6 @@ describe("createSessionPruningCompactor", () => { const folds: { turnsBefore: number; turnsAfter: number }[] = []; const summarize = async () => "summary"; const folding = createSessionPruningCompactor({ - compactionMode: "llm", summarize, onFolded: (info) => folds.push(info), }); @@ -499,14 +490,17 @@ describe("createSessionPruningCompactor", () => { content: [{ type: "text", text: `t${i}` }], timestamp: now, })); - await folding.apply(many as never, { state: {} as never, trigger: "test" }); + const folded = await folding.apply(many as never, { state: {} as never, trigger: "test" }); expect(folds).toHaveLength(1); expect(folds[0]?.turnsBefore).toBe(8); - expect(folds[0]?.turnsAfter).toBeLessThan(8); + expect( + folded.output[0]?.content.some( + (block) => block.type === "text" && block.text.startsWith("[Compacted prior context]"), + ), + ).toBe(true); const silent: { turnsBefore: number; turnsAfter: number }[] = []; const noop = createSessionPruningCompactor({ - compactionMode: "llm", summarize, onFolded: (info) => silent.push(info), }); diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 8f7f0f3d3..31b358875 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -344,7 +344,6 @@ export function buildSessionSourcesFromConfig( const SESSION_COMPACTOR_SUMMARY_MAX_CHARS = 2500; export interface SessionPruningCompactorArgs { - compactionMode: "llm" | "pruning"; /** Omitted for sourceless leaves — compaction falls back to the deterministic stub. */ summarize?: ( turns: ConversationTurn[], @@ -363,9 +362,7 @@ export function createSessionPruningCompactor( const compactor = createPruningCompactor({ keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS, - ...(args.compactionMode !== "pruning" && args.summarize !== undefined - ? { summarize: args.summarize } - : {}), + ...(args.summarize !== undefined ? { summarize: args.summarize } : {}), ...(args.summaryContext ? { summaryContext: args.summaryContext } : {}), }); const telemetry = args.telemetry ?? NOOP_TELEMETRY; @@ -381,7 +378,7 @@ export function createSessionPruningCompactor( // averages toward the runs where nothing happened. if (result.record.decisions.summarizedTurnCount !== undefined) { telemetry.capture("compaction", { - mode: args.compactionMode, + mode: "llm", duration_ms: Date.now() - startedAt, turns_before: turnsBefore, turns_after: result.output.length, diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 2aff1103b..aa25535c8 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -4,15 +4,16 @@ // replaces older turns with a summary. A deterministic stats blob ("Turns: N, // Tools called: ...") loses everything that matters for resuming work, so this // module produces a structured, workflow-aware narrative via a one-shot -// inference call against the session's own model. On any failure it falls back -// to the deterministic summary so compaction never breaks the session. +// inference call against the session's own model. Failure is fatal to that +// compact cycle: the caller retains the prior context instead of substituting +// a statistics-only stub. import { runInference, type Dependencies } from "@intx/inference"; import { createDefaultDependencies } from "@intx/inference/providers"; import { getLogger } from "@intx/log"; import type { ConversationTurn, InferenceSource } from "@intx/types/runtime"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; -import { buildTurnSummary } from "./compactor.js"; +import { buildArchiveSummaryExcerpt, type SummaryExcerptArchive } from "./summary-excerpt.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]); @@ -54,6 +55,8 @@ const SYSTEM_INSTRUCTION = [ "The immediate next action(s) to take right now.", "", "Be specific and terse. Prefer paths, names, and exact values over prose.", + "When the excerpt includes archive:/// refs, keep those identifiers so later", + "turns can retrieve the evidence. Do not invent archive contents.", ].join("\n"); // Pull a compact, model-readable excerpt out of the turns being dropped: @@ -138,8 +141,11 @@ function workflowPreamble(ctx: SummaryContext | undefined): string { export function buildSummaryPrompt( turns: ConversationTurn[], ctx?: SummaryContext, + excerpt?: string, ): string { - return `${workflowPreamble(ctx)}Session excerpt:\n\n${condenseTurns(turns)}`; + const body = + excerpt !== undefined && excerpt.length > 0 ? excerpt : condenseTurns(turns); + return `${workflowPreamble(ctx)}Session excerpt:\n\n${body}`; } // Low-level completion: one inference round-trip returning assistant text. @@ -183,13 +189,15 @@ export interface ModelSummarizerOptions { deps?: Dependencies; /** Cap on the returned summary length. */ maxChars?: number; + /** Primary sessions pass the evidence archive so the prompt is not a clipped stub. */ + getArchive?: () => SummaryExcerptArchive | undefined; } /** * Build a `summarize(turns, ctx)` function suitable for `CompactorConfig`. - * Produces a structured, workflow-aware summary via the model; on any error - * (or empty output) falls back to the deterministic summary so a compaction - * cycle never throws. + * Produces a structured, workflow-aware summary via the model. Empty output + * or a failed call throws so the compact cycle can keep the prior context + * instead of replacing it with a statistics-only stub. */ export function createModelSummarizer( options: ModelSummarizerOptions, @@ -199,11 +207,9 @@ export function createModelSummarizer( const maxChars = options.maxChars ?? 4000; return async (turns, ctx) => { - // The marker tells the model (and anyone reading a transcript) that the - // compacted region is a lossy stats stub, not a real handoff summary. - const fallback = (reason: string): string => - `[Model summary unavailable (${reason}); deterministic fallback]\n${buildTurnSummary(turns, maxChars)}`; try { + const archive = options.getArchive?.(); + const excerpt = archive !== undefined ? await buildArchiveSummaryExcerpt(archive) : undefined; const promptTurns: ConversationTurn[] = [ { role: "system", @@ -212,27 +218,22 @@ export function createModelSummarizer( }, { role: "user", - content: [{ type: "text", text: buildSummaryPrompt(turns, ctx) }], + content: [{ type: "text", text: buildSummaryPrompt(turns, ctx, excerpt) }], timestamp: 0, }, ]; const signal = options.getSignal?.() ?? new AbortController().signal; const text = await complete(promptTurns, options.getSource(), signal); if (text.length === 0) { - logger.warn( - "compaction summary call returned empty text; using deterministic fallback", - ); - return fallback("empty model output"); + logger.warn("compaction summary call returned empty text"); + throw new Error("compaction summary returned empty text"); } return text.length > maxChars ? text.slice(0, maxChars) : text; } catch (error) { - logger.warn( - "compaction summary call failed; using deterministic fallback: {error}", - { - error: error instanceof Error ? error.message : String(error), - }, - ); - return fallback("summary call failed"); + logger.warn("compaction summary call failed: {error}", { + error: error instanceof Error ? error.message : String(error), + }); + throw error instanceof Error ? error : new Error(String(error)); } }; } diff --git a/src/session/summary-excerpt.test.ts b/src/session/summary-excerpt.test.ts new file mode 100644 index 000000000..06ab2dc7f --- /dev/null +++ b/src/session/summary-excerpt.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import type { ArchiveOccurrence } from "./compaction-archive-schema.js"; +import { buildArchiveSummaryExcerpt } from "./summary-excerpt.js"; + +function occ( + partial: Pick & + Partial>, +): ArchiveOccurrence { + return { + sessionId: "s1", + contentHash: "hash", + blobKey: `blob-${partial.occurrenceId}`, + recordedAt: 1, + ...partial, + }; +} + +test("empty archive yields an empty excerpt", async () => { + const excerpt = await buildArchiveSummaryExcerpt({ + listOccurrences: async () => [], + readAuthorizedPayload: async () => { + throw new Error("should not read"); + }, + }); + expect(excerpt).toBe(""); +}); + +test("prefers user messages over tool results and keeps the full payload", async () => { + const userBody = `USER_BODY ${"y".repeat(500)}`; + const excerpt = await buildArchiveSummaryExcerpt({ + listOccurrences: async () => [ + occ({ occurrenceId: "occ-result", kind: "tool_result", callId: "c1" }), + occ({ occurrenceId: "occ-user", kind: "user_message" }), + ], + readAuthorizedPayload: async (id) => (id === "occ-user" ? userBody : "RESULT_BODY"), + }); + expect(excerpt.indexOf("USER_BODY")).toBeGreaterThanOrEqual(0); + expect(excerpt.indexOf("USER_BODY")).toBeLessThan(excerpt.indexOf("RESULT_BODY")); + expect(excerpt).toContain(userBody); + expect(excerpt).toContain("archive:///occ-user"); +}); + +test("gap rows contribute metadata only", async () => { + const excerpt = await buildArchiveSummaryExcerpt({ + listOccurrences: async () => [ + occ({ occurrenceId: "occ-gap", kind: "tool_result", callId: "c9", gap: true }), + ], + readAuthorizedPayload: async () => { + throw new Error("gap rows must not load a payload"); + }, + }); + expect(excerpt).toContain("[gap]"); + expect(excerpt).toContain("(payload not stored)"); + expect(excerpt).toContain("archive:///occ-gap"); +}); + +test("later kinds yield when the budget is already full", async () => { + const excerpt = await buildArchiveSummaryExcerpt( + { + listOccurrences: async () => [ + occ({ occurrenceId: "occ-user", kind: "user_message" }), + occ({ occurrenceId: "occ-result", kind: "tool_result" }), + ], + readAuthorizedPayload: async (id) => (id === "occ-user" ? "USER" : "RESULT"), + }, + 80, + ); + expect(excerpt).toContain("USER"); + expect(excerpt).not.toContain("RESULT"); +}); + +test("omits an occurrence whose full payload cannot fit, without slicing it", async () => { + const excerpt = await buildArchiveSummaryExcerpt( + { + listOccurrences: async () => [ + occ({ occurrenceId: "occ-big", kind: "user_message" }), + occ({ occurrenceId: "occ-small", kind: "user_message" }), + ], + readAuthorizedPayload: async (id) => (id === "occ-big" ? `BIG${"x".repeat(500)}` : "SMALL"), + }, + 80, + ); + expect(excerpt).not.toContain("BIG"); + expect(excerpt).not.toContain("xxxxx"); + expect(excerpt).toContain("SMALL"); + expect(excerpt).toContain("archive:///occ-small"); +}); diff --git a/src/session/summary-excerpt.ts b/src/session/summary-excerpt.ts new file mode 100644 index 000000000..53a9ee156 --- /dev/null +++ b/src/session/summary-excerpt.ts @@ -0,0 +1,80 @@ +// Token-budgeted compaction excerpt from the evidence archive. +// +// The live transcript is a clipped view. The archive holds the authorized +// payloads compaction is about to drop, so the summary call should read those +// rather than 400-character stubs. Budget is the control: later kinds yield +// when earlier ones fill the window. Gap rows contribute metadata only. + +import type { CompactionArchive } from "./compaction-archive.js"; +import type { ArchiveKind, ArchiveOccurrence } from "./compaction-archive-schema.js"; +import { formatArchiveRef } from "./archive-uri.js"; + +export const SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS = 80_000; + +const KIND_PRIORITY: readonly ArchiveKind[] = [ + "user_message", + "assistant_text", + "tool_args", + "tool_failure", + "tool_result", + "overflow_blob", +]; + +export type SummaryExcerptArchive = Pick< + CompactionArchive, + "listOccurrences" | "readAuthorizedPayload" +>; + +function heading(occ: ArchiveOccurrence): string { + const parts = [`### ${occ.kind} ${formatArchiveRef(occ.occurrenceId)}`]; + if (occ.callId !== undefined) parts.push(`call=${occ.callId}`); + if (occ.lifecycle !== undefined) parts.push(`lifecycle=${occ.lifecycle}`); + if (occ.gap === true) parts.push("[gap]"); + return parts.join(" "); +} + +/** + * Build a budgeted, kind-prioritized excerpt for the compaction summary call. + * Empty archives return "" so the caller can fall back to the live transcript. + */ +export async function buildArchiveSummaryExcerpt( + archive: SummaryExcerptArchive, + budgetChars = SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS, +): Promise { + const occurrences = await archive.listOccurrences(); + if (occurrences.length === 0) return ""; + + const byKind = new Map(); + for (const occ of occurrences) { + const list = byKind.get(occ.kind); + if (list !== undefined) list.push(occ); + else byKind.set(occ.kind, [occ]); + } + + const sections: string[] = []; + let used = 0; + + for (const kind of KIND_PRIORITY) { + const group = byKind.get(kind); + if (group === undefined) continue; + for (const occ of group) { + const remaining = budgetChars - used; + if (remaining <= 0) return sections.join("\n\n"); + + let body: string; + if (occ.gap === true) { + body = "(payload not stored)"; + } else { + const payload = await archive.readAuthorizedPayload(occ.occurrenceId); + body = payload; + } + + const section = `${heading(occ)}\n${body}`; + if (section.length + (sections.length > 0 ? 2 : 0) > remaining) continue; + sections.push(section); + used += section.length + 2; + } + } + + return sections.join("\n\n"); +} diff --git a/src/subagent/run.ts b/src/subagent/run.ts index d7f53a040..88916c5b8 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -87,7 +87,6 @@ import { } from "../shell/background-shell.js"; import { createShellCollectTool } from "../agent/background-shell-tool.js"; import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; -import { createModelSummarizer } from "../session/summarizer.js"; import { gatherEnvironment } from "../agent/environment.js"; import { generateSessionId } from "../session/index.js"; import { consumeStream } from "../session/stream-consumer.js"; @@ -1036,9 +1035,6 @@ async function runSubAgentInner( params.catalog, params.settings, ); - const subagentSource = - bundle.sources.find((s) => s.id === bundle.defaultSource) ?? - bundle.sources[0]; agent = await createAgentWithLiveToolDispatch(def, { sources: bundle.sources, defaultSource: bundle.defaultSource, @@ -1062,19 +1058,7 @@ async function runSubAgentInner( defaultId: `${ID_PREFIX}/subagent`, }), compactors: { - "pruning-compactor": createSessionPruningCompactor({ - compactionMode: "llm", - // A structured model summary keeps sub-agent context useful across a - // compaction; the deterministic stub remains the fallback on failure. - ...(subagentSource !== undefined - ? { - summarize: createModelSummarizer({ - getSource: () => subagentSource, - deps: inferenceDeps, - }), - } - : {}), - }), + "pruning-compactor": createSessionPruningCompactor({}), }, }); // Tools were built before the agent; bind the child's store now so own spills diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index e4df877f2..eaff5cb46 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -41,7 +41,6 @@ import { openPalette } from "./shell/palette"; function baseSnapshot(): SettingsSnapshot { return { - compactionMode: "llm", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, @@ -151,7 +150,6 @@ function settingsDeps(overrides?: Partial): { readonly deps: CommandSurfaceDeps; readonly snapshot: () => SettingsSnapshot; readonly calls: { - compaction: string[]; waitForApproval: boolean[]; telemetry: boolean[]; showPromptCost: boolean[]; @@ -159,7 +157,6 @@ function settingsDeps(overrides?: Partial): { } { let state: SettingsSnapshot = { ...baseSnapshot(), ...overrides }; const calls = { - compaction: [] as string[], waitForApproval: [] as boolean[], telemetry: [] as boolean[], showPromptCost: [] as boolean[], @@ -168,10 +165,6 @@ function settingsDeps(overrides?: Partial): { notify: () => undefined, settings: { read: () => state, - setCompactionMode: (mode) => { - calls.compaction.push(mode); - state = { ...state, compactionMode: mode }; - }, setWaitForApproval: (value) => { calls.waitForApproval.push(value); state = { ...state, waitForApproval: value }; @@ -197,14 +190,14 @@ describe("settings surface", () => { await Promise.resolve(); await Promise.resolve(); - expect(shell.overlayItems.some((l) => l.includes("summarize"))).toBe( + expect(shell.overlayItems.some((l) => l.includes("approval wait"))).toBe( true, ); expect(shell.overlayItems.some((l) => l.includes("off"))).toBe(true); }); }); - test("left/right cycles compaction in place and persists", async () => { + test("left/right cycles approval wait in place and persists", async () => { await withShell(async (shell) => { const { deps, calls } = settingsDeps(); openCommandSurface(shell, "settings", deps); @@ -214,9 +207,9 @@ describe("settings surface", () => { expect(cycleOverlaySelection(shell, 1)).toBe(true); await Promise.resolve(); await Promise.resolve(); - expect(calls.compaction).toEqual(["pruning"]); + expect(calls.waitForApproval).toEqual([false]); expect(shell.overlayKind).toBe("settings"); - expect(shell.overlayItems[0]).toContain("drop"); + expect(shell.overlayItems[0]).toContain("off"); }); }); @@ -233,7 +226,7 @@ describe("settings surface", () => { acceptOverlaySelection(shell); const row = shell.streamLog.at(-1); - expect(row?.text).toBe("Set compaction to drop."); + expect(row?.text).toBe("Set wait for approval to off."); expect(row?.meta).not.toBe("overlay"); expect(row?.text).not.toContain("‹"); expect(row?.text).not.toContain("›"); @@ -252,6 +245,8 @@ describe("settings surface", () => { false, ); expect(shell.overlayItems.some((l) => l.includes("scope"))).toBe(false); + expect(shell.overlayItems.some((l) => l.includes("compaction"))).toBe(false); + expect(shell.overlayItems.some((l) => l.includes("summarize"))).toBe(false); }); }); @@ -266,8 +261,8 @@ describe("settings surface", () => { true, ); - // compaction, approval wait, telemetry, show cost - moveOverlaySelection(shell, 3); + // approval wait, telemetry, show cost + moveOverlaySelection(shell, 2); cycleOverlaySelection(shell, 1); await Promise.resolve(); await Promise.resolve(); diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 3a372cdbe..a481eedac 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -98,11 +98,8 @@ export interface WebProviderChoice { readonly name: string; } -export type CompactionMode = "llm" | "pruning"; - /** Live values behind the settings surface, re-read on every open. */ export interface SettingsSnapshot { - readonly compactionMode: CompactionMode; readonly waitForApproval: boolean; readonly telemetryEnabled: boolean; readonly showPromptCost: boolean; @@ -212,7 +209,6 @@ export interface HooksSurfaceSummary { export interface SettingsSurfaceDeps { readonly read: () => SettingsSnapshot; - readonly setCompactionMode: (mode: CompactionMode) => void; readonly setWaitForApproval: (value: boolean) => void; readonly setTelemetryEnabled: (value: boolean) => void; readonly setShowPromptCost: (value: boolean) => void; @@ -408,18 +404,6 @@ function cycleField( .join(" "); } -/** Step `current` to the next/previous option in `options`, wrapping. */ -function cycleValue( - options: readonly T[], - current: T, - direction: -1 | 1, -): T { - const idx = options.indexOf(current); - const base = idx < 0 ? 0 : idx; - const next = options[(base + direction + options.length) % options.length]; - return next ?? current; -} - /** The active option's plain label — the value an accept echo should report, not the row's painted display string. */ function activeOptionLabel( options: readonly CycleOption[], @@ -428,10 +412,6 @@ function activeOptionLabel( return options.find((o) => o.id === activeId)?.label ?? activeId; } -const COMPACTION_OPTIONS: readonly CycleOption[] = [ - { id: "llm", label: "summarize" }, - { id: "pruning", label: "drop" }, -]; const ON_OFF_OPTIONS: readonly CycleOption<"on" | "off">[] = [ { id: "on", label: "on" }, { id: "off", label: "off" }, @@ -454,28 +434,6 @@ function settingsCycleRows( settings: SettingsSurfaceDeps, ): readonly SettingsCycleRow[] { return [ - { - id: "compaction", - value: `${"compaction".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(COMPACTION_OPTIONS, snapshot.compactionMode)}`, - chosenLabel: activeOptionLabel( - COMPACTION_OPTIONS, - snapshot.compactionMode, - ), - describe: { - what: "how the transcript is trimmed once the context fills.", - impact: - "summarize (default) costs a call; drop is free but strips output too.", - tone: "consequence", - }, - cycle: (dir) => - settings.setCompactionMode( - cycleValue( - COMPACTION_OPTIONS.map((o) => o.id), - snapshot.compactionMode, - dir, - ), - ), - }, { id: "wait-for-approval", value: `${"approval wait".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(ON_OFF_OPTIONS, snapshot.waitForApproval ? "on" : "off")}`, diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 80b33b541..f9f73afb2 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -293,12 +293,10 @@ describe("mountRunnerHost command surfaces", () => { surfaces: { settings: { read: () => ({ - compactionMode: "llm", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), - setCompactionMode: () => undefined, setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index dbb604134..74f16d64f 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -502,13 +502,14 @@ export async function assembleTUISession( const buildSessionSources = (): LiveSessionSources => resolveLiveSessionSources(state.config, state.sessionId); - // Compaction summarizer: produces a structured, workflow-aware handoff via a - // one-shot call on the live model, falling back to the deterministic summary - // on any failure. Workflow state is read at compaction time so a pass - // mid-/build or mid-/plan still names the active step. + // Compaction summarizer: structured handoff via the live model. Failure + // keeps prior context rather than substituting a stats stub. Workflow state + // is read at compaction time so a pass mid-/build or mid-/plan still names + // the active step. The archive, when mounted, supplies the unclipped excerpt. const compactionSummarize = createModelSummarizer({ getSource: () => state.liveSource, deps: start.inferenceDeps, + getArchive: () => evidenceArchiveHolder.current, }); const summaryContext = (): SummaryContext | undefined => { const status = workflowHost.status(); @@ -560,7 +561,6 @@ export async function assembleTUISession( : state.liveSource.id, getCompactor: () => createSessionPruningCompactor({ - compactionMode: state.liveCompactionMode, summarize: compactionSummarize, summaryContext, telemetry: liveTelemetry, diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index 98df647ab..5143d3ad2 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -636,18 +636,10 @@ function createSettingsSurface( ) { return { read: () => ({ - compactionMode: state.liveCompactionMode, waitForApproval: resolveWaitForApproval(services.liveToolWatchdog), telemetryEnabled: state.liveTelemetryIntent, showPromptCost: state.liveShowPromptCost, }), - setCompactionMode: (mode: NonNullable) => { - state.liveCompactionMode = mode; - void persistGlobalSettings("compaction mode", (base) => ({ - ...base, - compactionMode: mode, - })); - }, setWaitForApproval: (value: boolean) => { services.liveToolWatchdog.waitForApproval = value; void persistGlobalSettings("wait-for-approval", (base) => ({ diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index c8ae26d23..0b0006501 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -234,9 +234,6 @@ export interface RunnerState { // Every configured server's latest settings entry, for the /mcp surface. configuredMcpEntries: MCPServerSettingsEntry[]; liveHookConfig: Record; - // Mutable reference so the compaction summarize callback reads the live - // mode without requiring an agent rebuild on every settings change. - liveCompactionMode: NonNullable; // Tracks the user's intent (persisted opt-in, updated live by the settings // toggle) rather than the held instance's state, so the settings tab shows // On during the first-run hold. @@ -373,7 +370,6 @@ export function createRunnerState(start: TUIStart): RunnerState { connectedMcpServers: start.resumeSeed.mcpServers, configuredMcpEntries: [...config.mcpServerEntries], liveHookConfig: { ...(config.settings?.hooks ?? {}) }, - liveCompactionMode: config.settings?.compactionMode ?? "llm", liveTelemetryIntent: false, liveShowPromptCost: config.settings?.showPromptCost ?? false, listedGrants: [], diff --git a/src/tui/slash-popup-gate.test.ts b/src/tui/slash-popup-gate.test.ts index 99bbbb4f0..91a3b604c 100644 --- a/src/tui/slash-popup-gate.test.ts +++ b/src/tui/slash-popup-gate.test.ts @@ -140,12 +140,10 @@ function settingsOnCommand( notify: () => undefined, settings: { read: () => ({ - compactionMode: "llm", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), - setCompactionMode: () => undefined, setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, @@ -544,12 +542,10 @@ describe("slash/palette accept holds the host until dispatch settles", () => { notify: () => undefined, settings: { read: () => ({ - compactionMode: "llm", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), - setCompactionMode: () => undefined, setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, @@ -621,12 +617,10 @@ describe("slash/palette accept holds the host until dispatch settles", () => { notify: () => undefined, settings: { read: () => ({ - compactionMode: "llm", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), - setCompactionMode: () => undefined, setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, @@ -808,12 +802,10 @@ describe("overlay host occupancy and opt-in deferral", () => { notify: () => undefined, settings: { read: () => ({ - compactionMode: "llm", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), - setCompactionMode: () => undefined, setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, @@ -967,12 +959,10 @@ describe("overlay host occupancy and opt-in deferral", () => { notify: () => undefined, settings: { read: () => ({ - compactionMode: "llm", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), - setCompactionMode: () => undefined, setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, diff --git a/tests/integration/compaction-baseline.test.ts b/tests/integration/compaction-baseline.test.ts index abed36b07..a1a3734ea 100644 --- a/tests/integration/compaction-baseline.test.ts +++ b/tests/integration/compaction-baseline.test.ts @@ -111,7 +111,7 @@ function queueEvidenceReply(session: IntegrationSession, callId: string) { } } -describe("integration — frozen compaction mechanics baseline", () => { +describe("integration — compaction mechanics baseline", () => { test.serial( "counts repeated real nonzero shell exits as failed attempts", async () => { @@ -294,14 +294,14 @@ describe("integration — frozen compaction mechanics baseline", () => { turn.content.some( (block) => block.type === "text" && block.text.startsWith(COMPACTED_PREFIX), ), - ).length === - fold + 1, + ).length === 1, continuedAtCall: inferenceCount >= 2 ? requestCount + 2 : null, }; folds.push(observation); expect(qualifyingFold(observation)).toBe(true); expect(summaryInputs.length).toBe(fold + 1); - expect(recoverEvidence(reply)).toEqual(REQUIRED_EVIDENCE.slice(0, 1)); + const recovered = recoverEvidence(reply); + expect(recovered).toEqual([...REQUIRED_EVIDENCE]); process.stdout.write( `${JSON.stringify({ phase: fold + 1, ...observation, recoveredFacts: recoverEvidence(reply).length, requiredFacts: REQUIRED_EVIDENCE.length, phaseLatencyMs: performance.now() - startedAt })}\n`, ); diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 6062601aa..6f7f1f666 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -23,7 +23,7 @@ import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; import type { AuthzCallResult } from "@intx/inference"; import type { ReactorEmittedEvent } from "@intx/inference"; import { setupHarness, type Harness } from "@intx/inference-testing"; -import type { ContextTransform, InferenceSource } from "@intx/types/runtime"; +import type { ContextTransform, ContextStore, InferenceSource } from "@intx/types/runtime"; import { type } from "arktype"; import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js"; @@ -32,6 +32,14 @@ import { createAgentToolset } from "../../src/agent/tools.js"; import { ID_PREFIX } from "../../src/branding.js"; import type { PermissionGate } from "../../src/permission/gate.js"; import { createOptimizedContextStore } from "../../src/session/optimized-context-store.js"; +import { + applyRecordingPolicyToText, + createCompactionArchive, + createPrimaryDeliveryAdmission, + hashAuthorizedBytes, + wrapAuthorizeWithEvidenceArchive, + type CompactionArchive, +} from "../../src/session/compaction-archive.js"; import { assertReplySend } from "../../src/subagent/run.js"; import { createModelSummarizer, type CompletionFn } from "../../src/session/summarizer.js"; import { @@ -78,11 +86,20 @@ export async function openIntegrationSession( const harness = setupHarness(); const cwd = mkdtempSync(join(tmpdir(), "corbits-integration-cwd-")); const workdir = join(cwd, ".agent-state", "integration-session"); + const evidenceArchiveHolder: { current: CompactionArchive | undefined } = { current: undefined }; + const storageHolder: { current: ContextStore | undefined } = { current: undefined }; const toolset = await createAgentToolset({ cwd, permissionGate: opts.permissionGate, onOperatorGate: async () => ({ kind: "cancel" }), + ...(opts.compactionCompletion !== undefined + ? { + getEvidenceArchive: () => evidenceArchiveHolder.current, + getBlobWriter: () => storageHolder.current?.writeBlob, + getContextDir: () => workdir, + } + : {}), }); const chatDirectorDef = defineDirector({ @@ -121,11 +138,58 @@ export async function openIntegrationSession( }); const storage = await createOptimizedContextStore(workdir); + storageHolder.current = storage; const startAgent = opts.createAgentFn ?? createAgentWithLiveToolDispatch; - const agent = await startAgent(def, { + const baseAuthorize = opts.authorize ?? permissiveAuthorize(); + let storageForAgent: ContextStore = storage; + let authorize = baseAuthorize; + let primaryArchive: CompactionArchive | undefined; + if (opts.compactionCompletion !== undefined) { + const archive = createCompactionArchive({ + sessionId: "integration-session", + contextDir: workdir, + writeBlob: (key, bytes, contentType) => storage.writeBlob(key, bytes, contentType), + readBlob: (key) => storage.readBlob(key), + }); + primaryArchive = archive; + evidenceArchiveHolder.current = archive; + storageForAgent = { + ...storage, + async writeBlob(key, bytes, contentType, signal) { + await storage.writeBlob(key, bytes, contentType, signal); + if (!key.startsWith("img-")) return; + await archive.recordExistingBlobReference({ + kind: "attachment", + blobKey: key, + contentHash: hashAuthorizedBytes(bytes), + provenance: "persistBlobs:aged-image", + }); + }, + async writeResponse(turn, signal) { + const content = turn.content.map((block) => { + if (block.type !== "text") return block; + const text = applyRecordingPolicyToText(block.text); + return text === block.text ? block : { ...block, text }; + }); + const admitted = { ...turn, content }; + for (const block of admitted.content) { + if (block.type === "text" && block.text.length > 0) { + await archive.recordAuthorizedPayload({ + kind: "assistant_text", + payload: block.text, + provenance: "writeResponse:post-policy", + }); + } + } + return storage.writeResponse(admitted, signal); + }, + }; + authorize = wrapAuthorizeWithEvidenceArchive(baseAuthorize, () => archive); + } + const innerAgent = await startAgent(def, { sources: [INTEGRATION_SOURCE], defaultSource: INTEGRATION_SOURCE.id, - storage, + storage: storageForAgent, workdir, deps: { ...harness.deps, @@ -134,9 +198,7 @@ export async function openIntegrationSession( : {}), }, audit: noopAuditStore(), - ...(opts.authorize !== undefined - ? { authorize: opts.authorize } - : { authorize: permissiveAuthorize() }), + authorize, directors: createDirectorRegistry({ factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, @@ -145,11 +207,11 @@ export async function openIntegrationSession( ? { compactors: { "pruning-compactor": createSessionPruningCompactor({ - compactionMode: "llm", summarize: createModelSummarizer({ getSource: () => INTEGRATION_SOURCE, deps: harness.deps, complete: opts.compactionCompletion, + getArchive: () => evidenceArchiveHolder.current, }), }), }, @@ -157,6 +219,10 @@ export async function openIntegrationSession( : {}), closeTimeoutMs: 0, }); + const agent = + primaryArchive === undefined + ? innerAgent + : createPrimaryDeliveryAdmission(innerAgent, primaryArchive); return { harness, cwd, workdir, agent, toolset }; } diff --git a/tests/unit/summarizer.test.ts b/tests/unit/summarizer.test.ts index 85854d99d..1a0bc9db1 100644 --- a/tests/unit/summarizer.test.ts +++ b/tests/unit/summarizer.test.ts @@ -90,54 +90,57 @@ test("model summarizer returns the model output", async () => { expect(result).toContain("What Happened"); }); -test("model summarizer falls back to deterministic summary on failure", async () => { +test("model summarizer throws on failure instead of substituting a stats stub", async () => { const summarize = createModelSummarizer({ getSource: () => source, complete: async () => { throw new Error("model unreachable"); }, }); - const result = await summarize(turns()); - // Deterministic fallback (buildTurnSummary) reports tool usage stats. - expect(result).toContain("Tools called"); + await expect(summarize(turns())).rejects.toThrow("model unreachable"); }); -test("model summarizer falls back when the model returns empty text", async () => { +test("model summarizer throws when the model returns empty text", async () => { const summarize = createModelSummarizer({ getSource: () => source, complete: async () => "", }); - const result = await summarize(turns()); - expect(result).toContain("Tools called"); + await expect(summarize(turns())).rejects.toThrow("empty text"); }); -test("model summarizer marks a failure fallback as distinguishable from a real summary (CL-6906)", async () => { +test("model summarizer feeds archive payloads into the prompt instead of clipped turns", async () => { + const longPayload = `FULL_ARCHIVE_PAYLOAD ${"x".repeat(600)}`; + let captured = ""; const summarize = createModelSummarizer({ getSource: () => source, - complete: async () => { - throw new Error("model unreachable"); + complete: async (promptTurns) => { + const user = promptTurns.find((t) => t.role === "user"); + const block = user?.content.find((b) => b.type === "text"); + captured = block !== undefined && block.type === "text" ? block.text : ""; + return "## What Happened\n- used archive evidence"; }, + getArchive: () => ({ + listOccurrences: async () => [ + { + occurrenceId: "occ-user", + sessionId: "s1", + kind: "user_message" as const, + contentHash: "h1", + blobKey: "k1", + recordedAt: 1, + }, + ], + readAuthorizedPayload: async () => longPayload, + }), }); const result = await summarize(turns()); - expect(result).toContain("[Model summary unavailable"); - expect(result).toContain("summary call failed"); -}); - -test("model summarizer marks an empty-output fallback as distinguishable from a real summary (CL-6906)", async () => { - const summarize = createModelSummarizer({ - getSource: () => source, - complete: async () => "", - }); - const result = await summarize(turns()); - expect(result).toContain("[Model summary unavailable"); - expect(result).toContain("empty model output"); + expect(result).toContain("What Happened"); + expect(captured).toContain(longPayload); + expect(captured).toContain("archive:///occ-user"); }); -test("model summarizer does not mark a real summary with the fallback marker", async () => { - const summarize = createModelSummarizer({ - getSource: () => source, - complete: async () => "## What Happened\n- read src/auth.ts", - }); - const result = await summarize(turns()); - expect(result).not.toContain("[Model summary unavailable"); +test("buildSummaryPrompt uses a supplied excerpt instead of condensing turns", () => { + const prompt = buildSummaryPrompt(turns(), undefined, "ARCHIVE_EXCERPT_BODY"); + expect(prompt).toContain("ARCHIVE_EXCERPT_BODY"); + expect(prompt).not.toContain("Turns dropped"); }); diff --git a/tests/unit/telemetry-product-events.test.ts b/tests/unit/telemetry-product-events.test.ts index 1a17e59d0..805503b69 100644 --- a/tests/unit/telemetry-product-events.test.ts +++ b/tests/unit/telemetry-product-events.test.ts @@ -630,7 +630,6 @@ test("auth_failure names the provider and never ships the rejection message", as test("compaction fires only when turns were actually folded away", async () => { const { telemetry, events } = harness(); const compactor = createSessionPruningCompactor({ - compactionMode: "pruning", summarize: async () => "summary", telemetry, }); @@ -655,7 +654,7 @@ test("compaction fires only when turns were actually folded away", async () => { const captured = await events(); expect(captured.length).toBe(1); expect(captured[0]?.event).toBe("compaction"); - expect(captured[0]?.properties.mode).toBe("pruning"); + expect(captured[0]?.properties.mode).toBe("llm"); expect(captured[0]?.properties.turns_before).toBe(60); }); From 9d5a46ff13a1ccdf0be3cca3e300b89909b27de6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 14:51:37 -0700 Subject: [PATCH 10/16] Keep archive search on primary prompts, payloads, and the search budget Workers have no archive plugin, so advertising archive:/// sent grep onto the filesystem. Matching the formatted URI line also made pattern archive hit every occurrence. Sit archive search after shell-guard and abort the walk so the same 10s budget applies. --- src/agent/posix-tool-plugins.test.ts | 28 ++++++ src/agent/posix-tool-plugins.ts | 4 +- src/agent/prompts.ts | 22 ++++- .../evidence-archive-search-plugin.test.ts | 89 +++++++++++++++++++ src/plugins/evidence-archive-search-plugin.ts | 84 ++++++++++++++--- src/prompts.test.ts | 11 +++ 6 files changed, 223 insertions(+), 15 deletions(-) diff --git a/src/agent/posix-tool-plugins.test.ts b/src/agent/posix-tool-plugins.test.ts index 950ba0e9c..d9b8ad4d7 100644 --- a/src/agent/posix-tool-plugins.test.ts +++ b/src/agent/posix-tool-plugins.test.ts @@ -378,6 +378,34 @@ describe("buildCorePosixToolPlugins", () => { } }); + test("evidence archive search sits after shell-guard so grep/search inherit the 10s budget", async () => { + const cwd = await mkdtemp(join(tmpdir(), "ic-posix-plugins-")); + try { + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + cwd, + }); + const plugins = buildCorePosixToolPlugins({ + cwd, + permissionGate: gate, + getEvidenceArchive: () => undefined, + }); + const shellGuardIndex = findMiddlewareIndex(plugins, "[command timed out after"); + const archiveIndex = findMiddlewareIndex( + plugins, + "evidence archive is not available in this session", + ); + expect(shellGuardIndex).toBeGreaterThanOrEqual(0); + expect(archiveIndex).toBeGreaterThanOrEqual(0); + expect(archiveIndex).toBeGreaterThan(shellGuardIndex); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("a real line-range edit_file call verifies as success through the wired plugin chain", async () => { const cwd = await mkdtemp(join(tmpdir(), "ic-posix-plugins-")); try { diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index 8089de1be..8a1aa4a6d 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -106,7 +106,6 @@ export function buildCorePosixToolPlugins( resultTruncationPlugin(truncationOptions), toolResultSecretScrubPlugin(), pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }), - ...(getEvidenceArchive !== undefined ? [evidenceArchiveSearchPlugin(getEvidenceArchive)] : []), evidenceArchivePathGuardPlugin(), deleteFilePlugin(cwd, { allowOutside }), toolOutputUriPlugin(), @@ -119,6 +118,9 @@ export function buildCorePosixToolPlugins( ? { getBackgroundShellRegistry } : {}), }), + ...(getEvidenceArchive !== undefined + ? [evidenceArchiveSearchPlugin(getEvidenceArchive)] + : []), readFileGuardPlugin(cwd, readFileGuard), ripgrepPlugin(cwd), // Verify wraps the line-range short-circuit (composeMiddleware runs plugins diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 9ab98442b..44ec90bad 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -226,7 +226,7 @@ export function buildPromptDisciplineBlock( const TOOL_SUMMARIES: Record = { read_file: - "read a file, tool-output:///{callId} from a prior tool result, or archive:///{occurrenceId} (prefer over cat/head/tail in the shell). Only read_file a tool-output:// URI if the truncation notice named one", + "read a file or tool-output:///{callId} from a prior tool result (prefer over cat/head/tail in the shell). Only read_file a tool-output:// URI if the truncation notice named one", write_file: "create or overwrite a file (never shell redirects or heredocs)", edit_file: "make a surgical edit (exact old_string match, or start_line/end_line line-range mode; never include read_file's NNNNNN\\t line prefix; substring failures include nearby file text; prefer over sed/awk in the shell)", @@ -234,8 +234,8 @@ const TOOL_SUMMARIES: Record = { run_shell: "run a shell command (builds, tests, git; pass timeout ms to bound long commands; never to read/write/delete files, search trees, or talk to the user)", search_files: - "find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find); path archive:/// lists evidence-archive refs", - grep: "search file contents (bounded; timeout + output caps — safer than open-ended shell grep -r/rg); path archive:/// searches this session's evidence archive", + "find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find)", + grep: "search file contents (bounded; timeout + output caps — safer than open-ended shell grep -r/rg)", list_dir: "list a directory's entries (bounded listing)", lsp: "resolve symbols — goToDefinition, findReferences, hover (prefer before reading huge files)", web_search: "search the web (use instead of curl or wget)", @@ -263,11 +263,24 @@ const TOOL_SUMMARIES: Record = { "look up skill descriptions by capability (catalog — call directly, do not tool_search for this)", }; +const ARCHIVE_TOOL_SUMMARIES: Partial> = { + read_file: + "read a file, tool-output:///{callId} from a prior tool result, or archive:///{occurrenceId} (prefer over cat/head/tail in the shell). Only read_file a tool-output:// URI if the truncation notice named one", + search_files: + "find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find); path archive:/// lists evidence-archive refs", + grep: "search file contents (bounded; timeout + output caps — safer than open-ended shell grep -r/rg); path archive:/// searches this session's evidence archive", +}; + export function buildAvailableTools( tools: readonly string[] = CORE_TOOL_NAMES, + opts: { advertiseArchive?: boolean } = {}, ): string { + const summaries = + opts.advertiseArchive === true + ? { ...TOOL_SUMMARIES, ...ARCHIVE_TOOL_SUMMARIES } + : TOOL_SUMMARIES; const lines = tools.map( - (tool) => `- ${tool}: ${TOOL_SUMMARIES[tool] ?? "available"}`, + (tool) => `- ${tool}: ${summaries[tool] ?? "available"}`, ); return ["Tools:", ...lines].join("\n"); } @@ -365,6 +378,7 @@ export function buildChatSystemPrompt( baseSection(baseOverride, sessionMode), buildAvailableTools( coreToolNamesForSessionMode(sessionMode, toolAvailability), + { advertiseArchive: true }, ), ]; if (skills.length > 0) sections.push(buildSkillsSection(skills)); diff --git a/src/plugins/evidence-archive-search-plugin.test.ts b/src/plugins/evidence-archive-search-plugin.test.ts index 2438ff839..7655bf802 100644 --- a/src/plugins/evidence-archive-search-plugin.test.ts +++ b/src/plugins/evidence-archive-search-plugin.test.ts @@ -210,6 +210,87 @@ describe("evidenceArchiveSearchPlugin", () => { ); expect(result.content).toBe("passthrough:grep"); }); + + test("grep pattern archive searches payloads and metadata, not the URI line", async () => { + const archive = memoryArchive("sess-uri"); + const occ = await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "plain-payload-without-scheme", + }); + const plugin = evidenceArchiveSearchPlugin(() => archive); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + + const uriHits = await handler( + makeCall("grep", { pattern: "archive", path: "archive:///" }), + new AbortController().signal, + ); + expect(String(uriHits.content)).toContain("no matches"); + expect(String(uriHits.content)).not.toContain(formatArchiveRef(occ.occurrenceId)); + + const payloadHits = await handler( + makeCall("grep", { pattern: "plain-payload-without-scheme", path: "archive:///" }), + new AbortController().signal, + ); + expect(String(payloadHits.content)).toContain(formatArchiveRef(occ.occurrenceId)); + expect(String(payloadHits.content)).toContain("plain-payload-without-scheme"); + }); + + test("archive grep and search_files honor abort before loading remaining payloads", async () => { + const archive = memoryArchive("sess-abort"); + const first = await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "abort-first", + }); + await archive.recordAuthorizedPayload({ + kind: "assistant_text", + payload: "abort-second", + }); + const plugin = evidenceArchiveSearchPlugin(() => archive); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const controller = new AbortController(); + const reads: string[] = []; + const orig = archive.readAuthorizedPayload.bind(archive); + archive.readAuthorizedPayload = async (occurrenceId) => { + reads.push(occurrenceId); + if (reads.length === 1) controller.abort(); + return orig(occurrenceId); + }; + + const grepResult = await handler( + makeCall("grep", { pattern: "abort-second", path: "archive:///" }), + controller.signal, + ); + expect(grepResult.isError).toBe(true); + expect(String(grepResult.content).toLowerCase()).toMatch(/abort/); + expect(reads).toEqual([first.occurrenceId]); + + const searchController = new AbortController(); + searchController.abort(); + const searchResult = await handler( + makeCall("search_files", { pattern: "*", path: "archive:///" }), + searchController.signal, + ); + expect(searchResult.isError).toBe(true); + expect(String(searchResult.content).toLowerCase()).toMatch(/abort/); + }); + + test("archive grep includes posix-style context around payload matches", async () => { + const archive = memoryArchive("sess-context"); + const occ = await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "alpha\nbeta-hit\ngamma", + }); + const plugin = evidenceArchiveSearchPlugin(() => archive); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const hits = await handler( + makeCall("grep", { pattern: "beta-hit", path: "archive:///", context: 1 }), + new AbortController().signal, + ); + const ref = formatArchiveRef(occ.occurrenceId); + expect(String(hits.content)).toContain(`${ref}-1-alpha`); + expect(String(hits.content)).toContain(`${ref}:2:beta-hit`); + expect(String(hits.content)).toContain(`${ref}-3-gamma`); + }); }); describe("createAgentToolset archive mount", () => { @@ -233,6 +314,14 @@ describe("createAgentToolset archive mount", () => { .currentDefinitions() .find((d) => d.name === "read_file"); expect(workerRead?.description ?? "").not.toContain("archive:///"); + const workerGrep = worker.dynamicRunner.currentDefinitions().find((d) => d.name === "grep"); + expect(workerGrep?.description ?? "").not.toContain("archive:///"); + expect(JSON.stringify(workerGrep?.inputSchema ?? {})).not.toContain("archive:///"); + const workerSearch = worker.dynamicRunner + .currentDefinitions() + .find((d) => d.name === "search_files"); + expect(workerSearch?.description ?? "").not.toContain("archive:///"); + expect(JSON.stringify(workerSearch?.inputSchema ?? {})).not.toContain("archive:///"); await worker.dispose(); const primary = await createAgentToolset({ diff --git a/src/plugins/evidence-archive-search-plugin.ts b/src/plugins/evidence-archive-search-plugin.ts index 02b2d957e..29a53364e 100644 --- a/src/plugins/evidence-archive-search-plugin.ts +++ b/src/plugins/evidence-archive-search-plugin.ts @@ -174,7 +174,13 @@ export function evidenceArchiveSearchPlugin( const maxResults = num(call.arguments.max_results) ?? SEARCH_DEFAULT_MAX; return { callId: call.id, - content: await searchArchiveFiles(archive, pattern, target.occurrenceId, maxResults), + content: await searchArchiveFiles( + archive, + pattern, + target.occurrenceId, + maxResults, + signal, + ), }; } const pattern = str(call.arguments.pattern); @@ -187,9 +193,19 @@ export function evidenceArchiveSearchPlugin( } const maxResults = num(call.arguments.max_results) ?? GREP_DEFAULT_MAX; const glob = str(call.arguments.glob); + const contextArg = num(call.arguments.context); + const context = contextArg !== undefined && contextArg > 0 ? Math.floor(contextArg) : 0; return { callId: call.id, - content: await grepArchive(archive, pattern, target.occurrenceId, maxResults, glob), + content: await grepArchive( + archive, + pattern, + target.occurrenceId, + maxResults, + glob, + context, + signal, + ), }; } catch (err) { return { @@ -232,10 +248,13 @@ async function searchArchiveFiles( pattern: string, occurrenceId: string | undefined, maxResults: number, + signal: AbortSignal, ): Promise { + signal.throwIfAborted(); const occurrences = await selectOccurrences(archive, occurrenceId); const hits: string[] = []; for (const occ of occurrences) { + signal.throwIfAborted(); if (hits.length >= maxResults) break; if (!matchesArchiveName(pattern, occ)) continue; hits.push(formatArchiveRef(occ.occurrenceId)); @@ -246,13 +265,58 @@ async function searchArchiveFiles( return hits.join("\n"); } +function grepPayloadHits( + ref: string, + lines: string[], + regex: RegExp, + context: number, + remaining: number, +): string[] { + if (remaining <= 0) return []; + const matchLines: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i] ?? "")) matchLines.push(i); + if (matchLines.length >= remaining) break; + } + if (matchLines.length === 0) return []; + if (context <= 0) { + return matchLines.map((i) => `${ref}:${i + 1}:${lines[i] ?? ""}`); + } + const matchSet = new Set(matchLines); + const ranges: { start: number; end: number }[] = []; + for (const i of matchLines) { + const start = Math.max(0, i - context); + const end = Math.min(lines.length - 1, i + context); + const prev = ranges[ranges.length - 1]; + if (prev !== undefined && start <= prev.end + 1) { + prev.end = end; + } else { + ranges.push({ start, end }); + } + } + const out: string[] = []; + let first = true; + for (const range of ranges) { + if (!first) out.push("--"); + first = false; + for (let i = range.start; i <= range.end; i++) { + const sep = matchSet.has(i) ? ":" : "-"; + out.push(`${ref}${sep}${i + 1}${sep}${lines[i] ?? ""}`); + } + } + return out; +} + async function grepArchive( archive: CompactionArchive, pattern: string, occurrenceId: string | undefined, maxResults: number, glob: string | undefined, + context: number, + signal: AbortSignal, ): Promise { + signal.throwIfAborted(); let regex: RegExp; try { regex = new RegExp(pattern); @@ -262,10 +326,11 @@ async function grepArchive( const occurrences = await selectOccurrences(archive, occurrenceId); const hits: string[] = []; for (const occ of occurrences) { + signal.throwIfAborted(); if (hits.length >= maxResults) break; if (glob !== undefined && !matchesArchiveName(glob, occ)) continue; const ref = formatArchiveRef(occ.occurrenceId); - if (regex.test(metadataBlob(occ)) || regex.test(formatHit(occ))) { + if (regex.test(metadataBlob(occ))) { hits.push(`${ref}:1:${formatHit(occ)}`); continue; } @@ -273,15 +338,14 @@ async function grepArchive( let payload: string; try { payload = await archive.readAuthorizedPayload(occ.occurrenceId); - } catch { + } catch (err) { + if (signal.aborted) throw err; continue; } - const lines = payload.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (hits.length >= maxResults) break; - const line = lines[i] ?? ""; - if (regex.test(line)) hits.push(`${ref}:${i + 1}:${line}`); - } + signal.throwIfAborted(); + hits.push( + ...grepPayloadHits(ref, payload.split("\n"), regex, context, maxResults - hits.length), + ); } if (hits.length === 0) return `no matches for /${pattern}/`; return hits.join("\n"); diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 5e2df2f6a..83a3a6060 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -432,6 +432,17 @@ test("sub-agent prompt does not advertise tool_search (it gets the full toolset) expect(prompt).toContain("your full toolset"); }); +test("worker prompt does not advertise archive:///; primary chat prompt does", () => { + const worker = buildSubAgentSystemPrompt(); + expect(worker).not.toContain("archive:///"); + const primary = buildChatSystemPrompt(); + expect(primary).toContain("archive:///"); + expect(buildAvailableTools(["read_file", "grep", "search_files"])).not.toContain("archive:///"); + expect( + buildAvailableTools(["read_file", "grep", "search_files"], { advertiseArchive: true }), + ).toContain("archive:///"); +}); + // Pins the appendix-last invariant for JS-plugin agents: regardless of how the // systemPromptRole is sourced (data-only markdown vs. a JS plugin's // `agentPlugin.agents[i].systemPromptRole`), `buildSubAgentSystemPrompt` is From c60e0327f0442e01d73eb00f7a89d82df89fed5b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 16:07:12 -0700 Subject: [PATCH 11/16] Align evidence capture with the completeness gate Compact fail-closed because the certificate hashed live history while admission stored a different representation. Record the same bytes the gate looks up; do not weaken the certificate. --- src/context-compactor.test.ts | 1 + src/plugins/result-truncation-plugin.test.ts | 92 ++++++++++ src/session/compaction-archive.test.ts | 147 +++++++++++++++- src/session/compaction-archive.ts | 169 ++++++++++++++----- src/session/summary-excerpt.test.ts | 54 +++++- src/session/summary-excerpt.ts | 31 +++- tests/integration/harness.ts | 20 ++- 7 files changed, 451 insertions(+), 63 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 72bbdde84..35a81c96f 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -866,6 +866,7 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { output2.some( (t) => t.role === "user" && + !firstText(t).startsWith(COMPACTED_PREFIX) && t.content.some((b) => b.type === "text" && b.text === goal), ), ).toBe(true); diff --git a/src/plugins/result-truncation-plugin.test.ts b/src/plugins/result-truncation-plugin.test.ts index 3ee409406..ac6c6072f 100644 --- a/src/plugins/result-truncation-plugin.test.ts +++ b/src/plugins/result-truncation-plugin.test.ts @@ -19,6 +19,7 @@ import { toolOutputAbsolutePath } from "./tool-result-materialize.js"; import { CREDENTIAL_REDACTION } from "./tool-result-secret-scrub.js"; import { toolResultSecretScrubPlugin } from "./tool-result-secret-scrub-plugin.js"; import type { ToolPlugin } from "@intx/tools-posix"; +import type { CompactionArchive } from "../session/compaction-archive.js"; /** In-memory stand-in for ContextStore's writeBlob/readBlob pair, for tests. */ function fakeBlobStore() { @@ -611,6 +612,97 @@ describe("wrapAgentToolsWithResultTruncation", () => { }); }); +describe("archive then truncate", () => { + test("archives oversized results before truncating them", async () => { + const payloads: unknown[] = []; + const blobs: unknown[] = []; + const archive = { + recordAuthorizedPayload: async (input: unknown) => { + payloads.push(input); + return { + occurrenceId: "occ-1", + sessionId: "s", + kind: "tool_result", + contentHash: "h", + blobKey: "b", + recordedAt: 1, + }; + }, + recordExistingBlobReference: async (input: unknown) => { + blobs.push(input); + return { + occurrenceId: "occ-blob", + sessionId: "s", + kind: "overflow_blob", + contentHash: "h", + blobKey: "b", + recordedAt: 1, + }; + }, + } as unknown as CompactionArchive; + const oversized = "x".repeat(MAX_RESULT_CHARS + 50); + const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive }); + if (plugin.middleware === undefined) throw new Error("expected middleware"); + const middleware = plugin.middleware(async (call) => ({ + callId: call.id, + content: oversized, + })); + const result = await middleware( + { id: "call-ld", name: "list_dir", arguments: { path: "." } }, + new AbortController().signal, + ); + expect(String(result.content)).not.toBe(oversized); + expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); + expect(payloads).toEqual([ + { + kind: "tool_result", + payload: oversized, + callId: "call-ld", + provenance: "posix:post-policy-pre-truncation", + }, + ]); + expect(blobs).toHaveLength(1); + }); + + test("archives error results without truncating them", async () => { + const payloads: unknown[] = []; + const archive = { + recordAuthorizedPayload: async (input: unknown) => { + payloads.push(input); + return { + occurrenceId: "occ-err", + sessionId: "s", + kind: "tool_result", + contentHash: "h", + blobKey: "b", + recordedAt: 1, + }; + }, + } as unknown as CompactionArchive; + const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive }); + if (plugin.middleware === undefined) throw new Error("expected middleware"); + const middleware = plugin.middleware(async (call) => ({ + callId: call.id, + content: { error: "conflict" }, + isError: true, + })); + const result = await middleware( + { id: "call-wf", name: "write_file", arguments: { path: "a.ts" } }, + new AbortController().signal, + ); + expect(result.content).toEqual({ error: "conflict" }); + expect(result.isError).toBe(true); + expect(payloads).toEqual([ + { + kind: "tool_result", + payload: { error: "conflict" }, + callId: "call-wf", + provenance: "posix:error", + }, + ]); + }); +}); + describe("scrub-before-spill", () => { test("secret scrub runs on the full content before truncation spills", async () => { const store = fakeBlobStore(); diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index 40af53658..ee1147dcd 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -4,6 +4,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { InboundMessage } from "@intx/types/runtime"; +import { base64Encode } from "@intx/types"; +import { createInboundTurn } from "@intx/inference"; import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js"; import { admitPrimaryInboundMessage, @@ -180,7 +182,11 @@ describe("primary message admission", () => { expect(occurrences).toHaveLength(1); expect(occurrences[0]!.kind).toBe("user_message"); const archived = await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId); - expect(archived).toBe(admitted); + const history = createInboundTurn(delivered[0]!); + const historyText = history?.content.find((block) => block.type === "text"); + expect(historyText?.type === "text" ? historyText.text : undefined).toBe(archived); + expect(archived.startsWith("[From: user@local]\n\n")).toBe(true); + expect(archived.endsWith(admitted)).toBe(true); }); test("send(string) admits and archives like InboundMessage", async () => { @@ -217,7 +223,9 @@ describe("primary message admission", () => { expect(sent[0]).not.toContain(secret); const occurrences = await archive.listOccurrences(); expect(occurrences).toHaveLength(1); - expect(await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId)).toBe(sent[0]!); + expect(await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId)).toBe( + `[From: user@local]\n\n${sent[0]!}`, + ); }); }); @@ -712,7 +720,8 @@ describe("wrapCompactorWithCompletenessGate", () => { const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); const { archive } = memoryArchive(); const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive); - const png = "iVBORw0KGgo="; + const pngBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]); + const png = base64Encode(pngBytes); const turns: import("@intx/types/runtime").ConversationTurn[] = [ { role: "user", @@ -735,16 +744,140 @@ describe("wrapCompactorWithCompletenessGate", () => { expect(blocked.output).toBe(turns); expect(blocked.record.reason).toBe("incomplete-evidence-archive"); + const agent = { + deliver(_message: InboundMessage) { + /* admission archives; history is the turns above */ + }, + async send(content: string | InboundMessage) { + return { ok: true as const, content }; + }, + }; + const admitted = createPrimaryDeliveryAdmission(agent, archive); + await admitted.send( + inbound({ + attachments: [{ name: "shot.png", contentType: "image/png", data: pngBytes }], + }), + ); + const allowed = await wrapped.apply(turns, ctx); + expect(allowed.output).toHaveLength(1); + expect(allowed.record.reason).toBe("compact"); + }); + + test("dropped list_dir and write_file results fail-close until archived", async () => { + const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { archive } = memoryArchive(); + const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive); + const turns: import("@intx/types/runtime").ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "do work" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "tool_call", id: "ld", name: "list_dir", arguments: { path: "." } }], + timestamp: 2, + }, + { + role: "user", + content: [ + { type: "tool_result", callId: "ld", content: [{ type: "text", text: "src/\n" }] }, + ], + timestamp: 3, + }, + { + role: "assistant", + content: [{ type: "tool_call", id: "wf", name: "write_file", arguments: { path: "a.ts" } }], + timestamp: 4, + }, + { + role: "user", + content: [ + { type: "tool_result", callId: "wf", content: [{ type: "text", text: "wrote" }] }, + ], + timestamp: 5, + }, + { + role: "user", + content: [{ type: "text", text: "keep" }], + timestamp: 6, + }, + ]; + + const blocked = await wrapped.apply(turns, ctx); + expect(blocked.output).toBe(turns); + expect(blocked.record.reason).toBe("incomplete-evidence-archive"); + await archive.recordAuthorizedPayload({ - kind: "attachment", - payload: png, + kind: "user_message", + payload: "do work", }); await archive.recordAuthorizedPayload({ - kind: "user_message", - payload: "keep", + kind: "tool_args", + payload: { name: "list_dir", arguments: { path: "." } }, + callId: "ld", + }); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "src/\n", + callId: "ld", + }); + await archive.recordAuthorizedPayload({ + kind: "tool_args", + payload: { name: "write_file", arguments: { path: "a.ts" } }, + callId: "wf", + }); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "wrote", + callId: "wf", }); + const allowed = await wrapped.apply(turns, ctx); expect(allowed.output).toHaveLength(1); expect(allowed.record.reason).toBe("compact"); }); + + test("cloned keep-window turns are not treated as dropped", async () => { + const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { archive } = memoryArchive(); + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "dropped-prefix", + }); + const inner: import("@intx/types/runtime").Compactor = { + name: "pruning-compactor", + version: "1", + async apply(turns) { + const kept = turns.slice(-1).map((turn) => ({ ...turn, content: [...turn.content] })); + return { + output: kept, + record: { + strategy: "pruning-compactor", + version: "1", + parameters: {}, + reason: "compact", + decisions: { dropped: turns.length - 1 }, + }, + }; + }, + }; + const wrapped = wrapCompactorWithCompletenessGate(inner, archive); + const turns: import("@intx/types/runtime").ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "dropped-prefix" }], + timestamp: 1, + }, + { + role: "user", + content: [{ type: "text", text: "keep-window" }], + timestamp: 2, + }, + ]; + const result = await wrapped.apply(turns, ctx); + expect(result.record.reason).toBe("compact"); + expect(result.output).toHaveLength(1); + expect(result.output[0]).not.toBe(turns[1]); + }); }); diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index 4333f6226..d1d6955f9 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -11,12 +11,15 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { type } from "arktype"; +import { createInboundTurn } from "@intx/inference"; +import { createInboundMessage } from "@intx/mime"; import type { Compactor, ConversationTurn, InboundMessage, StrategyContext, } from "@intx/types/runtime"; +import { base64Decode } from "@intx/types"; import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js"; import { ArchiveOccurrence, @@ -160,9 +163,10 @@ export function isControlOrEmptyInbound(message: InboundMessage): boolean { } /** - * Primary admission hook (Corbits-owned). Produces the canonical admitted - * representation that must enter history and the archive — same bytes, not a - * divergent scrubbed copy. Workers omit this hook. + * Primary admission hook (Corbits-owned). Scrubs inbound content before it + * enters the reactor. History then envelopes that admitted content via + * `createInboundTurn`; the archive records that history text, not the + * pre-envelope inbound string. Workers omit this hook. */ export function admitPrimaryInboundMessage(message: InboundMessage): InboundMessage { if (isControlOrEmptyInbound(message)) return message; @@ -173,16 +177,23 @@ export function admitPrimaryInboundMessage(message: InboundMessage): InboundMess return { ...message, content: admittedContent }; } +function historyUserTexts(admitted: InboundMessage): string[] { + const turn = createInboundTurn(admitted); + if (turn === null) return []; + return turn.content.flatMap((entry) => + entry.type === "text" && entry.text.length > 0 ? [entry.text] : [], + ); +} + async function archiveAdmittedInbound( archive: CompactionArchive, admitted: InboundMessage, ): Promise { if (isControlOrEmptyInbound(admitted)) return; - const content = admitted.content ?? ""; - if (content.length > 0) { + for (const historyText of historyUserTexts(admitted)) { await archive.recordAuthorizedPayload({ kind: "user_message", - payload: content, + payload: historyText, provenance: "primary-admission", }); } @@ -219,6 +230,17 @@ async function archiveAdmittedInbound( } } +// Must match vendor/intx-agent send(string) synthesis (DEFAULT_SEND_FROM/TO) +// so archive hashes equal createInboundTurn history text. +function sendFromHeader(rest: readonly unknown[]): string { + const opts = rest[0]; + if (opts !== undefined && typeof opts === "object" && opts !== null && "from" in opts) { + const from = opts.from; + if (typeof from === "string" && from.length > 0) return from; + } + return "user@local"; +} + export function createPrimaryDeliveryAdmission< T extends { deliver: (message: InboundMessage) => void; @@ -241,12 +263,17 @@ export function createPrimaryDeliveryAdmission< if (archive === undefined || admitted.length === 0) { return agent.send(admitted, ...rest); } + const from = sendFromHeader(rest as unknown[]); const run = async () => { - await archive.recordAuthorizedPayload({ - kind: "user_message", - payload: admitted, - provenance: "primary-admission", - }); + await archiveAdmittedInbound( + archive, + createInboundMessage({ + from, + to: "agent@local", + content: admitted, + interchangeType: "conversation.message", + }), + ); return agent.send(admitted, ...rest); }; return run(); @@ -633,16 +660,19 @@ function textKindForRole(role: ConversationTurn["role"]): ArchiveKind { return "user_message"; } +interface ContentUnit { + kind: "text" | "tool_call" | "tool_result" | "image"; + text?: string; + role?: ConversationTurn["role"]; + callId?: string; + data?: string; + blobKey?: string; +} + function coveringOccurrence( - units: readonly { - kind: "text" | "tool_call" | "tool_result" | "image"; - text?: string; - role?: ConversationTurn["role"]; - callId?: string; - data?: string; - blobKey?: string; - }[], + units: readonly ContentUnit[], occurrences: readonly ArchiveOccurrence[], + attachmentByteHashes: ReadonlyMap, ): { ids: string[]; unmatched: boolean } { const used = new Set(); const ids: string[] = []; @@ -653,7 +683,11 @@ function coveringOccurrence( if (occ.kind !== "attachment") return false; if (unit.blobKey !== undefined) return occ.blobKey === unit.blobKey; if (unit.data === undefined) return false; - return occ.contentHash === hashAuthorizedBytes(new TextEncoder().encode(unit.data)); + const liveHash = hashLiveImageData(unit.data); + if (liveHash === undefined) return false; + return ( + occ.contentHash === liveHash || attachmentByteHashes.get(occ.occurrenceId) === liveHash + ); } if (unit.kind === "text") { if (unit.role === undefined || unit.text === undefined) return false; @@ -671,23 +705,75 @@ function coveringOccurrence( return { ids, unmatched: false }; } -function droppedContentUnits(dropped: readonly ConversationTurn[]): { - kind: "text" | "tool_call" | "tool_result" | "image"; - text?: string; - role?: ConversationTurn["role"]; - callId?: string; - data?: string; - blobKey?: string; -}[] { - const units: { - kind: "text" | "tool_call" | "tool_result" | "image"; - text?: string; - role?: ConversationTurn["role"]; - callId?: string; - data?: string; - blobKey?: string; - }[] = []; - for (const turn of dropped) { +function hashLiveImageData(data: string): string | undefined { + try { + return hashAuthorizedBytes(base64Decode(data)); + } catch { + return undefined; + } +} + +function attachmentPayloadByteHash(payload: string): string | undefined { + try { + const parsed: unknown = JSON.parse(payload); + if (parsed === null || typeof parsed !== "object") return undefined; + if (!("contentHash" in parsed)) return undefined; + const hash = parsed.contentHash; + return typeof hash === "string" && hash.length > 0 ? hash : undefined; + } catch { + return undefined; + } +} + +async function attachmentByteHashes( + archive: CompactionArchive, + occurrences: readonly ArchiveOccurrence[], +): Promise> { + const hashes = new Map(); + for (const occ of occurrences) { + if (occ.kind !== "attachment" || occ.gap === true) continue; + try { + const inner = attachmentPayloadByteHash( + await archive.readAuthorizedPayload(occ.occurrenceId), + ); + if (inner !== undefined) hashes.set(occ.occurrenceId, inner); + } catch { + /* payload unreadable; occ.contentHash may still cover a bytes blob */ + } + } + return hashes; +} + +function sameContentUnit(a: ContentUnit, b: ContentUnit): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "image") { + if (a.blobKey !== undefined || b.blobKey !== undefined) return a.blobKey === b.blobKey; + return a.data === b.data; + } + if (a.kind === "text") return a.role === b.role && a.text === b.text; + return a.callId !== undefined && a.callId === b.callId; +} + +function uncoveredContentUnits( + input: readonly ConversationTurn[], + output: readonly ConversationTurn[], +): ContentUnit[] { + const proposed = contentUnits(output); + const used = new Set(); + const uncovered: ContentUnit[] = []; + for (const unit of contentUnits(input)) { + const idx = proposed.findIndex( + (candidate, i) => !used.has(i) && sameContentUnit(unit, candidate), + ); + if (idx === -1) uncovered.push(unit); + else used.add(idx); + } + return uncovered; +} + +function contentUnits(turns: readonly ConversationTurn[]): ContentUnit[] { + const units: ContentUnit[] = []; + for (const turn of turns) { for (const block of turn.content) { if (block.type === "text" && block.text.length > 0) { const marker = parseAgedImageMarker(block.text); @@ -739,11 +825,14 @@ export function wrapCompactorWithCompletenessGate( async apply(turns: ConversationTurn[], ctx: StrategyContext) { await archive.awaitPendingWrites(); const proposed = await inner.apply(turns, ctx); - const dropped = turns.filter((turn) => !proposed.output.includes(turn)); - const units = droppedContentUnits(dropped); + const units = uncoveredContentUnits(turns, proposed.output); if (units.length === 0) return proposed; const occurrences = await archive.listOccurrences(); - const covering = coveringOccurrence(units, occurrences); + const covering = coveringOccurrence( + units, + occurrences, + await attachmentByteHashes(archive, occurrences), + ); if (covering.unmatched || covering.ids.length === 0) { return incompleteIdentity(inner, turns); } diff --git a/src/session/summary-excerpt.test.ts b/src/session/summary-excerpt.test.ts index 06ab2dc7f..85059fee7 100644 --- a/src/session/summary-excerpt.test.ts +++ b/src/session/summary-excerpt.test.ts @@ -54,7 +54,25 @@ test("gap rows contribute metadata only", async () => { expect(excerpt).toContain("archive:///occ-gap"); }); -test("later kinds yield when the budget is already full", async () => { +test("includes attachments after user messages", async () => { + const excerpt = await buildArchiveSummaryExcerpt({ + listOccurrences: async () => [ + occ({ occurrenceId: "occ-asst", kind: "assistant_text" }), + occ({ occurrenceId: "occ-att", kind: "attachment" }), + occ({ occurrenceId: "occ-user", kind: "user_message" }), + ], + readAuthorizedPayload: async (id) => { + if (id === "occ-user") return "USER"; + if (id === "occ-att") return "ATTACH"; + return "ASSISTANT"; + }, + }); + expect(excerpt.indexOf("USER")).toBeGreaterThanOrEqual(0); + expect(excerpt.indexOf("USER")).toBeLessThan(excerpt.indexOf("ATTACH")); + expect(excerpt.indexOf("ATTACH")).toBeLessThan(excerpt.indexOf("ASSISTANT")); +}); + +test("marks over-budget occurrences as omitted instead of dropping them silently", async () => { const excerpt = await buildArchiveSummaryExcerpt( { listOccurrences: async () => [ @@ -67,6 +85,7 @@ test("later kinds yield when the budget is already full", async () => { ); expect(excerpt).toContain("USER"); expect(excerpt).not.toContain("RESULT"); + expect(excerpt).toMatch(/1 occurrence omitted/); }); test("omits an occurrence whose full payload cannot fit, without slicing it", async () => { @@ -84,4 +103,37 @@ test("omits an occurrence whose full payload cannot fit, without slicing it", as expect(excerpt).not.toContain("xxxxx"); expect(excerpt).toContain("SMALL"); expect(excerpt).toContain("archive:///occ-small"); + expect(excerpt).toMatch(/1 occurrence omitted/); +}); + +test("a readAuthorizedPayload throw omits that occurrence and continues", async () => { + const excerpt = await buildArchiveSummaryExcerpt({ + listOccurrences: async () => [ + occ({ occurrenceId: "occ-bad", kind: "user_message" }), + occ({ occurrenceId: "occ-ok", kind: "user_message" }), + ], + readAuthorizedPayload: async (id) => { + if (id === "occ-bad") throw new Error("blob missing"); + return "OK_BODY"; + }, + }); + expect(excerpt).toContain("OK_BODY"); + expect(excerpt).toContain("archive:///occ-ok"); + expect(excerpt).toMatch(/1 occurrence omitted/); +}); + +test("join separators are not charged against the first section", async () => { + const excerpt = await buildArchiveSummaryExcerpt( + { + listOccurrences: async () => [ + occ({ occurrenceId: "occ-a", kind: "user_message" }), + occ({ occurrenceId: "occ-b", kind: "user_message" }), + ], + readAuthorizedPayload: async (id) => (id === "occ-a" ? "AAAA" : "BB"), + }, + "### user_message archive:///occ-a\nAAAA\n\n### user_message archive:///occ-b\nBB".length, + ); + expect(excerpt).toContain("AAAA"); + expect(excerpt).toContain("BB"); + expect(excerpt).not.toMatch(/omitted/); }); diff --git a/src/session/summary-excerpt.ts b/src/session/summary-excerpt.ts index 53a9ee156..046a97e15 100644 --- a/src/session/summary-excerpt.ts +++ b/src/session/summary-excerpt.ts @@ -13,6 +13,7 @@ export const SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS = 80_000; const KIND_PRIORITY: readonly ArchiveKind[] = [ "user_message", + "attachment", "assistant_text", "tool_args", "tool_failure", @@ -53,28 +54,44 @@ export async function buildArchiveSummaryExcerpt( const sections: string[] = []; let used = 0; + let omitted = 0; for (const kind of KIND_PRIORITY) { const group = byKind.get(kind); if (group === undefined) continue; for (const occ of group) { const remaining = budgetChars - used; - if (remaining <= 0) return sections.join("\n\n"); + if (remaining <= 0) { + omitted++; + continue; + } - let body: string; + let body: string | undefined; if (occ.gap === true) { body = "(payload not stored)"; } else { - const payload = await archive.readAuthorizedPayload(occ.occurrenceId); - body = payload; + try { + body = await archive.readAuthorizedPayload(occ.occurrenceId); + } catch { + omitted++; + continue; + } } const section = `${heading(occ)}\n${body}`; - if (section.length + (sections.length > 0 ? 2 : 0) > remaining) continue; + const separator = sections.length > 0 ? 2 : 0; + if (section.length + separator > remaining) { + omitted++; + continue; + } sections.push(section); - used += section.length + 2; + used += section.length + separator; } } - return sections.join("\n\n"); + const excerpt = sections.join("\n\n"); + if (omitted === 0) return excerpt; + const note = `${omitted} occurrence${omitted === 1 ? "" : "s"} omitted`; + if (excerpt.length === 0) return note; + return `${excerpt}\n\n${note}`; } diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 6f7f1f666..cc9fdf070 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -38,6 +38,7 @@ import { createPrimaryDeliveryAdmission, hashAuthorizedBytes, wrapAuthorizeWithEvidenceArchive, + wrapCompactorWithCompletenessGate, type CompactionArchive, } from "../../src/session/compaction-archive.js"; import { assertReplySend } from "../../src/subagent/run.js"; @@ -203,17 +204,20 @@ export async function openIntegrationSession( factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, }), - ...(opts.compactionCompletion !== undefined + ...(opts.compactionCompletion !== undefined && primaryArchive !== undefined ? { compactors: { - "pruning-compactor": createSessionPruningCompactor({ - summarize: createModelSummarizer({ - getSource: () => INTEGRATION_SOURCE, - deps: harness.deps, - complete: opts.compactionCompletion, - getArchive: () => evidenceArchiveHolder.current, + "pruning-compactor": wrapCompactorWithCompletenessGate( + createSessionPruningCompactor({ + summarize: createModelSummarizer({ + getSource: () => INTEGRATION_SOURCE, + deps: harness.deps, + complete: opts.compactionCompletion, + getArchive: () => evidenceArchiveHolder.current, + }), }), - }), + primaryArchive, + ), }, } : {}), From 8d5b5249f7764aff4529c62267456ef359431a19 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 17:29:23 -0700 Subject: [PATCH 12/16] Cap archive grep max results on match hits Context lines and separators were counted against max_results, so later matching occurrences were dropped. --- .../evidence-archive-search-plugin.test.ts | 30 +++++++++++++++++++ src/plugins/evidence-archive-search-plugin.ts | 27 ++++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/plugins/evidence-archive-search-plugin.test.ts b/src/plugins/evidence-archive-search-plugin.test.ts index 7655bf802..3f2d3f560 100644 --- a/src/plugins/evidence-archive-search-plugin.test.ts +++ b/src/plugins/evidence-archive-search-plugin.test.ts @@ -291,6 +291,36 @@ describe("evidenceArchiveSearchPlugin", () => { expect(String(hits.content)).toContain(`${ref}:2:beta-hit`); expect(String(hits.content)).toContain(`${ref}-3-gamma`); }); + + test("archive grep max_results caps match hits rather than context lines", async () => { + const archive = memoryArchive("sess-max-results-context"); + const first = await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "before-one\nneedle\nafter-one", + }); + const second = await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: "before-two\nneedle\nafter-two", + }); + const plugin = evidenceArchiveSearchPlugin(() => archive); + const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const hits = await handler( + makeCall("grep", { + pattern: "needle", + path: "archive:///", + context: 1, + max_results: 2, + }), + new AbortController().signal, + ); + const content = String(hits.content); + const firstRef = formatArchiveRef(first.occurrenceId); + const secondRef = formatArchiveRef(second.occurrenceId); + expect(content).toContain(`${firstRef}:2:needle`); + expect(content).toContain(`${secondRef}:2:needle`); + expect(content).toContain(`${firstRef}-1-before-one`); + expect(content).toContain(`${secondRef}-1-before-two`); + }); }); describe("createAgentToolset archive mount", () => { diff --git a/src/plugins/evidence-archive-search-plugin.ts b/src/plugins/evidence-archive-search-plugin.ts index 29a53364e..544d2443f 100644 --- a/src/plugins/evidence-archive-search-plugin.ts +++ b/src/plugins/evidence-archive-search-plugin.ts @@ -271,16 +271,19 @@ function grepPayloadHits( regex: RegExp, context: number, remaining: number, -): string[] { - if (remaining <= 0) return []; +): { lines: string[]; matchCount: number } { + if (remaining <= 0) return { lines: [], matchCount: 0 }; const matchLines: number[] = []; for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i] ?? "")) matchLines.push(i); if (matchLines.length >= remaining) break; } - if (matchLines.length === 0) return []; + if (matchLines.length === 0) return { lines: [], matchCount: 0 }; if (context <= 0) { - return matchLines.map((i) => `${ref}:${i + 1}:${lines[i] ?? ""}`); + return { + lines: matchLines.map((i) => `${ref}:${i + 1}:${lines[i] ?? ""}`), + matchCount: matchLines.length, + }; } const matchSet = new Set(matchLines); const ranges: { start: number; end: number }[] = []; @@ -304,7 +307,7 @@ function grepPayloadHits( out.push(`${ref}${sep}${i + 1}${sep}${lines[i] ?? ""}`); } } - return out; + return { lines: out, matchCount: matchLines.length }; } async function grepArchive( @@ -325,13 +328,15 @@ async function grepArchive( } const occurrences = await selectOccurrences(archive, occurrenceId); const hits: string[] = []; + let matchCount = 0; for (const occ of occurrences) { signal.throwIfAborted(); - if (hits.length >= maxResults) break; + if (matchCount >= maxResults) break; if (glob !== undefined && !matchesArchiveName(glob, occ)) continue; const ref = formatArchiveRef(occ.occurrenceId); if (regex.test(metadataBlob(occ))) { hits.push(`${ref}:1:${formatHit(occ)}`); + matchCount++; continue; } if (occ.gap === true) continue; @@ -343,9 +348,15 @@ async function grepArchive( continue; } signal.throwIfAborted(); - hits.push( - ...grepPayloadHits(ref, payload.split("\n"), regex, context, maxResults - hits.length), + const payloadHits = grepPayloadHits( + ref, + payload.split("\n"), + regex, + context, + maxResults - matchCount, ); + hits.push(...payloadHits.lines); + matchCount += payloadHits.matchCount; } if (hits.length === 0) return `no matches for /${pattern}/`; return hits.join("\n"); From 5f56cc59070111f74796ef72c4a1423697084500 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 08:40:03 -0700 Subject: [PATCH 13/16] Fix rebase typecheck for chat-agent wiring stubs --- src/context-compactor.test.ts | 1 - src/session/assemble-runtime.test.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 35a81c96f..19fc8d021 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -980,7 +980,6 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { expect(allText(result2.output)).toContain("UNIQUE_SUCCESS_SUMMARY"); expect(hasConsecutiveSameRole(result2.output)).toBe(false); }); -} }); describe("buildContextEnvelope", () => { diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 41b0ceb6d..19e7d9810 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -128,6 +128,7 @@ function stubChatAgentWiring(overrides: Partial = {}): ChatAgen requestContinuation: () => {}, getProvider: () => ({ providerName: "test", model: "m" }), getWorkdir: () => "/build-dir", + getSessionId: () => "test-session", inferenceDeps: stubInferenceDeps(), getSources: () => [ { From 6c24c54efe06be91b6a2b7a4eedab846d10212e3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 08:41:11 -0700 Subject: [PATCH 14/16] Format after rebase onto main --- evals/compaction/fixtures.ts | 19 +- evals/compaction/metrics.test.ts | 45 +++-- evals/compaction/metrics.ts | 37 +++- src/agent/posix-tool-plugins.test.ts | 5 +- src/agent/posix-tool-plugins.ts | 4 +- src/agent/tools.ts | 3 +- src/context-compactor.test.ts | 9 +- src/mcp/client.ts | 7 +- src/mcp/plugin.ts | 7 +- .../evidence-archive-path-guard.test.ts | 45 ++++- src/plugins/evidence-archive-path-guard.ts | 6 +- .../evidence-archive-search-plugin.test.ts | 114 ++++++++--- src/plugins/evidence-archive-search-plugin.ts | 56 ++++-- src/plugins/result-truncation-plugin.test.ts | 8 +- src/plugins/result-truncation-plugin.ts | 10 +- .../tool-result-secret-scrub-plugin.ts | 5 +- src/plugins/tool-result-secret-scrub.test.ts | 22 +- src/plugins/tool-result-secret-scrub.ts | 3 +- src/prompts.test.ts | 8 +- src/session/archive-uri.test.ts | 8 +- src/session/archive-uri.ts | 4 +- src/session/assemble-runtime.test.ts | 57 ++++-- src/session/assemble-runtime.ts | 14 +- src/session/attachment-store.ts | 3 +- src/session/compaction-archive-schema.ts | 4 +- src/session/compaction-archive.test.ts | 188 ++++++++++++++---- src/session/compaction-archive.ts | 153 ++++++++++---- src/session/compactor.ts | 12 +- src/session/optimized-context-store.test.ts | 26 ++- src/session/runtime-assembly.test.ts | 9 +- src/session/summarizer.ts | 18 +- src/session/summary-excerpt.test.ts | 23 ++- src/session/summary-excerpt.ts | 5 +- src/tui/command-surfaces.test.ts | 8 +- .../integration/compaction-atomicity.test.ts | 60 ++++-- tests/integration/compaction-baseline.test.ts | 91 +++++++-- tests/integration/harness.ts | 27 ++- 37 files changed, 855 insertions(+), 268 deletions(-) diff --git a/evals/compaction/fixtures.ts b/evals/compaction/fixtures.ts index 67f1fdfb4..2ec9eeef8 100644 --- a/evals/compaction/fixtures.ts +++ b/evals/compaction/fixtures.ts @@ -20,19 +20,28 @@ export const REQUIRED_EVIDENCE: readonly Evidence[] = [ { id: "constraint", source: "operator:initial", value: "no-schema-change" }, { id: "decision", source: "operator:correction", value: "west-not-east" }, { id: "failure", source: "command:diagnose", value: "unsupported-format-7" }, - { id: "decisive", source: "file:diagnostic.log:middle", value: "route-cobalt" }, + { + id: "decisive", + source: "file:diagnostic.log:middle", + value: "route-cobalt", + }, ]; export function evidenceText(facts: readonly Evidence[]): string { - return facts.map((fact) => `[[evidence:${fact.id}|${fact.source}|${fact.value}]]`).join("\n"); + return facts + .map((fact) => `[[evidence:${fact.id}|${fact.source}|${fact.value}]]`) + .join("\n"); } export const INITIAL = - "Audit the deployment. Preserve this constraint: " + evidenceText(REQUIRED_EVIDENCE.slice(0, 1)); + "Audit the deployment. Preserve this constraint: " + + evidenceText(REQUIRED_EVIDENCE.slice(0, 1)); export const CORRECTION = - "Correction: target west instead of east. " + evidenceText(REQUIRED_EVIDENCE.slice(1, 2)); + "Correction: target west instead of east. " + + evidenceText(REQUIRED_EVIDENCE.slice(1, 2)); export const FAILED_OUTPUT = - "Diagnostic preamble.\n".repeat(250) + evidenceText(REQUIRED_EVIDENCE.slice(2, 3)); + "Diagnostic preamble.\n".repeat(250) + + evidenceText(REQUIRED_EVIDENCE.slice(2, 3)); export const OVERSIZED_OUTPUT = "Unrelated diagnostic row.\n".repeat(1500) + evidenceText(REQUIRED_EVIDENCE.slice(3)) + diff --git a/evals/compaction/metrics.test.ts b/evals/compaction/metrics.test.ts index 6754d807d..ab4ca8ff3 100644 --- a/evals/compaction/metrics.test.ts +++ b/evals/compaction/metrics.test.ts @@ -30,17 +30,25 @@ const baseline = { describe("compaction baseline grading", () => { test("requires evidence actually visible to the scripted responder", () => { - expect(recoverEvidence("[[evidence:region|operator:correction|west]]")).toEqual([fact]); + expect( + recoverEvidence("[[evidence:region|operator:correction|west]]"), + ).toEqual([fact]); expect(recoverEvidence("The region was mentioned earlier.")).toEqual([]); expect( - grade({ ...baseline, recovered: recoverEvidence("evidence removed") }).factualRecovery, + grade({ ...baseline, recovered: recoverEvidence("evidence removed") }) + .factualRecovery, ).toBe(false); }); test("rejects wrong values, sources, and altered artifacts independently", () => { expect(grade(baseline).passed).toBe(true); - expect(grade({ ...baseline, recovered: [{ ...fact, value: "east" }] }).passed).toBe(false); - expect(grade({ ...baseline, recovered: [{ ...fact, source: "invented" }] }).passed).toBe(false); + expect( + grade({ ...baseline, recovered: [{ ...fact, value: "east" }] }).passed, + ).toBe(false); + expect( + grade({ ...baseline, recovered: [{ ...fact, source: "invented" }] }) + .passed, + ).toBe(false); const altered = grade({ ...baseline, artifact: "east\n" }); expect(altered.completion).toBe(false); expect(altered.factualRecovery).toBe(true); @@ -50,9 +58,13 @@ describe("compaction baseline grading", () => { expect(grade({ ...baseline, folds: [] }).qualifying).toBe(false); for (const fold of folds) { expect(qualifyingFold({ ...fold, persisted: false })).toBe(false); - expect(qualifyingFold({ ...fold, afterHash: fold.beforeHash })).toBe(false); + expect(qualifyingFold({ ...fold, afterHash: fold.beforeHash })).toBe( + false, + ); expect(qualifyingFold({ ...fold, continuedAtCall: null })).toBe(false); - expect(qualifyingFold({ ...fold, afterTurns: fold.beforeTurns })).toBe(false); + expect(qualifyingFold({ ...fold, afterTurns: fold.beforeTurns })).toBe( + false, + ); } expect(grade({ ...baseline, recovered: [] }).requiredFacts).toBe(1); }); @@ -89,16 +101,23 @@ describe("compaction baseline grading", () => { }); test("missing usage is unavailable, not zero or an unlabelled estimate", () => { - expect(Measurement({ status: "unavailable", reason: "offline" }) instanceof type.errors).toBe( - false, - ); expect( - Measurement({ status: "reported", value: -1, unit: "tokens" }) instanceof type.errors, - ).toBe(true); - expect(Measurement({ value: 0, unit: "tokens" }) instanceof type.errors).toBe(true); + Measurement({ status: "unavailable", reason: "offline" }) instanceof + type.errors, + ).toBe(false); expect( - Measurement({ status: "synthetic", value: 200000, unit: "trigger tokens" }) instanceof + Measurement({ status: "reported", value: -1, unit: "tokens" }) instanceof type.errors, + ).toBe(true); + expect( + Measurement({ value: 0, unit: "tokens" }) instanceof type.errors, + ).toBe(true); + expect( + Measurement({ + status: "synthetic", + value: 200000, + unit: "trigger tokens", + }) instanceof type.errors, ).toBe(false); }); }); diff --git a/evals/compaction/metrics.ts b/evals/compaction/metrics.ts index 3cc4efadb..582a0227c 100644 --- a/evals/compaction/metrics.ts +++ b/evals/compaction/metrics.ts @@ -1,11 +1,19 @@ import { type } from "arktype"; -export const Evidence = type({ id: "string", source: "string", value: "string" }); +export const Evidence = type({ + id: "string", + source: "string", + value: "string", +}); export type Evidence = typeof Evidence.infer; export const Measurement = type.or( { status: "'unavailable'", reason: "string" }, - { status: "'reported' | 'estimated' | 'synthetic'", value: "number >= 0", unit: "string" }, + { + status: "'reported' | 'estimated' | 'synthetic'", + value: "number >= 0", + unit: "string", + }, ); export type Measurement = typeof Measurement.infer; @@ -54,8 +62,10 @@ export function repeatedWork(trace: readonly Work[]) { } if (seen.has(key)) { if (work.name === "read_file") repeatedReads++; - if (["grep", "search_files", "web_search"].includes(work.name)) repeatedSearches++; - if (["write_file", "edit_file", "apply_patch"].includes(work.name)) duplicatedEdits++; + if (["grep", "search_files", "web_search"].includes(work.name)) + repeatedSearches++; + if (["write_file", "edit_file", "apply_patch"].includes(work.name)) + duplicatedEdits++; } if (failures.has(key)) repeatedFailedAttempts++; seen.add(key); @@ -81,7 +91,9 @@ export function grade(args: { const recoveredFacts = args.expected.filter((fact) => args.recovered.some( (answer) => - answer.id === fact.id && answer.source === fact.source && answer.value === fact.value, + answer.id === fact.id && + answer.source === fact.source && + answer.value === fact.value, ), ).length; const work = repeatedWork(args.trace); @@ -89,7 +101,10 @@ export function grade(args: { const completion = args.artifact === args.expectedArtifact; const factualRecovery = recoveredFacts === args.expected.length; const repeatedActions = - work.repeatedReads + work.repeatedSearches + work.repeatedFailedAttempts + work.duplicatedEdits; + work.repeatedReads + + work.repeatedSearches + + work.repeatedFailedAttempts + + work.duplicatedEdits; return { completion, factualRecovery, @@ -98,14 +113,20 @@ export function grade(args: { persistedFolds, qualifying: persistedFolds >= 3, ...work, - passed: completion && factualRecovery && persistedFolds >= 3 && repeatedActions === 0, + passed: + completion && + factualRecovery && + persistedFolds >= 3 && + repeatedActions === 0, }; } /** The responder receives only inference-visible text, never grader expectations. */ export function recoverEvidence(context: string): Evidence[] { const facts = new Map(); - for (const match of context.matchAll(/\[\[evidence:([^|\]\n]+)\|([^|\]\n]+)\|([^\]\n]+)\]\]/g)) { + for (const match of context.matchAll( + /\[\[evidence:([^|\]\n]+)\|([^|\]\n]+)\|([^\]\n]+)\]\]/g, + )) { const [, id, source, value] = match; if (id !== undefined && source !== undefined && value !== undefined) { facts.set(id, { id, source, value }); diff --git a/src/agent/posix-tool-plugins.test.ts b/src/agent/posix-tool-plugins.test.ts index d9b8ad4d7..2a6ae450f 100644 --- a/src/agent/posix-tool-plugins.test.ts +++ b/src/agent/posix-tool-plugins.test.ts @@ -393,7 +393,10 @@ describe("buildCorePosixToolPlugins", () => { permissionGate: gate, getEvidenceArchive: () => undefined, }); - const shellGuardIndex = findMiddlewareIndex(plugins, "[command timed out after"); + const shellGuardIndex = findMiddlewareIndex( + plugins, + "[command timed out after", + ); const archiveIndex = findMiddlewareIndex( plugins, "evidence archive is not available in this session", diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index 8a1aa4a6d..ddb1dc64c 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -95,7 +95,9 @@ export function buildCorePosixToolPlugins( // regardless. const allowOutside = (): boolean => permissionGate.getSkipPermissions(); const truncationOptions = - getBlobWriter !== undefined || getContextDir !== undefined || getEvidenceArchive !== undefined + getBlobWriter !== undefined || + getContextDir !== undefined || + getEvidenceArchive !== undefined ? { ...(getBlobWriter !== undefined ? { getBlobWriter } : {}), ...(getContextDir !== undefined ? { getContextDir } : {}), diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 0774a5d7c..f8fbb51c6 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -562,7 +562,8 @@ export async function createAgentToolset( let definition = advertiseEditFileLineRange( advertiseShellGuardTimeout(tool.definition, shellTimeout?.defaultMs), ); - if (getEvidenceArchive !== undefined) definition = advertiseArchiveSurface(definition); + if (getEvidenceArchive !== undefined) + definition = advertiseArchiveSurface(definition); return { ...tool, definition }; }), createListDirTool(cwd, { diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 19fc8d021..352befbc1 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -846,7 +846,10 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { }), ); turns.push( - makeTurn({ role: "user", content: [{ type: "text", text: "recent ask" }] }), + makeTurn({ + role: "user", + content: [{ type: "text", text: "recent ask" }], + }), ); const output1 = (await compactor.apply(turns, mockStrategyCtx)).output; @@ -970,7 +973,9 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { const result1 = await compactor.apply(turns, mockStrategyCtx); expect(result1.output).toBe(turns); expect(result1.record.reason).toBe("summarize failed"); - expect(firstText(defined(result1.output[0]))).not.toContain(COMPACTED_PREFIX); + expect(firstText(defined(result1.output[0]))).not.toContain( + COMPACTED_PREFIX, + ); const result2 = await compactor.apply( grow(result1.output, 16, "ok"), diff --git a/src/mcp/client.ts b/src/mcp/client.ts index aa90ab2a4..3269a2691 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -546,9 +546,12 @@ async function finishClient( serverName, tools, async callBlocks(toolName, args, signal) { - const context = authContext === undefined ? undefined : { ...authContext, signal }; + const context = + authContext === undefined ? undefined : { ...authContext, signal }; const result = await withHTTPAuthorizationRecovery(context, () => - client.callTool({ name: toolName, arguments: args }, undefined, { signal }), + client.callTool({ name: toolName, arguments: args }, undefined, { + signal, + }), ); return validateMcpContentBlocks(result.content); }, diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts index cb1fa8e8b..d933d8a8b 100644 --- a/src/mcp/plugin.ts +++ b/src/mcp/plugin.ts @@ -60,7 +60,12 @@ export function mcpClientTools( client: MCPClient, spillOptions: McpSpillOptions = {}, ): AgentTool[] { - const { getBlobWriter, getContextDir, excludeToolNames = [], getEvidenceArchive } = spillOptions; + const { + getBlobWriter, + getContextDir, + excludeToolNames = [], + getEvidenceArchive, + } = spillOptions; const excluded = new Set(excludeToolNames); return client.tools diff --git a/src/plugins/evidence-archive-path-guard.test.ts b/src/plugins/evidence-archive-path-guard.test.ts index fb758008c..1c3a25700 100644 --- a/src/plugins/evidence-archive-path-guard.test.ts +++ b/src/plugins/evidence-archive-path-guard.test.ts @@ -17,23 +17,42 @@ const nextHandler = async (call: ToolCall): Promise => ({ describe("isProtectedEvidenceLocation", () => { test("matches evidence-archive and tool-output/archive-* forms", () => { - expect(isProtectedEvidenceLocation("evidence-archive/index.jsonl")).toBe(true); - expect(isProtectedEvidenceLocation("/tmp/context/evidence-archive")).toBe(true); - expect(isProtectedEvidenceLocation("C:\\tmp\\evidence-archive\\index.jsonl")).toBe(true); - expect(isProtectedEvidenceLocation("tool-output/archive-sess-occ-1")).toBe(true); - expect(isProtectedEvidenceLocation("tool-output:///archive-sess-occ-1")).toBe(true); - expect(isProtectedEvidenceLocation("src/session/compaction-archive.ts")).toBe(false); - expect(isProtectedEvidenceLocation("tool-output:///other-spill")).toBe(false); + expect(isProtectedEvidenceLocation("evidence-archive/index.jsonl")).toBe( + true, + ); + expect(isProtectedEvidenceLocation("/tmp/context/evidence-archive")).toBe( + true, + ); + expect( + isProtectedEvidenceLocation("C:\\tmp\\evidence-archive\\index.jsonl"), + ).toBe(true); + expect(isProtectedEvidenceLocation("tool-output/archive-sess-occ-1")).toBe( + true, + ); + expect( + isProtectedEvidenceLocation("tool-output:///archive-sess-occ-1"), + ).toBe(true); + expect( + isProtectedEvidenceLocation("src/session/compaction-archive.ts"), + ).toBe(false); + expect(isProtectedEvidenceLocation("tool-output:///other-spill")).toBe( + false, + ); }); }); describe("evidenceArchivePathGuardPlugin", () => { test("denies path tools targeting evidence-archive or tool-output/archive-*", async () => { const plugin = evidenceArchivePathGuardPlugin(); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const denied = [ makeCall("read_file", { path: "evidence-archive/index.jsonl" }), - makeCall("grep", { path: "/tmp/context/evidence-archive", pattern: "foo" }), + makeCall("grep", { + path: "/tmp/context/evidence-archive", + pattern: "foo", + }), makeCall("search_files", { path: "evidence-archive" }), makeCall("list_dir", { path: "evidence-archive" }), makeCall("write_file", { path: "evidence-archive/x", content: "nope" }), @@ -50,7 +69,9 @@ describe("evidenceArchivePathGuardPlugin", () => { test("does not deny a grep pattern that mentions evidence-archive", async () => { const plugin = evidenceArchivePathGuardPlugin(); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const result = await handler( makeCall("grep", { path: "src", pattern: "evidence-archive" }), new AbortController().signal, @@ -61,7 +82,9 @@ describe("evidenceArchivePathGuardPlugin", () => { test("passes ordinary workspace paths", async () => { const plugin = evidenceArchivePathGuardPlugin(); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const result = await handler( makeCall("read_file", { path: "src/session/compaction-archive.ts" }), new AbortController().signal, diff --git a/src/plugins/evidence-archive-path-guard.ts b/src/plugins/evidence-archive-path-guard.ts index 54f10d5df..a5f57e446 100644 --- a/src/plugins/evidence-archive-path-guard.ts +++ b/src/plugins/evidence-archive-path-guard.ts @@ -28,7 +28,11 @@ export function evidenceArchivePathGuardPlugin(): ToolPlugin { middleware: (next) => async (call, signal) => { if (!PATH_TOOLS.has(call.name)) return next(call, signal); for (const [key, value] of Object.entries(call.arguments)) { - if (typeof value === "string" && looksLikePath(key) && isProtectedEvidenceLocation(value)) { + if ( + typeof value === "string" && + looksLikePath(key) && + isProtectedEvidenceLocation(value) + ) { return { callId: call.id, content: DENY_MESSAGE, isError: true }; } } diff --git a/src/plugins/evidence-archive-search-plugin.test.ts b/src/plugins/evidence-archive-search-plugin.test.ts index 3f2d3f560..b91fb6fb9 100644 --- a/src/plugins/evidence-archive-search-plugin.test.ts +++ b/src/plugins/evidence-archive-search-plugin.test.ts @@ -9,7 +9,10 @@ import { evidenceArchiveSearchPlugin, } from "./evidence-archive-search-plugin.js"; import { formatArchiveRef } from "../session/archive-uri.js"; -import { createCompactionArchive, type CompactionArchive } from "../session/compaction-archive.js"; +import { + createCompactionArchive, + type CompactionArchive, +} from "../session/compaction-archive.js"; import { CATALOG_TOOL_NAMES, CORE_TOOL_NAMES } from "../agent/tool-search.js"; function makeCall(name: string, args: Record): ToolCall { @@ -84,7 +87,9 @@ describe("evidenceArchiveSearchPlugin", () => { provenance: "primary-admission", }); const plugin = evidenceArchiveSearchPlugin(() => archive); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const hits = await handler( makeCall("search_files", { pattern: "*", path: "archive:///" }), @@ -95,10 +100,15 @@ describe("evidenceArchiveSearchPlugin", () => { expect(String(hits.content)).not.toContain(occ.blobKey); const grepHits = await handler( - makeCall("grep", { pattern: "unique-payload-alpha", path: "archive:///" }), + makeCall("grep", { + pattern: "unique-payload-alpha", + path: "archive:///", + }), new AbortController().signal, ); - expect(String(grepHits.content)).toContain(formatArchiveRef(occ.occurrenceId)); + expect(String(grepHits.content)).toContain( + formatArchiveRef(occ.occurrenceId), + ); expect(String(grepHits.content)).toContain("unique-payload-alpha"); expect(String(grepHits.content)).not.toContain(occ.blobKey); @@ -120,10 +130,15 @@ describe("evidenceArchiveSearchPlugin", () => { gap: true, }); const plugin = evidenceArchiveSearchPlugin(() => archive); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const payloadHits = await handler( - makeCall("grep", { pattern: "gap-payload-must-not-search", path: "archive:///" }), + makeCall("grep", { + pattern: "gap-payload-must-not-search", + path: "archive:///", + }), new AbortController().signal, ); expect(String(payloadHits.content)).toContain("no matches"); @@ -133,7 +148,9 @@ describe("evidenceArchiveSearchPlugin", () => { makeCall("grep", { pattern: "attachment-missing", path: "archive:///" }), new AbortController().signal, ); - expect(String(metaHits.content)).toContain(formatArchiveRef(gap.occurrenceId)); + expect(String(metaHits.content)).toContain( + formatArchiveRef(gap.occurrenceId), + ); expect(String(metaHits.content)).toContain("gap"); expect(reads).toEqual([]); @@ -154,7 +171,9 @@ describe("evidenceArchiveSearchPlugin", () => { payload: "other-session-only", }); const plugin = evidenceArchiveSearchPlugin(() => primary); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const forged = await handler( makeCall("read_file", { path: "archive:///occ-forged-not-in-index" }), @@ -179,13 +198,17 @@ describe("evidenceArchiveSearchPlugin", () => { test("read_file pages archive payloads with offset and limit", async () => { const archive = memoryArchive("sess-page"); - const lines = Array.from({ length: 8 }, (_, i) => `archive-line-${i}`).join("\n"); + const lines = Array.from({ length: 8 }, (_, i) => `archive-line-${i}`).join( + "\n", + ); const occ = await archive.recordAuthorizedPayload({ kind: "tool_result", payload: lines, }); const plugin = evidenceArchiveSearchPlugin(() => archive); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const body = await handler( makeCall("read_file", { path: formatArchiveRef(occ.occurrenceId), @@ -202,8 +225,12 @@ describe("evidenceArchiveSearchPlugin", () => { }); test("passes ordinary workspace paths through", async () => { - const plugin = evidenceArchiveSearchPlugin(() => memoryArchive("sess-pass")); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const plugin = evidenceArchiveSearchPlugin(() => + memoryArchive("sess-pass"), + ); + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const result = await handler( makeCall("grep", { pattern: "foo", path: "src" }), new AbortController().signal, @@ -218,21 +245,32 @@ describe("evidenceArchiveSearchPlugin", () => { payload: "plain-payload-without-scheme", }); const plugin = evidenceArchiveSearchPlugin(() => archive); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const uriHits = await handler( makeCall("grep", { pattern: "archive", path: "archive:///" }), new AbortController().signal, ); expect(String(uriHits.content)).toContain("no matches"); - expect(String(uriHits.content)).not.toContain(formatArchiveRef(occ.occurrenceId)); + expect(String(uriHits.content)).not.toContain( + formatArchiveRef(occ.occurrenceId), + ); const payloadHits = await handler( - makeCall("grep", { pattern: "plain-payload-without-scheme", path: "archive:///" }), + makeCall("grep", { + pattern: "plain-payload-without-scheme", + path: "archive:///", + }), new AbortController().signal, ); - expect(String(payloadHits.content)).toContain(formatArchiveRef(occ.occurrenceId)); - expect(String(payloadHits.content)).toContain("plain-payload-without-scheme"); + expect(String(payloadHits.content)).toContain( + formatArchiveRef(occ.occurrenceId), + ); + expect(String(payloadHits.content)).toContain( + "plain-payload-without-scheme", + ); }); test("archive grep and search_files honor abort before loading remaining payloads", async () => { @@ -246,7 +284,9 @@ describe("evidenceArchiveSearchPlugin", () => { payload: "abort-second", }); const plugin = evidenceArchiveSearchPlugin(() => archive); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const controller = new AbortController(); const reads: string[] = []; const orig = archive.readAuthorizedPayload.bind(archive); @@ -281,9 +321,15 @@ describe("evidenceArchiveSearchPlugin", () => { payload: "alpha\nbeta-hit\ngamma", }); const plugin = evidenceArchiveSearchPlugin(() => archive); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const hits = await handler( - makeCall("grep", { pattern: "beta-hit", path: "archive:///", context: 1 }), + makeCall("grep", { + pattern: "beta-hit", + path: "archive:///", + context: 1, + }), new AbortController().signal, ); const ref = formatArchiveRef(occ.occurrenceId); @@ -303,7 +349,9 @@ describe("evidenceArchiveSearchPlugin", () => { payload: "before-two\nneedle\nafter-two", }); const plugin = evidenceArchiveSearchPlugin(() => archive); - const handler = plugin.middleware ? plugin.middleware(nextHandler) : nextHandler; + const handler = plugin.middleware + ? plugin.middleware(nextHandler) + : nextHandler; const hits = await handler( makeCall("grep", { pattern: "needle", @@ -337,21 +385,29 @@ describe("createAgentToolset archive mount", () => { permissionGate, onOperatorGate: async () => ({ kind: "option", index: 0 }), }); - const workerNames = worker.dynamicRunner.currentDefinitions().map((d) => d.name); + const workerNames = worker.dynamicRunner + .currentDefinitions() + .map((d) => d.name); expect(workerNames).not.toContain("search_archive"); expect(workerNames).not.toContain("read_archive"); const workerRead = worker.dynamicRunner .currentDefinitions() .find((d) => d.name === "read_file"); expect(workerRead?.description ?? "").not.toContain("archive:///"); - const workerGrep = worker.dynamicRunner.currentDefinitions().find((d) => d.name === "grep"); + const workerGrep = worker.dynamicRunner + .currentDefinitions() + .find((d) => d.name === "grep"); expect(workerGrep?.description ?? "").not.toContain("archive:///"); - expect(JSON.stringify(workerGrep?.inputSchema ?? {})).not.toContain("archive:///"); + expect(JSON.stringify(workerGrep?.inputSchema ?? {})).not.toContain( + "archive:///", + ); const workerSearch = worker.dynamicRunner .currentDefinitions() .find((d) => d.name === "search_files"); expect(workerSearch?.description ?? "").not.toContain("archive:///"); - expect(JSON.stringify(workerSearch?.inputSchema ?? {})).not.toContain("archive:///"); + expect(JSON.stringify(workerSearch?.inputSchema ?? {})).not.toContain( + "archive:///", + ); await worker.dispose(); const primary = await createAgentToolset({ @@ -360,14 +416,18 @@ describe("createAgentToolset archive mount", () => { onOperatorGate: async () => ({ kind: "option", index: 0 }), getEvidenceArchive: () => undefined, }); - const primaryNames = primary.dynamicRunner.currentDefinitions().map((d) => d.name); + const primaryNames = primary.dynamicRunner + .currentDefinitions() + .map((d) => d.name); expect(primaryNames).not.toContain("search_archive"); expect(primaryNames).not.toContain("read_archive"); const primaryRead = primary.dynamicRunner .currentDefinitions() .find((d) => d.name === "read_file"); expect(primaryRead?.description).toContain("archive:///"); - const primaryGrep = primary.dynamicRunner.currentDefinitions().find((d) => d.name === "grep"); + const primaryGrep = primary.dynamicRunner + .currentDefinitions() + .find((d) => d.name === "grep"); expect(primaryGrep?.description).toContain("archive:///"); const primarySearch = primary.dynamicRunner .currentDefinitions() diff --git a/src/plugins/evidence-archive-search-plugin.ts b/src/plugins/evidence-archive-search-plugin.ts index 544d2443f..c3e1b182d 100644 --- a/src/plugins/evidence-archive-search-plugin.ts +++ b/src/plugins/evidence-archive-search-plugin.ts @@ -1,10 +1,17 @@ import type { ToolDefinition } from "@intx/types/runtime"; import type { ToolPlugin } from "@intx/tools-posix"; -import { READ_FILE_DEFAULT_MAX_LINES, readBytesBounded } from "./read-file-guard-plugin.js"; +import { + READ_FILE_DEFAULT_MAX_LINES, + readBytesBounded, +} from "./read-file-guard-plugin.js"; import type { CompactionArchive } from "../session/compaction-archive.js"; import type { ArchiveOccurrence } from "../session/compaction-archive-schema.js"; -import { formatArchiveRef, isArchiveLike, parseArchiveTarget } from "../session/archive-uri.js"; +import { + formatArchiveRef, + isArchiveLike, + parseArchiveTarget, +} from "../session/archive-uri.js"; const SEARCH_DEFAULT_MAX = 1000; const GREP_DEFAULT_MAX = 500; @@ -14,7 +21,9 @@ function str(value: unknown): string | undefined { } function num(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; } function globToRegExp(pattern: string): RegExp { @@ -82,12 +91,18 @@ function patchPathDescription( ): ToolDefinition { const schema = definition.inputSchema; const props = schema["properties"]; - if (props === undefined || typeof props !== "object" || props === null) return definition; + if (props === undefined || typeof props !== "object" || props === null) + return definition; const properties = props as Record; const pathSchema = properties.path; const nextPath = - pathSchema !== undefined && typeof pathSchema === "object" && pathSchema !== null - ? { ...(pathSchema as Record), description: pathDescription } + pathSchema !== undefined && + typeof pathSchema === "object" && + pathSchema !== null + ? { + ...(pathSchema as Record), + description: pathDescription, + } : { type: "string", description: pathDescription }; return { ...definition, @@ -102,7 +117,9 @@ function patchPathDescription( }; } -export function advertiseArchiveSurface(definition: ToolDefinition): ToolDefinition { +export function advertiseArchiveSurface( + definition: ToolDefinition, +): ToolDefinition { if (definition.name === "read_file") { return patchPathDescription( definition, @@ -132,7 +149,11 @@ export function evidenceArchiveSearchPlugin( ): ToolPlugin { return { middleware: (next) => async (call, signal) => { - if (call.name !== "read_file" && call.name !== "grep" && call.name !== "search_files") { + if ( + call.name !== "read_file" && + call.name !== "grep" && + call.name !== "search_files" + ) { return next(call, signal); } const path = str(call.arguments.path); @@ -171,7 +192,8 @@ export function evidenceArchiveSearchPlugin( isError: true, }; } - const maxResults = num(call.arguments.max_results) ?? SEARCH_DEFAULT_MAX; + const maxResults = + num(call.arguments.max_results) ?? SEARCH_DEFAULT_MAX; return { callId: call.id, content: await searchArchiveFiles( @@ -194,7 +216,10 @@ export function evidenceArchiveSearchPlugin( const maxResults = num(call.arguments.max_results) ?? GREP_DEFAULT_MAX; const glob = str(call.arguments.glob); const contextArg = num(call.arguments.context); - const context = contextArg !== undefined && contextArg > 0 ? Math.floor(contextArg) : 0; + const context = + contextArg !== undefined && contextArg > 0 + ? Math.floor(contextArg) + : 0; return { callId: call.id, content: await grepArchive( @@ -229,10 +254,13 @@ async function readArchiveOccurrence( } const text = await archive.readAuthorizedPayload(occurrenceId); const offsetArg = num(args.offset); - const offset = offsetArg !== undefined && offsetArg > 0 ? Math.floor(offsetArg) : 0; + const offset = + offsetArg !== undefined && offsetArg > 0 ? Math.floor(offsetArg) : 0; const limitArg = num(args.limit); const limit = - limitArg !== undefined && limitArg > 0 ? Math.floor(limitArg) : READ_FILE_DEFAULT_MAX_LINES; + limitArg !== undefined && limitArg > 0 + ? Math.floor(limitArg) + : READ_FILE_DEFAULT_MAX_LINES; const result = await readBytesBounded( new TextEncoder().encode(text), offset, @@ -324,7 +352,9 @@ async function grepArchive( try { regex = new RegExp(pattern); } catch (err) { - throw new Error(`invalid regex: ${err instanceof Error ? err.message : String(err)}`); + throw new Error( + `invalid regex: ${err instanceof Error ? err.message : String(err)}`, + ); } const occurrences = await selectOccurrences(archive, occurrenceId); const hits: string[] = []; diff --git a/src/plugins/result-truncation-plugin.test.ts b/src/plugins/result-truncation-plugin.test.ts index ac6c6072f..d4207d763 100644 --- a/src/plugins/result-truncation-plugin.test.ts +++ b/src/plugins/result-truncation-plugin.test.ts @@ -641,7 +641,9 @@ describe("archive then truncate", () => { }, } as unknown as CompactionArchive; const oversized = "x".repeat(MAX_RESULT_CHARS + 50); - const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive }); + const plugin = resultTruncationPlugin({ + getEvidenceArchive: () => archive, + }); if (plugin.middleware === undefined) throw new Error("expected middleware"); const middleware = plugin.middleware(async (call) => ({ callId: call.id, @@ -679,7 +681,9 @@ describe("archive then truncate", () => { }; }, } as unknown as CompactionArchive; - const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive }); + const plugin = resultTruncationPlugin({ + getEvidenceArchive: () => archive, + }); if (plugin.middleware === undefined) throw new Error("expected middleware"); const middleware = plugin.middleware(async (call) => ({ callId: call.id, diff --git a/src/plugins/result-truncation-plugin.ts b/src/plugins/result-truncation-plugin.ts index 815e2c93c..5bd79403d 100644 --- a/src/plugins/result-truncation-plugin.ts +++ b/src/plugins/result-truncation-plugin.ts @@ -8,7 +8,10 @@ import { type MaterializedToolResult, } from "./tool-result-materialize.js"; import { scrubSecretShapedContent } from "./tool-result-secret-scrub.js"; -import { hashAuthorizedBytes, type CompactionArchive } from "../session/compaction-archive.js"; +import { + hashAuthorizedBytes, + type CompactionArchive, +} from "../session/compaction-archive.js"; // Characters, not tokens — conversion ratio is roughly 4 chars/token. // Match the reactor's default size-cap (vendor/intx-inference assembly.ts) so @@ -232,7 +235,8 @@ async function archiveAuthorizedResult( kind: "tool_result", payload: content, callId, - provenance: isError === true ? "posix:error" : "posix:post-policy-pre-truncation", + provenance: + isError === true ? "posix:error" : "posix:post-policy-pre-truncation", }); } @@ -396,4 +400,4 @@ export function resultTruncationPlugin( return archiveThenTruncate(result, call.id, options); }, }; -} \ No newline at end of file +} diff --git a/src/plugins/tool-result-secret-scrub-plugin.ts b/src/plugins/tool-result-secret-scrub-plugin.ts index 648e7eb19..bf30cc755 100644 --- a/src/plugins/tool-result-secret-scrub-plugin.ts +++ b/src/plugins/tool-result-secret-scrub-plugin.ts @@ -1,6 +1,9 @@ import type { ToolPlugin } from "@intx/tools-posix"; import type { ToolResult } from "@intx/types/runtime"; -import { scrubSecretShapedContent, scrubSecretShapedValue } from "./tool-result-secret-scrub.js"; +import { + scrubSecretShapedContent, + scrubSecretShapedValue, +} from "./tool-result-secret-scrub.js"; // Posix-middleware scrub path only. search_agents is listed for future unified // scrubbing if it ever rides this middleware; live scrub for profile bodies is in diff --git a/src/plugins/tool-result-secret-scrub.test.ts b/src/plugins/tool-result-secret-scrub.test.ts index f664b331e..78184a696 100644 --- a/src/plugins/tool-result-secret-scrub.test.ts +++ b/src/plugins/tool-result-secret-scrub.test.ts @@ -27,11 +27,14 @@ MIIEpAIBAAKCAQEA7 }); test("is idempotent for redacted query parameters", () => { - const text = "GET https://provider.invalid/v1?api_key=sk-live-secret-value-here&model=test"; + const text = + "GET https://provider.invalid/v1?api_key=sk-live-secret-value-here&model=test"; const once = scrubSecretShapedContent(text); expect(scrubSecretShapedContent(once)).toBe(once); - expect(once).toBe(`GET https://provider.invalid/v1?api_key=${CREDENTIAL_REDACTION}&model=test`); + expect(once).toBe( + `GET https://provider.invalid/v1?api_key=${CREDENTIAL_REDACTION}&model=test`, + ); }); test("passes benign documentation mentioning API keys", () => { @@ -70,7 +73,9 @@ describe("toolResultSecretScrubPlugin", () => { test("scrubs grep tool results", async () => { const plugin = toolResultSecretScrubPlugin(); - const handler = plugin.middleware!(next("secrets/.env:1:TOKEN=supersecretvalue")); + const handler = plugin.middleware!( + next("secrets/.env:1:TOKEN=supersecretvalue"), + ); const result = await handler( { id: "c1", name: "grep", arguments: { pattern: "TOKEN" } }, new AbortController().signal, @@ -118,9 +123,14 @@ describe("toolResultSecretScrubPlugin", () => { new AbortController().signal, ); - if (typeof result.content !== "string") throw new Error("expected text tool result"); - expect(result.content).toContain(`api_key=${CREDENTIAL_REDACTION}&model=test`); - expect(result.content.match(/\[redacted: looks like a credential\]/g)).toHaveLength(1); + if (typeof result.content !== "string") + throw new Error("expected text tool result"); + expect(result.content).toContain( + `api_key=${CREDENTIAL_REDACTION}&model=test`, + ); + expect( + result.content.match(/\[redacted: looks like a credential\]/g), + ).toHaveLength(1); }); // search_agents is listed in SCRUBBABLE_TOOLS for future unified scrubbing, but diff --git a/src/plugins/tool-result-secret-scrub.ts b/src/plugins/tool-result-secret-scrub.ts index 272987cfc..0c28ec78c 100644 --- a/src/plugins/tool-result-secret-scrub.ts +++ b/src/plugins/tool-result-secret-scrub.ts @@ -78,7 +78,8 @@ export function scrubSecretShapedContent(text: string): string { */ export function scrubSecretShapedValue(value: unknown): unknown { if (typeof value === "string") return scrubSecretShapedContent(value); - if (Array.isArray(value)) return value.map((item) => scrubSecretShapedValue(item)); + if (Array.isArray(value)) + return value.map((item) => scrubSecretShapedValue(item)); if (value !== null && typeof value === "object") { const out: Record = {}; for (const [key, child] of Object.entries(value)) { diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 83a3a6060..83313fe15 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -437,9 +437,13 @@ test("worker prompt does not advertise archive:///; primary chat prompt does", ( expect(worker).not.toContain("archive:///"); const primary = buildChatSystemPrompt(); expect(primary).toContain("archive:///"); - expect(buildAvailableTools(["read_file", "grep", "search_files"])).not.toContain("archive:///"); expect( - buildAvailableTools(["read_file", "grep", "search_files"], { advertiseArchive: true }), + buildAvailableTools(["read_file", "grep", "search_files"]), + ).not.toContain("archive:///"); + expect( + buildAvailableTools(["read_file", "grep", "search_files"], { + advertiseArchive: true, + }), ).toContain("archive:///"); }); diff --git a/src/session/archive-uri.test.ts b/src/session/archive-uri.test.ts index 24ed6b849..e68e2e276 100644 --- a/src/session/archive-uri.test.ts +++ b/src/session/archive-uri.test.ts @@ -15,7 +15,9 @@ describe("archive URI", () => { expect(parseArchiveRef("archive:///occ-abc?x=1")).toBe("occ-abc"); expect(parseArchiveRef("occ-abc")).toBeUndefined(); expect(parseArchiveRef("archive:///")).toBeUndefined(); - expect(parseArchiveRef("/tmp/evidence-archive/index.jsonl")).toBeUndefined(); + expect( + parseArchiveRef("/tmp/evidence-archive/index.jsonl"), + ).toBeUndefined(); }); test("treats archive:/// as the virtual search root", () => { @@ -24,7 +26,9 @@ describe("archive URI", () => { expect(isArchiveLike("evidence-archive/index.jsonl")).toBe(false); expect(parseArchiveTarget("archive:///")).toEqual({}); expect(parseArchiveTarget("archive:/")).toEqual({}); - expect(parseArchiveTarget("archive:///occ-abc")).toEqual({ occurrenceId: "occ-abc" }); + expect(parseArchiveTarget("archive:///occ-abc")).toEqual({ + occurrenceId: "occ-abc", + }); expect(parseArchiveTarget("src/foo.ts")).toBeUndefined(); }); }); diff --git a/src/session/archive-uri.ts b/src/session/archive-uri.ts index 436c50135..11e9c1569 100644 --- a/src/session/archive-uri.ts +++ b/src/session/archive-uri.ts @@ -15,7 +15,9 @@ export function parseArchiveRef(value: string): string | undefined { } /** Root `archive:///` has no occurrenceId; a ref includes one. */ -export function parseArchiveTarget(value: string): { occurrenceId?: string } | undefined { +export function parseArchiveTarget( + value: string, +): { occurrenceId?: string } | undefined { if (!isArchiveLike(value)) return undefined; const rest = value.slice(ARCHIVE_URI_PREFIX.length).replace(/^\/+/, ""); const occurrenceId = rest.split(/[/?#]/)[0] ?? ""; diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 19e7d9810..630390c38 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -3,7 +3,11 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Agent } from "@intx/agent"; -import type { Compactor, ContextStore, ToolDefinition } from "@intx/types/runtime"; +import type { + Compactor, + ContextStore, + ToolDefinition, +} from "@intx/types/runtime"; import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; import { @@ -13,10 +17,16 @@ import { } from "./assemble-runtime.js"; function def(name: string): ToolDefinition { - return { name, description: `${name} tool`, inputSchema: { type: "object", properties: {} } }; + return { + name, + description: `${name} tool`, + inputSchema: { type: "object", properties: {} }, + }; } -function wiring(overrides: Partial[0]> = {}) { +function wiring( + overrides: Partial[0]> = {}, +) { return { sessionMode: "orchestrator" as const, toolAvailability: { languageServerAvailable: false }, @@ -28,7 +38,10 @@ function wiring(overrides: Partial[0] describe("createAdvertisedToolset", () => { test("drops names outside the built-in prefix", () => { const { computeAdvertised } = createAdvertisedToolset(wiring()); - const names = computeAdvertised([def("write_file"), def("mystery_tool")]).map((d) => d.name); + const names = computeAdvertised([ + def("write_file"), + def("mystery_tool"), + ]).map((d) => d.name); expect(names).toContain("write_file"); expect(names).not.toContain("mystery_tool"); }); @@ -36,16 +49,23 @@ describe("createAdvertisedToolset", () => { test("appends activated tools after the prefix, in activation order", () => { const { activated, computeAdvertised } = createAdvertisedToolset(wiring()); expect(activated.activate(["mystery_tool"])).toBe(true); - const names = computeAdvertised([def("read_file"), def("mystery_tool")]).map((d) => d.name); + const names = computeAdvertised([ + def("read_file"), + def("mystery_tool"), + ]).map((d) => d.name); expect(names[names.length - 1]).toBe("mystery_tool"); expect(names.slice(0, -1)).not.toContain("mystery_tool"); }); test("honors an explicit built-in prefix", () => { - const { computeAdvertised } = createAdvertisedToolset(wiring({ builtInPrefix: ["read_file"] })); - expect(computeAdvertised([def("read_file"), def("write_file")]).map((d) => d.name)).toEqual([ - "read_file", - ]); + const { computeAdvertised } = createAdvertisedToolset( + wiring({ builtInPrefix: ["read_file"] }), + ); + expect( + computeAdvertised([def("read_file"), def("write_file")]).map( + (d) => d.name, + ), + ).toEqual(["read_file"]); }); test("advertises nothing from an empty registry", () => { @@ -109,17 +129,25 @@ function stubInferenceDeps(): ChatAgentWiring["inferenceDeps"] { } function stubAuthorize(): ChatAgentWiring["authorize"] { - return async () => ({ effect: "allow", matchingGrants: [], resolvedBy: null }); + return async () => ({ + effect: "allow", + matchingGrants: [], + resolvedBy: null, + }); } -function stubChatAgentWiring(overrides: Partial = {}): ChatAgentWiring { +function stubChatAgentWiring( + overrides: Partial = {}, +): ChatAgentWiring { return { toolsId: "test/tools", agentId: "test/agent", systemPrompt: "prompt", authorize: stubAuthorize(), getDynamicRunner: () => { - throw new Error("getDynamicRunner should not run at assemble or mocked build"); + throw new Error( + "getDynamicRunner should not run at assemble or mocked build", + ); }, computeAdvertised: () => [], activateTools: () => false, @@ -172,7 +200,10 @@ describe("assembleChatAgent", () => { ...real, createAgentWithLiveToolDispatch: async ( _def: unknown, - env: { workdir: string; compactors: { "pruning-compactor": Compactor } }, + env: { + workdir: string; + compactors: { "pruning-compactor": Compactor }; + }, ) => { agentWorkdirs.push(env.workdir); agentCompactors.push(env.compactors["pruning-compactor"]); diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 3b9666a52..8ca57bd3e 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -545,7 +545,10 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { authorize: primaryArchive === undefined ? wiring.authorize - : wrapAuthorizeWithEvidenceArchive(wiring.authorize, () => primaryArchive), + : wrapAuthorizeWithEvidenceArchive( + wiring.authorize, + () => primaryArchive, + ), directors: createDirectorRegistry({ factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, @@ -554,11 +557,16 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { "pruning-compactor": primaryArchive === undefined ? wiring.getCompactor() - : wrapCompactorWithCompletenessGate(wiring.getCompactor(), primaryArchive), + : wrapCompactorWithCompletenessGate( + wiring.getCompactor(), + primaryArchive, + ), }, }); const admittedAgent = - primaryArchive === undefined ? agent : createPrimaryDeliveryAdmission(agent, primaryArchive); + primaryArchive === undefined + ? agent + : createPrimaryDeliveryAdmission(agent, primaryArchive); wiring.onBuilt(admittedAgent, storageForAgent); return admittedAgent; }; diff --git a/src/session/attachment-store.ts b/src/session/attachment-store.ts index f0dac601c..6860e938e 100644 --- a/src/session/attachment-store.ts +++ b/src/session/attachment-store.ts @@ -50,7 +50,8 @@ export async function ageImageBlocks( await options.archive.recordAuthorizedPayload({ kind: "attachment", payload: { - status: block.source.kind === "url" ? "unsupported-url" : "reference", + status: + block.source.kind === "url" ? "unsupported-url" : "reference", sourceKind: block.source.kind, }, provenance: "attachment-age:non-base64", diff --git a/src/session/compaction-archive-schema.ts b/src/session/compaction-archive-schema.ts index 7f4215489..26b9ce073 100644 --- a/src/session/compaction-archive-schema.ts +++ b/src/session/compaction-archive-schema.ts @@ -5,7 +5,9 @@ export const ArchiveKind = type( ); export type ArchiveKind = typeof ArchiveKind.infer; -export const ToolRecordingLifecycle = type("'requested' | 'suspended' | 'denied' | 'admitted'"); +export const ToolRecordingLifecycle = type( + "'requested' | 'suspended' | 'denied' | 'admitted'", +); export type ToolRecordingLifecycle = typeof ToolRecordingLifecycle.infer; export const ArchiveOccurrence = type({ diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index ee1147dcd..66a7fbd4f 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -23,7 +23,9 @@ function tempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "compaction-archive-")); } -function inbound(partial: Partial & { content?: string }): InboundMessage { +function inbound( + partial: Partial & { content?: string }, +): InboundMessage { return { ref: { uid: 1, mailbox: "INBOX" }, headers: { @@ -37,7 +39,9 @@ function inbound(partial: Partial & { content?: string }): Inbou flags: [], signatureStatus: "missing", content: partial.content ?? "", - ...(partial.attachments !== undefined ? { attachments: partial.attachments } : {}), + ...(partial.attachments !== undefined + ? { attachments: partial.attachments } + : {}), ...(partial.ref !== undefined ? { ref: partial.ref } : {}), }; } @@ -67,7 +71,8 @@ describe("recording policy", () => { test("authorized tool args representation does not mutate execution args", () => { const args = { - command: "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' https://x", + command: + "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' https://x", }; const recorded = authorizedToolArgsRepresentation(args); expect(recorded.command).toContain(CREDENTIAL_REDACTION); @@ -82,7 +87,9 @@ describe("primary message admission", () => { }); const admitted = admitPrimaryInboundMessage(message); expect(admitted.content).toContain(CREDENTIAL_REDACTION); - expect(admitted.content).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + expect(admitted.content).not.toContain( + "sk-abcdefghijklmnopqrstuvwxyz012345", + ); expect(message.content).toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); }); @@ -109,7 +116,9 @@ describe("primary message admission", () => { }); test("does not scrub binary attachment payloads", () => { - const bytes = new TextEncoder().encode("sk-abcdefghijklmnopqrstuvwxyz012345"); + const bytes = new TextEncoder().encode( + "sk-abcdefghijklmnopqrstuvwxyz012345", + ); const message = inbound({ content: "see image", attachments: [{ name: "a.png", contentType: "image/png", data: bytes }], @@ -135,8 +144,12 @@ describe("primary message admission", () => { }, }; const wrapped = createPrimaryDeliveryAdmission(agent); - wrapped.deliver(inbound({ content: "leak sk-abcdefghijklmnopqrstuvwxyz012345" })); - await wrapped.send(inbound({ content: "also sk-abcdefghijklmnopqrstuvwxyz012345" })); + wrapped.deliver( + inbound({ content: "leak sk-abcdefghijklmnopqrstuvwxyz012345" }), + ); + await wrapped.send( + inbound({ content: "also sk-abcdefghijklmnopqrstuvwxyz012345" }), + ); expect(delivered).toHaveLength(2); expect(delivered[0]!.content).toContain(CREDENTIAL_REDACTION); expect(delivered[1]!.content).toContain(CREDENTIAL_REDACTION); @@ -172,7 +185,9 @@ describe("primary message admission", () => { }, }; const wrapped = createPrimaryDeliveryAdmission(agent, archive); - wrapped.deliver(inbound({ content: "constraint sk-abcdefghijklmnopqrstuvwxyz012345" })); + wrapped.deliver( + inbound({ content: "constraint sk-abcdefghijklmnopqrstuvwxyz012345" }), + ); await archive.awaitPendingWrites(); expect(delivered).toHaveLength(1); const admitted = delivered[0]!.content!; @@ -181,10 +196,14 @@ describe("primary message admission", () => { const occurrences = await archive.listOccurrences(); expect(occurrences).toHaveLength(1); expect(occurrences[0]!.kind).toBe("user_message"); - const archived = await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId); + const archived = await archive.readAuthorizedPayload( + occurrences[0]!.occurrenceId, + ); const history = createInboundTurn(delivered[0]!); const historyText = history?.content.find((block) => block.type === "text"); - expect(historyText?.type === "text" ? historyText.text : undefined).toBe(archived); + expect(historyText?.type === "text" ? historyText.text : undefined).toBe( + archived, + ); expect(archived.startsWith("[From: user@local]\n\n")).toBe(true); expect(archived.endsWith(admitted)).toBe(true); }); @@ -210,7 +229,8 @@ describe("primary message admission", () => { /* unused */ }, async send(content: string | InboundMessage) { - if (typeof content !== "string") throw new Error("expected string send"); + if (typeof content !== "string") + throw new Error("expected string send"); sent.push(content); return { ok: true as const }; }, @@ -223,9 +243,9 @@ describe("primary message admission", () => { expect(sent[0]).not.toContain(secret); const occurrences = await archive.listOccurrences(); expect(occurrences).toHaveLength(1); - expect(await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId)).toBe( - `[From: user@local]\n\n${sent[0]!}`, - ); + expect( + await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId), + ).toBe(`[From: user@local]\n\n${sent[0]!}`); }); }); @@ -365,10 +385,14 @@ describe("compaction archive storage", () => { }); expect(denied.lifecycle).toBe("denied"); expect(denied.kind).toBe("tool_failure"); - const payload = JSON.parse(await archive.readAuthorizedPayload(denied.occurrenceId)); + const payload = JSON.parse( + await archive.readAuthorizedPayload(denied.occurrenceId), + ); expect(payload.lifecycle).toBe("denied"); expect(payload.arguments.token).toBe(CREDENTIAL_REDACTION); - expect(JSON.stringify(payload)).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); + expect(JSON.stringify(payload)).not.toContain( + "sk-abcdefghijklmnopqrstuvwxyz012345", + ); // Execution args object remains untouched for the runner. expect(forbiddenArgs.token).toBe("sk-abcdefghijklmnopqrstuvwxyz012345"); }); @@ -389,7 +413,11 @@ describe("compaction archive storage", () => { }, }); const args = { path: "README.md" }; - await archive.noteToolRequested({ id: "ask-1", name: "read_file", arguments: args }); + await archive.noteToolRequested({ + id: "ask-1", + name: "read_file", + arguments: args, + }); const suspended = await archive.finalizeToolRecording({ callId: "ask-1", name: "read_file", @@ -443,7 +471,10 @@ describe("compaction archive storage", () => { }); expect(missing.gap).toBe(true); - const cert = await archive.certifyRange([overflow.occurrenceId, missing.occurrenceId]); + const cert = await archive.certifyRange([ + overflow.occurrenceId, + missing.occurrenceId, + ]); expect(cert.status).toBe("incomplete"); expect(cert.unverifiedBlobIds).toContain(missing.occurrenceId); }); @@ -466,7 +497,12 @@ describe("compaction archive storage", () => { const result = await archive.importHistoricalEvidence([ { kind: "user_message", payload: "present fact", available: true }, - { kind: "tool_result", payload: "lost", available: false, callId: "old-1" }, + { + kind: "tool_result", + payload: "lost", + available: false, + callId: "old-1", + }, ]); expect(result.importedOccurrenceIds).toHaveLength(1); expect(result.gapOccurrenceIds).toHaveLength(1); @@ -512,7 +548,8 @@ describe("compaction archive storage", () => { const archive = createCompactionArchive({ sessionId: "sess-store-keys", contextDir: dir, - writeBlob: (key, bytes, contentType) => store.writeBlob(key, bytes, contentType), + writeBlob: (key, bytes, contentType) => + store.writeBlob(key, bytes, contentType), readBlob: (key) => store.readBlob(key), }); const occ = await archive.recordAuthorizedPayload({ @@ -521,7 +558,9 @@ describe("compaction archive storage", () => { }); expect(occ.blobKey.includes("/")).toBe(false); expect(occ.blobKey.includes("..")).toBe(false); - expect(await archive.readAuthorizedPayload(occ.occurrenceId)).toBe("hello-store"); + expect(await archive.readAuthorizedPayload(occ.occurrenceId)).toBe( + "hello-store", + ); }); test("rejects slash-containing blob keys so they cannot nest under tool-output", async () => { @@ -553,7 +592,9 @@ describe("compaction archive storage", () => { }); describe("wrapCompactorWithCompletenessGate", () => { - const ctx = { trigger: "test" } as unknown as import("@intx/types/runtime").StrategyContext; + const ctx = { + trigger: "test", + } as unknown as import("@intx/types/runtime").StrategyContext; function truncating(name: string): import("@intx/types/runtime").Compactor { return { @@ -600,7 +641,8 @@ describe("wrapCompactorWithCompletenessGate", () => { } test("incomplete archive returns identity history and drops stats blobs", async () => { - const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { wrapCompactorWithCompletenessGate } = + await import("./compaction-archive.js"); const { archive } = memoryArchive(); const inner = truncating("pruning-compactor"); const wrapped = wrapCompactorWithCompletenessGate(inner, archive); @@ -613,14 +655,23 @@ describe("wrapCompactorWithCompletenessGate", () => { { role: "assistant", content: [ - { type: "tool_call", id: "call-drop", name: "read_file", arguments: { path: "a.ts" } }, + { + type: "tool_call", + id: "call-drop", + name: "read_file", + arguments: { path: "a.ts" }, + }, ], timestamp: 2, }, { role: "user", content: [ - { type: "tool_result", callId: "call-drop", content: [{ type: "text", text: "ok" }] }, + { + type: "tool_result", + callId: "call-drop", + content: [{ type: "text", text: "ok" }], + }, ], timestamp: 3, }, @@ -633,7 +684,8 @@ describe("wrapCompactorWithCompletenessGate", () => { }); test("complete archive covering dropped callIds allows the rewrite", async () => { - const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { wrapCompactorWithCompletenessGate } = + await import("./compaction-archive.js"); const { archive } = memoryArchive(); await archive.recordAuthorizedPayload({ kind: "tool_args", @@ -661,14 +713,23 @@ describe("wrapCompactorWithCompletenessGate", () => { { role: "assistant", content: [ - { type: "tool_call", id: "call-drop", name: "read_file", arguments: { path: "a.ts" } }, + { + type: "tool_call", + id: "call-drop", + name: "read_file", + arguments: { path: "a.ts" }, + }, ], timestamp: 2, }, { role: "user", content: [ - { type: "tool_result", callId: "call-drop", content: [{ type: "text", text: "ok" }] }, + { + type: "tool_result", + callId: "call-drop", + content: [{ type: "text", text: "ok" }], + }, ], timestamp: 3, }, @@ -681,7 +742,8 @@ describe("wrapCompactorWithCompletenessGate", () => { }); test("explicit gap records are not required for completeness", async () => { - const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { wrapCompactorWithCompletenessGate } = + await import("./compaction-archive.js"); const { archive } = memoryArchive(); await archive.importHistoricalEvidence([ { kind: "user_message", available: false, callId: "historical-gap" }, @@ -696,7 +758,10 @@ describe("wrapCompactorWithCompletenessGate", () => { payload: "secret-fact", }); - const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive); + const wrapped = wrapCompactorWithCompletenessGate( + truncating("pruning-compactor"), + archive, + ); const turns: import("@intx/types/runtime").ConversationTurn[] = [ { role: "user", @@ -706,7 +771,11 @@ describe("wrapCompactorWithCompletenessGate", () => { { role: "user", content: [ - { type: "tool_result", callId: "call-drop", content: [{ type: "text", text: "ok" }] }, + { + type: "tool_result", + callId: "call-drop", + content: [{ type: "text", text: "ok" }], + }, ], timestamp: 2, }, @@ -717,9 +786,13 @@ describe("wrapCompactorWithCompletenessGate", () => { }); test("image-only dropped turns require covering attachment evidence", async () => { - const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { wrapCompactorWithCompletenessGate } = + await import("./compaction-archive.js"); const { archive } = memoryArchive(); - const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive); + const wrapped = wrapCompactorWithCompletenessGate( + truncating("pruning-compactor"), + archive, + ); const pngBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]); const png = base64Encode(pngBytes); const turns: import("@intx/types/runtime").ConversationTurn[] = [ @@ -755,7 +828,9 @@ describe("wrapCompactorWithCompletenessGate", () => { const admitted = createPrimaryDeliveryAdmission(agent, archive); await admitted.send( inbound({ - attachments: [{ name: "shot.png", contentType: "image/png", data: pngBytes }], + attachments: [ + { name: "shot.png", contentType: "image/png", data: pngBytes }, + ], }), ); const allowed = await wrapped.apply(turns, ctx); @@ -764,9 +839,13 @@ describe("wrapCompactorWithCompletenessGate", () => { }); test("dropped list_dir and write_file results fail-close until archived", async () => { - const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { wrapCompactorWithCompletenessGate } = + await import("./compaction-archive.js"); const { archive } = memoryArchive(); - const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive); + const wrapped = wrapCompactorWithCompletenessGate( + truncating("pruning-compactor"), + archive, + ); const turns: import("@intx/types/runtime").ConversationTurn[] = [ { role: "user", @@ -775,25 +854,47 @@ describe("wrapCompactorWithCompletenessGate", () => { }, { role: "assistant", - content: [{ type: "tool_call", id: "ld", name: "list_dir", arguments: { path: "." } }], + content: [ + { + type: "tool_call", + id: "ld", + name: "list_dir", + arguments: { path: "." }, + }, + ], timestamp: 2, }, { role: "user", content: [ - { type: "tool_result", callId: "ld", content: [{ type: "text", text: "src/\n" }] }, + { + type: "tool_result", + callId: "ld", + content: [{ type: "text", text: "src/\n" }], + }, ], timestamp: 3, }, { role: "assistant", - content: [{ type: "tool_call", id: "wf", name: "write_file", arguments: { path: "a.ts" } }], + content: [ + { + type: "tool_call", + id: "wf", + name: "write_file", + arguments: { path: "a.ts" }, + }, + ], timestamp: 4, }, { role: "user", content: [ - { type: "tool_result", callId: "wf", content: [{ type: "text", text: "wrote" }] }, + { + type: "tool_result", + callId: "wf", + content: [{ type: "text", text: "wrote" }], + }, ], timestamp: 5, }, @@ -839,7 +940,8 @@ describe("wrapCompactorWithCompletenessGate", () => { }); test("cloned keep-window turns are not treated as dropped", async () => { - const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js"); + const { wrapCompactorWithCompletenessGate } = + await import("./compaction-archive.js"); const { archive } = memoryArchive(); await archive.recordAuthorizedPayload({ kind: "user_message", @@ -849,7 +951,9 @@ describe("wrapCompactorWithCompletenessGate", () => { name: "pruning-compactor", version: "1", async apply(turns) { - const kept = turns.slice(-1).map((turn) => ({ ...turn, content: [...turn.content] })); + const kept = turns + .slice(-1) + .map((turn) => ({ ...turn, content: [...turn.content] })); return { output: kept, record: { diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index d1d6955f9..bad0a8c64 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -87,13 +87,21 @@ export interface HistoricalEvidenceItem { } export interface CompactionArchive { - recordAuthorizedPayload(input: RecordAuthorizedPayloadInput): Promise; - recordExistingBlobReference(input: RecordExistingBlobInput): Promise; + recordAuthorizedPayload( + input: RecordAuthorizedPayloadInput, + ): Promise; + recordExistingBlobReference( + input: RecordExistingBlobInput, + ): Promise; noteToolRequested(call: ToolCallRecordingInput): Promise; - finalizeToolRecording(input: FinalizeToolRecordingInput): Promise; + finalizeToolRecording( + input: FinalizeToolRecordingInput, + ): Promise; readAuthorizedPayload(occurrenceId: string): Promise; listOccurrences(): Promise; - certifyRange(expectedOccurrenceIds: readonly string[]): Promise; + certifyRange( + expectedOccurrenceIds: readonly string[], + ): Promise; importHistoricalEvidence( items: readonly HistoricalEvidenceItem[], ): Promise; @@ -115,7 +123,8 @@ export function applyRecordingPolicyToText(text: string): string { export function applyRecordingPolicyToValue(value: unknown): unknown { if (typeof value === "string") return applyRecordingPolicyToText(value); - if (Array.isArray(value)) return value.map((item) => applyRecordingPolicyToValue(item)); + if (Array.isArray(value)) + return value.map((item) => applyRecordingPolicyToValue(item)); if (value !== null && typeof value === "object") { const out: Record = {}; for (const [key, child] of Object.entries(value)) { @@ -153,7 +162,8 @@ export function isControlOrEmptyInbound(message: InboundMessage): boolean { const content = message.content ?? ""; const attachments = message.attachments ?? []; if (content.length === 0 && attachments.length === 0) return true; - if (message.ref.mailbox === "approval" || message.ref.mailbox === "system") return true; + if (message.ref.mailbox === "approval" || message.ref.mailbox === "system") + return true; if (message.headers.interchangeCorrelationId !== undefined) { // Correlated approval/control deliveries carry decision JSON; do not scrub. if (message.headers.interchangeType !== "conversation.message") return true; @@ -168,7 +178,9 @@ export function isControlOrEmptyInbound(message: InboundMessage): boolean { * `createInboundTurn`; the archive records that history text, not the * pre-envelope inbound string. Workers omit this hook. */ -export function admitPrimaryInboundMessage(message: InboundMessage): InboundMessage { +export function admitPrimaryInboundMessage( + message: InboundMessage, +): InboundMessage { if (isControlOrEmptyInbound(message)) return message; const content = message.content ?? ""; if (content.length === 0) return message; @@ -234,7 +246,12 @@ async function archiveAdmittedInbound( // so archive hashes equal createInboundTurn history text. function sendFromHeader(rest: readonly unknown[]): string { const opts = rest[0]; - if (opts !== undefined && typeof opts === "object" && opts !== null && "from" in opts) { + if ( + opts !== undefined && + typeof opts === "object" && + opts !== null && + "from" in opts + ) { const from = opts.from; if (typeof from === "string" && from.length > 0) return from; } @@ -259,7 +276,8 @@ export function createPrimaryDeliveryAdmission< }, send(content: string | InboundMessage, ...rest: never[]) { if (typeof content === "string") { - const admitted = content.length === 0 ? content : applyRecordingPolicyToText(content); + const admitted = + content.length === 0 ? content : applyRecordingPolicyToText(content); if (archive === undefined || admitted.length === 0) { return agent.send(admitted, ...rest); } @@ -307,7 +325,9 @@ function trackPendingWrite(archive: object, write: Promise): void { void tracked.finally(() => set.delete(tracked)); } -export async function awaitArchivePendingWrites(archive: object): Promise { +export async function awaitArchivePendingWrites( + archive: object, +): Promise { const set = pendingWrites.get(archive); if (set === undefined || set.size === 0) return; await Promise.all([...set]); @@ -315,9 +335,20 @@ export async function awaitArchivePendingWrites(archive: object): Promise export function wrapAuthorizeWithEvidenceArchive< TResult extends { effect: string | null }, - TAuthorize extends (resource: string, action: string, context: unknown) => Promise, ->(authorize: TAuthorize, getArchive: () => CompactionArchive | undefined): TAuthorize { - const wrapped = (async (resource: string, action: string, context: unknown) => { + TAuthorize extends ( + resource: string, + action: string, + context: unknown, + ) => Promise, +>( + authorize: TAuthorize, + getArchive: () => CompactionArchive | undefined, +): TAuthorize { + const wrapped = (async ( + resource: string, + action: string, + context: unknown, + ) => { const archive = getArchive(); const call = context !== null && @@ -325,7 +356,11 @@ export function wrapAuthorizeWithEvidenceArchive< "id" in context && "name" in context && "arguments" in context - ? (context as { id: string; name: string; arguments: Record }) + ? (context as { + id: string; + name: string; + arguments: Record; + }) : undefined; if (archive !== undefined && call !== undefined && action === "invoke") { @@ -342,10 +377,16 @@ export function wrapAuthorizeWithEvidenceArchive< archive !== undefined && call !== undefined && action === "invoke" && - (result.effect === "allow" || result.effect === "deny" || result.effect === "ask") + (result.effect === "allow" || + result.effect === "deny" || + result.effect === "ask") ) { const lifecycle = - result.effect === "allow" ? "admitted" : result.effect === "deny" ? "denied" : "suspended"; + result.effect === "allow" + ? "admitted" + : result.effect === "deny" + ? "denied" + : "suspended"; await archive.finalizeToolRecording({ callId: call.id, name: call.name, @@ -359,9 +400,15 @@ export function wrapAuthorizeWithEvidenceArchive< return wrapped; } -function encodePayload(payload: unknown): { bytes: Uint8Array; contentType: string } { +function encodePayload(payload: unknown): { + bytes: Uint8Array; + contentType: string; +} { if (typeof payload === "string") { - return { bytes: new TextEncoder().encode(payload), contentType: "text/plain" }; + return { + bytes: new TextEncoder().encode(payload), + contentType: "text/plain", + }; } return { bytes: new TextEncoder().encode(JSON.stringify(payload)), @@ -371,17 +418,24 @@ function encodePayload(payload: unknown): { bytes: Uint8Array; contentType: stri function occurrenceBlobKey(sessionId: string, occurrenceId: string): string { // ContextStore.writeBlob rejects slash-containing keys (sanitizeCallId). - return assertSafeBlobKey(`archive-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${occurrenceId}`); + return assertSafeBlobKey( + `archive-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${occurrenceId}`, + ); } function assertSafeBlobKey(key: string): string { if (key.includes("/") || key.includes("..")) { - throw new Error(`blob key contains unsafe characters: ${JSON.stringify(key)}`); + throw new Error( + `blob key contains unsafe characters: ${JSON.stringify(key)}`, + ); } return key; } -async function appendIndex(contextDir: string, occurrence: ArchiveOccurrence): Promise { +async function appendIndex( + contextDir: string, + occurrence: ArchiveOccurrence, +): Promise { const file = indexPath(contextDir); await fs.promises.mkdir(path.dirname(file), { recursive: true }); await fs.promises.appendFile(file, `${JSON.stringify(occurrence)}\n`, "utf8"); @@ -403,7 +457,8 @@ async function readIndex(contextDir: string): Promise { } return out; } catch (cause) { - if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return []; + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return []; throw cause; } } @@ -427,12 +482,17 @@ function mintOccurrenceId(parts: { return `occ-${createHash("sha256").update(material).digest("hex").slice(0, 24)}`; } -export function createCompactionArchive(opts: CreateCompactionArchiveOpts): CompactionArchive { +export function createCompactionArchive( + opts: CreateCompactionArchiveOpts, +): CompactionArchive { const { sessionId, contextDir, writeBlob, readBlob } = opts; const now = opts.now ?? (() => Date.now()); const requested = new Map(); async function persistOccurrence( - input: RecordAuthorizedPayloadInput & { blobKey?: string; skipWrite?: boolean }, + input: RecordAuthorizedPayloadInput & { + blobKey?: string; + skipWrite?: boolean; + }, ): Promise { const recordedAt = now(); const { bytes, contentType } = encodePayload(input.payload ?? ""); @@ -445,7 +505,9 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp ...(input.lifecycle !== undefined ? { lifecycle: input.lifecycle } : {}), recordedAt, }); - const blobKey = assertSafeBlobKey(input.blobKey ?? occurrenceBlobKey(sessionId, occurrenceId)); + const blobKey = assertSafeBlobKey( + input.blobKey ?? occurrenceBlobKey(sessionId, occurrenceId), + ); if (input.skipWrite !== true && input.gap !== true) { await writeBlob(blobKey, bytes, contentType); } @@ -458,7 +520,9 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp recordedAt, ...(input.callId !== undefined ? { callId: input.callId } : {}), ...(input.lifecycle !== undefined ? { lifecycle: input.lifecycle } : {}), - ...(input.provenance !== undefined ? { provenance: input.provenance } : {}), + ...(input.provenance !== undefined + ? { provenance: input.provenance } + : {}), ...(input.gap === true ? { gap: true } : {}), }); if (occurrence instanceof type.errors) { @@ -499,7 +563,9 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp blobKey, recordedAt, ...(input.callId !== undefined ? { callId: input.callId } : {}), - ...(input.provenance !== undefined ? { provenance: input.provenance } : {}), + ...(input.provenance !== undefined + ? { provenance: input.provenance } + : {}), ...(gap ? { gap: true } : {}), }); if (occurrence instanceof type.errors) { @@ -515,7 +581,9 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp }, async finalizeToolRecording(input) { - const recordedArgs = authorizedToolArgsRepresentation(input.executionArgs); + const recordedArgs = authorizedToolArgsRepresentation( + input.executionArgs, + ); if (input.lifecycle === "denied") { return persistOccurrence({ kind: "tool_failure", @@ -559,8 +627,10 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp async readAuthorizedPayload(occurrenceId) { const occurrences = await readIndex(contextDir); const hit = occurrences.find((o) => o.occurrenceId === occurrenceId); - if (hit === undefined) throw new Error(`unknown occurrence ${occurrenceId}`); - if (hit.gap === true) throw new Error(`occurrence ${occurrenceId} is an explicit gap`); + if (hit === undefined) + throw new Error(`unknown occurrence ${occurrenceId}`); + if (hit.gap === true) + throw new Error(`occurrence ${occurrenceId} is an explicit gap`); const bytes = await readBlob(hit.blobKey); return new TextDecoder().decode(bytes); }, @@ -616,7 +686,9 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp certifiedAt: now(), }); if (certificate instanceof type.errors) { - throw new Error(`invalid completeness certificate: ${certificate.summary}`); + throw new Error( + `invalid completeness certificate: ${certificate.summary}`, + ); } return certificate; }, @@ -645,7 +717,10 @@ export function createCompactionArchive(opts: CreateCompactionArchiveOpts): Comp gapOccurrenceIds.push(occ.occurrenceId); } } - const result = HistoricalImportResult({ importedOccurrenceIds, gapOccurrenceIds }); + const result = HistoricalImportResult({ + importedOccurrenceIds, + gapOccurrenceIds, + }); if (result instanceof type.errors) { throw new Error(`invalid historical import result: ${result.summary}`); } @@ -686,16 +761,21 @@ function coveringOccurrence( const liveHash = hashLiveImageData(unit.data); if (liveHash === undefined) return false; return ( - occ.contentHash === liveHash || attachmentByteHashes.get(occ.occurrenceId) === liveHash + occ.contentHash === liveHash || + attachmentByteHashes.get(occ.occurrenceId) === liveHash ); } if (unit.kind === "text") { if (unit.role === undefined || unit.text === undefined) return false; if (occ.kind !== textKindForRole(unit.role)) return false; - return occ.contentHash === hashAuthorizedBytes(new TextEncoder().encode(unit.text)); + return ( + occ.contentHash === + hashAuthorizedBytes(new TextEncoder().encode(unit.text)) + ); } if (unit.callId === undefined || occ.callId !== unit.callId) return false; - if (unit.kind === "tool_call") return occ.kind === "tool_args" || occ.kind === "tool_failure"; + if (unit.kind === "tool_call") + return occ.kind === "tool_args" || occ.kind === "tool_failure"; return occ.kind === "tool_result" || occ.kind === "overflow_blob"; }); if (match === undefined) return { ids, unmatched: true }; @@ -747,7 +827,8 @@ async function attachmentByteHashes( function sameContentUnit(a: ContentUnit, b: ContentUnit): boolean { if (a.kind !== b.kind) return false; if (a.kind === "image") { - if (a.blobKey !== undefined || b.blobKey !== undefined) return a.blobKey === b.blobKey; + if (a.blobKey !== undefined || b.blobKey !== undefined) + return a.blobKey === b.blobKey; return a.data === b.data; } if (a.kind === "text") return a.role === b.role && a.text === b.text; diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 4386f72ab..83bc6724b 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -797,7 +797,9 @@ function coalesceAdjacentTextTurns( return out; } -function separateAdjacentUserTurns(turns: ConversationTurn[]): ConversationTurn[] { +function separateAdjacentUserTurns( + turns: ConversationTurn[], +): ConversationTurn[] { const out: ConversationTurn[] = []; for (const turn of turns) { const prev = out[out.length - 1]; @@ -958,10 +960,7 @@ export function createPruningCompactor( const candidate = scoredOlder[i]; if (candidate === undefined) continue; const candidateTurn = olderTurns[candidate.index]; - if ( - candidateTurn !== undefined && - isFoldableHandoffTurn(candidateTurn) - ) + if (candidateTurn !== undefined && isFoldableHandoffTurn(candidateTurn)) continue; if ( candidate.score < ANCHOR_SCORE_THRESHOLD || @@ -989,7 +988,8 @@ export function createPruningCompactor( for (const idx of [...anchorIndices]) { const turn = olderTurns[idx]; - if (turn !== undefined && isFoldableHandoffTurn(turn)) anchorIndices.delete(idx); + if (turn !== undefined && isFoldableHandoffTurn(turn)) + anchorIndices.delete(idx); } // Ascending original order keeps the concatenated [anchors, recent] diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index cee954252..3b1bf8690 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -728,10 +728,13 @@ function turnTexts(turns: ConversationTurn[]): string[] { } async function gitLsTree(dir: string): Promise { - const proc = Bun.spawn(["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], { - stdout: "pipe", - stderr: "pipe", - }); + const proc = Bun.spawn( + ["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], + { + stdout: "pipe", + stderr: "pipe", + }, + ); const [exitCode, stdout, stderr] = await Promise.all([ proc.exited, new Response(proc.stdout).text(), @@ -760,7 +763,10 @@ describe("createOptimizedContextStore unpublished rewrite", () => { await store.commit({ message: "publish compact" }); const published = await store.load(); - expect(turnTexts(published.turns)).toEqual(["[Compacted prior context]", "keep-b"]); + expect(turnTexts(published.turns)).toEqual([ + "[Compacted prior context]", + "keep-b", + ]); }); test("omitting commit leaves a new store on the old generation", async () => { @@ -798,11 +804,17 @@ describe("createOptimizedContextStore unpublished rewrite", () => { fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n"); fs.chmodSync(hook, 0o755); - await expect(store.commit({ message: "publish compact" })).rejects.toThrow(); + await expect( + store.commit({ message: "publish compact" }), + ).rejects.toThrow(); const loaded = await store.load(); expect(turnTexts(loaded.turns)).toEqual(["keep-a", "keep-b", "drop-me"]); - expect(turnTexts(await store.readAt(published.hash))).toEqual(["keep-a", "keep-b", "drop-me"]); + expect(turnTexts(await store.readAt(published.hash))).toEqual([ + "keep-a", + "keep-b", + "drop-me", + ]); }); test("append writeTurns is still visible before commit", async () => { diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 5571198b5..9f6f661c0 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -490,12 +490,17 @@ describe("createSessionPruningCompactor", () => { content: [{ type: "text", text: `t${i}` }], timestamp: now, })); - const folded = await folding.apply(many as never, { state: {} as never, trigger: "test" }); + const folded = await folding.apply(many as never, { + state: {} as never, + trigger: "test", + }); expect(folds).toHaveLength(1); expect(folds[0]?.turnsBefore).toBe(8); expect( folded.output[0]?.content.some( - (block) => block.type === "text" && block.text.startsWith("[Compacted prior context]"), + (block) => + block.type === "text" && + block.text.startsWith("[Compacted prior context]"), ), ).toBe(true); diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index aa25535c8..4526f37ba 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -13,7 +13,10 @@ import { createDefaultDependencies } from "@intx/inference/providers"; import { getLogger } from "@intx/log"; import type { ConversationTurn, InferenceSource } from "@intx/types/runtime"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; -import { buildArchiveSummaryExcerpt, type SummaryExcerptArchive } from "./summary-excerpt.js"; +import { + buildArchiveSummaryExcerpt, + type SummaryExcerptArchive, +} from "./summary-excerpt.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]); @@ -144,7 +147,9 @@ export function buildSummaryPrompt( excerpt?: string, ): string { const body = - excerpt !== undefined && excerpt.length > 0 ? excerpt : condenseTurns(turns); + excerpt !== undefined && excerpt.length > 0 + ? excerpt + : condenseTurns(turns); return `${workflowPreamble(ctx)}Session excerpt:\n\n${body}`; } @@ -209,7 +214,10 @@ export function createModelSummarizer( return async (turns, ctx) => { try { const archive = options.getArchive?.(); - const excerpt = archive !== undefined ? await buildArchiveSummaryExcerpt(archive) : undefined; + const excerpt = + archive !== undefined + ? await buildArchiveSummaryExcerpt(archive) + : undefined; const promptTurns: ConversationTurn[] = [ { role: "system", @@ -218,7 +226,9 @@ export function createModelSummarizer( }, { role: "user", - content: [{ type: "text", text: buildSummaryPrompt(turns, ctx, excerpt) }], + content: [ + { type: "text", text: buildSummaryPrompt(turns, ctx, excerpt) }, + ], timestamp: 0, }, ]; diff --git a/src/session/summary-excerpt.test.ts b/src/session/summary-excerpt.test.ts index 85059fee7..08070e8be 100644 --- a/src/session/summary-excerpt.test.ts +++ b/src/session/summary-excerpt.test.ts @@ -32,10 +32,13 @@ test("prefers user messages over tool results and keeps the full payload", async occ({ occurrenceId: "occ-result", kind: "tool_result", callId: "c1" }), occ({ occurrenceId: "occ-user", kind: "user_message" }), ], - readAuthorizedPayload: async (id) => (id === "occ-user" ? userBody : "RESULT_BODY"), + readAuthorizedPayload: async (id) => + id === "occ-user" ? userBody : "RESULT_BODY", }); expect(excerpt.indexOf("USER_BODY")).toBeGreaterThanOrEqual(0); - expect(excerpt.indexOf("USER_BODY")).toBeLessThan(excerpt.indexOf("RESULT_BODY")); + expect(excerpt.indexOf("USER_BODY")).toBeLessThan( + excerpt.indexOf("RESULT_BODY"), + ); expect(excerpt).toContain(userBody); expect(excerpt).toContain("archive:///occ-user"); }); @@ -43,7 +46,12 @@ test("prefers user messages over tool results and keeps the full payload", async test("gap rows contribute metadata only", async () => { const excerpt = await buildArchiveSummaryExcerpt({ listOccurrences: async () => [ - occ({ occurrenceId: "occ-gap", kind: "tool_result", callId: "c9", gap: true }), + occ({ + occurrenceId: "occ-gap", + kind: "tool_result", + callId: "c9", + gap: true, + }), ], readAuthorizedPayload: async () => { throw new Error("gap rows must not load a payload"); @@ -79,7 +87,8 @@ test("marks over-budget occurrences as omitted instead of dropping them silently occ({ occurrenceId: "occ-user", kind: "user_message" }), occ({ occurrenceId: "occ-result", kind: "tool_result" }), ], - readAuthorizedPayload: async (id) => (id === "occ-user" ? "USER" : "RESULT"), + readAuthorizedPayload: async (id) => + id === "occ-user" ? "USER" : "RESULT", }, 80, ); @@ -95,7 +104,8 @@ test("omits an occurrence whose full payload cannot fit, without slicing it", as occ({ occurrenceId: "occ-big", kind: "user_message" }), occ({ occurrenceId: "occ-small", kind: "user_message" }), ], - readAuthorizedPayload: async (id) => (id === "occ-big" ? `BIG${"x".repeat(500)}` : "SMALL"), + readAuthorizedPayload: async (id) => + id === "occ-big" ? `BIG${"x".repeat(500)}` : "SMALL", }, 80, ); @@ -131,7 +141,8 @@ test("join separators are not charged against the first section", async () => { ], readAuthorizedPayload: async (id) => (id === "occ-a" ? "AAAA" : "BB"), }, - "### user_message archive:///occ-a\nAAAA\n\n### user_message archive:///occ-b\nBB".length, + "### user_message archive:///occ-a\nAAAA\n\n### user_message archive:///occ-b\nBB" + .length, ); expect(excerpt).toContain("AAAA"); expect(excerpt).toContain("BB"); diff --git a/src/session/summary-excerpt.ts b/src/session/summary-excerpt.ts index 046a97e15..fd2946ccc 100644 --- a/src/session/summary-excerpt.ts +++ b/src/session/summary-excerpt.ts @@ -6,7 +6,10 @@ // when earlier ones fill the window. Gap rows contribute metadata only. import type { CompactionArchive } from "./compaction-archive.js"; -import type { ArchiveKind, ArchiveOccurrence } from "./compaction-archive-schema.js"; +import type { + ArchiveKind, + ArchiveOccurrence, +} from "./compaction-archive-schema.js"; import { formatArchiveRef } from "./archive-uri.js"; export const SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS = 80_000; diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index eaff5cb46..d4ff6d6c6 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -245,8 +245,12 @@ describe("settings surface", () => { false, ); expect(shell.overlayItems.some((l) => l.includes("scope"))).toBe(false); - expect(shell.overlayItems.some((l) => l.includes("compaction"))).toBe(false); - expect(shell.overlayItems.some((l) => l.includes("summarize"))).toBe(false); + expect(shell.overlayItems.some((l) => l.includes("compaction"))).toBe( + false, + ); + expect(shell.overlayItems.some((l) => l.includes("summarize"))).toBe( + false, + ); }); }); diff --git a/tests/integration/compaction-atomicity.test.ts b/tests/integration/compaction-atomicity.test.ts index c9ff6f3f4..ee606ab53 100644 --- a/tests/integration/compaction-atomicity.test.ts +++ b/tests/integration/compaction-atomicity.test.ts @@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { Compactor, ConversationTurn, StrategyContext } from "@intx/types/runtime"; +import type { + Compactor, + ConversationTurn, + StrategyContext, +} from "@intx/types/runtime"; import { createOptimizedContextStore } from "../../src/session/optimized-context-store.js"; import { createCompactionArchive, @@ -36,7 +40,11 @@ function truncatingCompactor(): Compactor { return { output: [turn("[Compacted prior context]"), turn("kept-tail")], blobs: [ - { key: "stats", bytes: new TextEncoder().encode("stats"), contentType: "text/plain" }, + { + key: "stats", + bytes: new TextEncoder().encode("stats"), + contentType: "text/plain", + }, ], record: { strategy: "pruning-compactor", @@ -65,7 +73,10 @@ describe("compaction atomicity", () => { expect(texts((await interrupted.load()).turns)).toEqual(["w1", "w2", "w3"]); await store.commit({ message: "worker-new" }); - expect(texts((await store.load()).turns)).toEqual(["[Compacted prior context]", "w3"]); + expect(texts((await store.load()).turns)).toEqual([ + "[Compacted prior context]", + "w3", + ]); }); test("primary incomplete certifyRange refuses destructive compact", async () => { @@ -83,13 +94,21 @@ describe("compaction atomicity", () => { return hit; }, }); - const wrapped = wrapCompactorWithCompletenessGate(truncatingCompactor(), archive); + const wrapped = wrapCompactorWithCompletenessGate( + truncatingCompactor(), + archive, + ); const history = [ turn("fact-a"), { role: "assistant" as const, content: [ - { type: "tool_call" as const, id: "c1", name: "read_file", arguments: { path: "x" } }, + { + type: "tool_call" as const, + id: "c1", + name: "read_file", + arguments: { path: "x" }, + }, ], timestamp: 2, }, @@ -119,7 +138,12 @@ describe("compaction atomicity", () => { { role: "assistant" as const, content: [ - { type: "tool_call" as const, id: "c1", name: "read_file", arguments: { path: "x" } }, + { + type: "tool_call" as const, + id: "c1", + name: "read_file", + arguments: { path: "x" }, + }, ], timestamp: 2, }, @@ -142,7 +166,8 @@ describe("compaction atomicity", () => { const archive = createCompactionArchive({ sessionId: "primary", contextDir: dir, - writeBlob: (key, bytes, contentType) => store.writeBlob(key, bytes, contentType), + writeBlob: (key, bytes, contentType) => + store.writeBlob(key, bytes, contentType), readBlob: (key) => store.readBlob(key), }); await archive.recordAuthorizedPayload({ @@ -160,7 +185,10 @@ describe("compaction atomicity", () => { callId: "c1", }); - const wrapped = wrapCompactorWithCompletenessGate(truncatingCompactor(), archive); + const wrapped = wrapCompactorWithCompletenessGate( + truncatingCompactor(), + archive, + ); const result = await wrapped.apply(history, ctx); expect(result.output).not.toBe(history); expect(result.record.reason).toBe("compact"); @@ -178,13 +206,19 @@ describe("compaction atomicity", () => { await store.commit({ message: "primary-compact" }); const loaded = await store.load(); - expect(texts(loaded.turns)).toEqual(["[Compacted prior context]", "kept-tail"]); + expect(texts(loaded.turns)).toEqual([ + "[Compacted prior context]", + "kept-tail", + ]); expect(await store.readAt(oldCommit.hash)).toHaveLength(3); - const proc = Bun.spawn(["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], { - stdout: "pipe", - stderr: "pipe", - }); + const proc = Bun.spawn( + ["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], + { + stdout: "pipe", + stderr: "pipe", + }, + ); const stdout = await new Response(proc.stdout).text(); await proc.exited; expect(stdout).toContain("evidence-archive/index.jsonl"); diff --git a/tests/integration/compaction-baseline.test.ts b/tests/integration/compaction-baseline.test.ts index a1a3734ea..8c2dd75c8 100644 --- a/tests/integration/compaction-baseline.test.ts +++ b/tests/integration/compaction-baseline.test.ts @@ -31,18 +31,33 @@ import { type TurnResult, } from "./harness.js"; -const PersistedTurn = type({ role: "string", content: ContentBlock.array(), timestamp: "number" }); -const WireRequest = type({ messages: type({ role: "string", content: "unknown" }).array() }); -const usage = (input: number) => ({ input, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 }); +const PersistedTurn = type({ + role: "string", + content: ContentBlock.array(), + timestamp: "number", +}); +const WireRequest = type({ + messages: type({ role: "string", content: "unknown" }).array(), +}); +const usage = (input: number) => ({ + input, + output: 1, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, +}); function observedWork(events: TurnResult["events"]): Work[] { return events.flatMap((event) => { if (event.type !== "tool.start") return []; const call = event.data.call; const done = events.find( - (candidate) => candidate.type === "tool.done" && candidate.data.result.callId === call.id, + (candidate) => + candidate.type === "tool.done" && + candidate.data.result.callId === call.id, ); - if (done?.type !== "tool.done") throw new Error(`Missing tool result: ${call.id}`); + if (done?.type !== "tool.done") + throw new Error(`Missing tool result: ${call.id}`); const result = done.data.result; const failedShellExit = call.name === "run_shell" && @@ -52,7 +67,8 @@ function observedWork(events: TurnResult["events"]): Work[] { { name: call.name, argumentsKey: JSON.stringify(call.arguments), - outcome: result.isError === true || failedShellExit ? "failure" : "success", + outcome: + result.isError === true || failedShellExit ? "failure" : "success", purpose: call.id.startsWith("fold-") ? "verification" : "action", }, ]; @@ -88,7 +104,9 @@ async function withTimeout(promise: Promise): Promise { // Each response is selectable only if its exact evidence set is in the actual wire request. function queueEvidenceReply(session: IntegrationSession, callId: string) { for (let mask = 0; mask < 1 << REQUIRED_EVIDENCE.length; mask++) { - const facts = REQUIRED_EVIDENCE.filter((_, index) => (mask & (1 << index)) !== 0); + const facts = REQUIRED_EVIDENCE.filter( + (_, index) => (mask & (1 << index)) !== 0, + ); const text = evidenceText(facts) || "Missing all required evidence."; const stream = session.harness.scenario.createStream(); stream.enqueueAll(wire.completeResponse("anthropic", { text }), { @@ -103,7 +121,9 @@ function queueEvidenceReply(session: IntegrationSession, callId: string) { facts.every((fact) => recovered.some( (answer) => - answer.id === fact.id && answer.source === fact.source && answer.value === fact.value, + answer.id === fact.id && + answer.source === fact.source && + answer.value === fact.value, ), ) ); @@ -128,15 +148,22 @@ describe("integration — compaction mechanics baseline", () => { for (let attempt = 0; attempt < 2; attempt++) { session.harness.scenario.replyOnce("anthropic", { text: `Run diagnostic attempt ${attempt}.`, - toolCalls: [{ name: "run_shell", args: { command: "exit 7", timeout: 5000 } }], + toolCalls: [ + { name: "run_shell", args: { command: "exit 7", timeout: 5000 } }, + ], + }); + session.harness.scenario.replyOnce("anthropic", { + text: "Diagnostic finished.", }); - session.harness.scenario.replyOnce("anthropic", { text: "Diagnostic finished." }); const { events } = await withTimeout( runUntilDone(session, `Diagnose attempt ${attempt}.`), ); trace.push(...observedWork(events)); } - expect(trace.map((work) => work.outcome)).toEqual(["failure", "failure"]); + expect(trace.map((work) => work.outcome)).toEqual([ + "failure", + "failure", + ]); expect(repeatedWork(trace).repeatedFailedAttempts).toBe(1); } finally { await closeIntegrationSession(session); @@ -185,7 +212,9 @@ describe("integration — compaction mechanics baseline", () => { }); try { queueEvidenceReply(session, "absent-evidence"); - const { reply } = await withTimeout(runUntilDone(session, "absent-evidence")); + const { reply } = await withTimeout( + runUntilDone(session, "absent-evidence"), + ); expect(reply).toBe("Missing all required evidence."); expect(recoverEvidence(reply)).toEqual([]); } finally { @@ -208,7 +237,9 @@ describe("integration — compaction mechanics baseline", () => { compactionCompletion: async (turns) => { const context = turns .flatMap((turn) => - turn.content.flatMap((block) => (block.type === "text" ? [block.text] : [])), + turn.content.flatMap((block) => + block.type === "text" ? [block.text] : [], + ), ) .join("\n"); summaryInputs.push(context); @@ -234,9 +265,18 @@ describe("integration — compaction mechanics baseline", () => { await withTimeout(runUntilDone(session, message)); } for (const toolCall of [ - { name: "run_shell", args: { command: "bun diagnose.ts", timeout: 5000 } }, - { name: "read_file", args: { path: "diagnostic.log", offset: 0, limit: 4000 } }, - { name: "read_file", args: { path: "diagnostic.log", offset: 1500, limit: 1 } }, + { + name: "run_shell", + args: { command: "bun diagnose.ts", timeout: 5000 }, + }, + { + name: "read_file", + args: { path: "diagnostic.log", offset: 0, limit: 4000 }, + }, + { + name: "read_file", + args: { path: "diagnostic.log", offset: 1500, limit: 1 }, + }, ]) { session.harness.scenario.replyOnce("anthropic", { text: "Inspecting evidence.", @@ -252,7 +292,9 @@ describe("integration — compaction mechanics baseline", () => { ); trace.push(...observedWork(events)); } - const evidenceBefore = recoverEvidence(JSON.stringify((await snapshot(session)).turns)); + const evidenceBefore = recoverEvidence( + JSON.stringify((await snapshot(session)).turns), + ); expect(evidenceBefore).toEqual([...REQUIRED_EVIDENCE]); for (let fold = 0; fold < BASELINE.folds; fold++) { for (let growth = 0; growth < BASELINE.growthTurnsPerFold; growth++) { @@ -260,10 +302,13 @@ describe("integration — compaction mechanics baseline", () => { text: `Checked independent audit item ${fold}-${growth}.`, headUsage: usage(BASELINE.syntheticLowInput), }); - await withTimeout(runUntilDone(session, `Verify audit item ${fold}-${growth}.`)); + await withTimeout( + runUntilDone(session, `Verify audit item ${fold}-${growth}.`), + ); } const before = await snapshot(session); - const requestCount = session.harness.scenario.matchedRequests().length; + const requestCount = + session.harness.scenario.matchedRequests().length; session.harness.scenario.replyOnce("anthropic", { text: `Verify diagnostic row ${fold}.`, toolCalls: [ @@ -282,7 +327,9 @@ describe("integration — compaction mechanics baseline", () => { ); trace.push(...observedWork(events)); const after = await snapshot(session); - const inferenceCount = events.filter((event) => event.type === "inference.start").length; + const inferenceCount = events.filter( + (event) => event.type === "inference.start", + ).length; const observation: Fold = { requestedAtCall: requestCount + 1, beforeHash: before.hash, @@ -292,7 +339,9 @@ describe("integration — compaction mechanics baseline", () => { persisted: after.turns.filter((turn) => turn.content.some( - (block) => block.type === "text" && block.text.startsWith(COMPACTED_PREFIX), + (block) => + block.type === "text" && + block.text.startsWith(COMPACTED_PREFIX), ), ).length === 1, continuedAtCall: inferenceCount >= 2 ? requestCount + 2 : null, diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index cc9fdf070..01f86caf8 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -23,7 +23,11 @@ import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; import type { AuthzCallResult } from "@intx/inference"; import type { ReactorEmittedEvent } from "@intx/inference"; import { setupHarness, type Harness } from "@intx/inference-testing"; -import type { ContextTransform, ContextStore, InferenceSource } from "@intx/types/runtime"; +import type { + ContextTransform, + ContextStore, + InferenceSource, +} from "@intx/types/runtime"; import { type } from "arktype"; import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js"; @@ -42,7 +46,10 @@ import { type CompactionArchive, } from "../../src/session/compaction-archive.js"; import { assertReplySend } from "../../src/subagent/run.js"; -import { createModelSummarizer, type CompletionFn } from "../../src/session/summarizer.js"; +import { + createModelSummarizer, + type CompletionFn, +} from "../../src/session/summarizer.js"; import { buildCompactionContinuationMessage, createSessionPruningCompactor, @@ -87,8 +94,12 @@ export async function openIntegrationSession( const harness = setupHarness(); const cwd = mkdtempSync(join(tmpdir(), "corbits-integration-cwd-")); const workdir = join(cwd, ".agent-state", "integration-session"); - const evidenceArchiveHolder: { current: CompactionArchive | undefined } = { current: undefined }; - const storageHolder: { current: ContextStore | undefined } = { current: undefined }; + const evidenceArchiveHolder: { current: CompactionArchive | undefined } = { + current: undefined, + }; + const storageHolder: { current: ContextStore | undefined } = { + current: undefined, + }; const toolset = await createAgentToolset({ cwd, @@ -111,7 +122,10 @@ export async function openIntegrationSession( onTasksChange: () => undefined, inactivityTimeoutMs: 750_000, ...(opts.compactionCompletion !== undefined - ? { requestContinuation: () => agent.deliver(buildCompactionContinuationMessage()) } + ? { + requestContinuation: () => + agent.deliver(buildCompactionContinuationMessage()), + } : {}), }), }); @@ -149,7 +163,8 @@ export async function openIntegrationSession( const archive = createCompactionArchive({ sessionId: "integration-session", contextDir: workdir, - writeBlob: (key, bytes, contentType) => storage.writeBlob(key, bytes, contentType), + writeBlob: (key, bytes, contentType) => + storage.writeBlob(key, bytes, contentType), readBlob: (key) => storage.readBlob(key), }); primaryArchive = archive; From 95e57152ede565443bced567d6705466a5039ca9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 08:47:11 -0700 Subject: [PATCH 15/16] Satisfy oxfmt and unused-binding lint on compaction rebase --- src/mcp/plugin.ts | 6 +--- src/plugins/tool-result-secret-scrub.test.ts | 13 ++++++--- src/session/assemble-runtime.test.ts | 10 +++---- src/session/compaction-archive.test.ts | 30 ++++++++++++++------ 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts index d933d8a8b..1010260bc 100644 --- a/src/mcp/plugin.ts +++ b/src/mcp/plugin.ts @@ -116,11 +116,7 @@ export function mcpClientTools( ...(contextDir !== undefined ? { contextDir } : {}), } : undefined; - const content = await truncateToolResultContent( - flattened, - undefined, - spill, - ); + const content = await sanitizeMcpResultContent(flattened, spill); return { callId: call.id, content }; } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/plugins/tool-result-secret-scrub.test.ts b/src/plugins/tool-result-secret-scrub.test.ts index 78184a696..78bea1c70 100644 --- a/src/plugins/tool-result-secret-scrub.test.ts +++ b/src/plugins/tool-result-secret-scrub.test.ts @@ -73,7 +73,9 @@ describe("toolResultSecretScrubPlugin", () => { test("scrubs grep tool results", async () => { const plugin = toolResultSecretScrubPlugin(); - const handler = plugin.middleware!( + if (plugin.middleware === undefined) + throw new Error("expected middleware plugin"); + const handler = plugin.middleware( next("secrets/.env:1:TOKEN=supersecretvalue"), ); const result = await handler( @@ -83,10 +85,11 @@ describe("toolResultSecretScrubPlugin", () => { expect(result.content).toContain(CREDENTIAL_REDACTION); expect(result.content).not.toContain("supersecretvalue"); }); - test("scrubs error results without stringifying object content", async () => { const plugin = toolResultSecretScrubPlugin(); - const handler = plugin.middleware!( + if (plugin.middleware === undefined) + throw new Error("expected middleware plugin"); + const handler = plugin.middleware( next( { message: "failed with sk-abcdefghijklmnopqrstuvwxyz012345", @@ -142,7 +145,9 @@ describe("toolResultSecretScrubPlugin", () => { const body = "Matching agent profiles:\n\n### leaky\n\nSystem prompt / body:\n" + "Use token sk-abcdefghijklmnopqrstuvwxyz012345 when calling the provider."; - const handler = plugin.middleware!(next(body)); + if (plugin.middleware === undefined) + throw new Error("expected middleware plugin"); + const handler = plugin.middleware(next(body)); const result = await handler( { id: "c2", name: "search_agents", arguments: { query: "leaky" } }, new AbortController().signal, diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 630390c38..ca9e7797e 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -152,8 +152,8 @@ function stubChatAgentWiring( computeAdvertised: () => [], activateTools: () => false, inactivityTimeoutMs: 1_000, - onTasksChange: () => {}, - requestContinuation: () => {}, + onTasksChange: () => undefined, + requestContinuation: () => undefined, getProvider: () => ({ providerName: "test", model: "m" }), getWorkdir: () => "/build-dir", getSessionId: () => "test-session", @@ -169,7 +169,7 @@ function stubChatAgentWiring( ], getDefaultSource: () => "s", getCompactor: () => stubCompactor("build"), - onBuilt: () => {}, + onBuilt: () => undefined, ...overrides, }; } @@ -182,7 +182,7 @@ describe("assembleChatAgent", () => { const fakeStorage = { readBlob: async () => new Uint8Array(), } as unknown as ContextStore; - const fakeAgent = { close: async () => {} } as unknown as Agent; + const fakeAgent = { close: async () => undefined } as unknown as Agent; await withMockedModuleDuring( import.meta.resolve("./optimized-context-store.js"), @@ -256,7 +256,7 @@ describe("assembleChatAgent", () => { const fakeStorage = { readBlob: async () => new Uint8Array(), } as unknown as ContextStore; - const fakeAgent = { close: async () => {} } as unknown as Agent; + const fakeAgent = { close: async () => undefined } as unknown as Agent; const authorize = stubAuthorize(); let capturedStorage: ContextStore | undefined; let capturedAuthorize: unknown; diff --git a/src/session/compaction-archive.test.ts b/src/session/compaction-archive.test.ts index 66a7fbd4f..3d75c7a97 100644 --- a/src/session/compaction-archive.test.ts +++ b/src/session/compaction-archive.test.ts @@ -23,6 +23,11 @@ function tempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "compaction-archive-")); } +function required(value: T | undefined, label: string): T { + if (value === undefined) throw new Error(`expected ${label}`); + return value; +} + function inbound( partial: Partial & { content?: string }, ): InboundMessage { @@ -151,8 +156,12 @@ describe("primary message admission", () => { inbound({ content: "also sk-abcdefghijklmnopqrstuvwxyz012345" }), ); expect(delivered).toHaveLength(2); - expect(delivered[0]!.content).toContain(CREDENTIAL_REDACTION); - expect(delivered[1]!.content).toContain(CREDENTIAL_REDACTION); + expect(required(delivered[0], "first delivery").content).toContain( + CREDENTIAL_REDACTION, + ); + expect(required(delivered[1], "second delivery").content).toContain( + CREDENTIAL_REDACTION, + ); }); test("history and archive share the same admitted representation", async () => { @@ -190,16 +199,19 @@ describe("primary message admission", () => { ); await archive.awaitPendingWrites(); expect(delivered).toHaveLength(1); - const admitted = delivered[0]!.content!; + const admitted = required( + required(delivered[0], "first delivery").content, + "admitted content", + ); expect(admitted).toContain(CREDENTIAL_REDACTION); expect(admitted).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345"); const occurrences = await archive.listOccurrences(); expect(occurrences).toHaveLength(1); - expect(occurrences[0]!.kind).toBe("user_message"); + expect(required(occurrences[0], "occurrence").kind).toBe("user_message"); const archived = await archive.readAuthorizedPayload( - occurrences[0]!.occurrenceId, + required(occurrences[0], "occurrence").occurrenceId, ); - const history = createInboundTurn(delivered[0]!); + const history = createInboundTurn(required(delivered[0], "first delivery")); const historyText = history?.content.find((block) => block.type === "text"); expect(historyText?.type === "text" ? historyText.text : undefined).toBe( archived, @@ -244,8 +256,10 @@ describe("primary message admission", () => { const occurrences = await archive.listOccurrences(); expect(occurrences).toHaveLength(1); expect( - await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId), - ).toBe(`[From: user@local]\n\n${sent[0]!}`); + await archive.readAuthorizedPayload( + required(occurrences[0], "occurrence").occurrenceId, + ), + ).toBe(`[From: user@local]\n\n${required(sent[0], "sent")}`); }); }); From 793ee3f6d71516d9652ec2fcd5ca1f21801b6f7a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 08:48:03 -0700 Subject: [PATCH 16/16] Drop unused Settings type import from runner state --- src/tui/runner/state.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 0b0006501..59e7bb630 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -20,7 +20,6 @@ import { xaiProfileFromProviderName } from "../../config/xai-providers.js"; import type { MCPServerConfig, MCPServerSettingsEntry, - Settings, } from "../../config/settings.js"; import { globalSettingsPath,