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 556ff49b8..5da158a62 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -79,7 +79,10 @@ 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 stream-consumer.ts Async stream consumer with error handling hooks.ts Lifecycle hooks: discovery, turn collector, run summary @@ -114,6 +117,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/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..2ec9eeef8 --- /dev/null +++ b/evals/compaction/fixtures.ts @@ -0,0 +1,48 @@ +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..ab4ca8ff3 --- /dev/null +++ b/evals/compaction/metrics.test.ts @@ -0,0 +1,123 @@ +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..582a0227c --- /dev/null +++ b/evals/compaction/metrics.ts @@ -0,0 +1,136 @@ +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/src/agent/posix-tool-plugins.test.ts b/src/agent/posix-tool-plugins.test.ts index 950ba0e9c..2a6ae450f 100644 --- a/src/agent/posix-tool-plugins.test.ts +++ b/src/agent/posix-tool-plugins.test.ts @@ -378,6 +378,37 @@ 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 2d8d39cf8..ddb1dc64c 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -1,6 +1,8 @@ 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"; @@ -27,6 +29,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 +47,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 +86,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,16 +95,20 @@ 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 [ resultTruncationPlugin(truncationOptions), toolResultSecretScrubPlugin(), pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }), + evidenceArchivePathGuardPlugin(), deleteFilePlugin(cwd, { allowOutside }), toolOutputUriPlugin(), secretGuardPlugin(), @@ -110,6 +120,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 ab768c929..44ec90bad 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -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/agent/tools.ts b/src/agent/tools.ts index c819dabbf..f8fbb51c6 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"; @@ -26,6 +27,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 +201,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 +338,7 @@ export async function createAgentToolset( getBlobReader, getBlobWriter, getContextDir, + getEvidenceArchive, sessionMode = "orchestrator", shellEnv, toolAvailability = { languageServerAvailable: true }, @@ -420,6 +425,7 @@ export async function createAgentToolset( const truncationOptions = { ...(getBlobWriter !== undefined ? { getBlobWriter } : {}), ...(getContextDir !== undefined ? { getContextDir } : {}), + ...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}), }; const posixTools = createPosixTools({ cwd, @@ -435,6 +441,7 @@ export async function createAgentToolset( ? { readFileGuard: { blobReader: sessionBlobReader } } : {}), ...truncationOptions, + ...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}), ...(shellEnv !== undefined ? { shellEnv } : {}), getBackgroundShellRegistry: () => backgroundShells, }), @@ -551,12 +558,14 @@ 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(), }), @@ -977,6 +986,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/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..352befbc1 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,72 @@ 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" && + !firstText(t).startsWith(COMPACTED_PREFIX) && + 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 +895,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 +947,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,21 +970,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 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); + const result2 = await compactor.apply( + grow(result1.output, 16, "ok"), + mockStrategyCtx, + ); + expect(compactedTurns(result2.output)).toHaveLength(1); + expect(allText(result2.output)).toContain("UNIQUE_SUCCESS_SUMMARY"); + expect(hasConsecutiveSameRole(result2.output)).toBe(false); }); }); diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 71d14bb94..b064f7198 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) => { @@ -650,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({ @@ -698,7 +701,6 @@ export async function runExec(config: Config): Promise { liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id, getCompactor: () => createSessionPruningCompactor({ - compactionMode: liveCompactionMode, summarize: summarizeForCompaction, telemetry: liveTelemetry, }), @@ -706,6 +708,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..3269a2691 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,16 @@ 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 +564,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..1010260bc 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,12 @@ 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 +82,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 +116,27 @@ export function mcpClientTools( ...(contextDir !== undefined ? { contextDir } : {}), } : undefined; - return { - callId: call.id, - content: await sanitizeMcpResultContent(content, spill), - }; + const content = await sanitizeMcpResultContent(flattened, 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/evidence-archive-path-guard.test.ts b/src/plugins/evidence-archive-path-guard.test.ts new file mode 100644 index 000000000..1c3a25700 --- /dev/null +++ b/src/plugins/evidence-archive-path-guard.test.ts @@ -0,0 +1,95 @@ +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_files"); + 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..a5f57e446 --- /dev/null +++ b/src/plugins/evidence-archive-path-guard.ts @@ -0,0 +1,42 @@ +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-* dumps. Use search_files, grep, or read_file 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/plugins/evidence-archive-search-plugin.test.ts b/src/plugins/evidence-archive-search-plugin.test.ts new file mode 100644 index 000000000..b91fb6fb9 --- /dev/null +++ b/src/plugins/evidence-archive-search-plugin.test.ts @@ -0,0 +1,438 @@ +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"); + }); + + 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`); + }); + + 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", () => { + 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:///"); + 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({ + 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..c3e1b182d --- /dev/null +++ b/src/plugins/evidence-archive-search-plugin.ts @@ -0,0 +1,404 @@ +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, + signal, + ), + }; + } + 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); + 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, + context, + signal, + ), + }; + } 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, + 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)); + } + if (hits.length === 0) { + return `No evidence-archive occurrences matched "${pattern}".`; + } + return hits.join("\n"); +} + +function grepPayloadHits( + ref: string, + lines: string[], + regex: RegExp, + context: number, + remaining: number, +): { 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 { lines: [], matchCount: 0 }; + if (context <= 0) { + return { + lines: matchLines.map((i) => `${ref}:${i + 1}:${lines[i] ?? ""}`), + matchCount: matchLines.length, + }; + } + 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 { lines: out, matchCount: matchLines.length }; +} + +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); + } catch (err) { + throw new Error( + `invalid regex: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const occurrences = await selectOccurrences(archive, occurrenceId); + const hits: string[] = []; + let matchCount = 0; + for (const occ of occurrences) { + signal.throwIfAborted(); + 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; + let payload: string; + try { + payload = await archive.readAuthorizedPayload(occ.occurrenceId); + } catch (err) { + if (signal.aborted) throw err; + continue; + } + signal.throwIfAborted(); + 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"); +} + +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/plugins/result-truncation-plugin.test.ts b/src/plugins/result-truncation-plugin.test.ts index 3ee409406..d4207d763 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,101 @@ 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/plugins/result-truncation-plugin.ts b/src/plugins/result-truncation-plugin.ts index 6362647da..5bd79403d 100644 --- a/src/plugins/result-truncation-plugin.ts +++ b/src/plugins/result-truncation-plugin.ts @@ -8,6 +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"; // 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 +220,24 @@ 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 +293,66 @@ 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 { + 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(spilled)), + 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 +368,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 +376,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 +397,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); }, }; } diff --git a/src/plugins/tool-result-secret-scrub-plugin.ts b/src/plugins/tool-result-secret-scrub-plugin.ts index a45d6eddd..bf30cc755 100644 --- a/src/plugins/tool-result-secret-scrub-plugin.ts +++ b/src/plugins/tool-result-secret-scrub-plugin.ts @@ -1,5 +1,9 @@ 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 +21,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 +31,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..78bea1c70 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`); @@ -28,7 +28,7 @@ MIIEpAIBAAKCAQEA7 test("is idempotent for redacted query parameters", () => { const text = - "GET https://provider.invalid/v1?api_key=plain-value&model=test"; + "GET https://provider.invalid/v1?api_key=sk-live-secret-value-here&model=test"; const once = scrubSecretShapedContent(text); expect(scrubSecretShapedContent(once)).toBe(once); @@ -44,17 +44,38 @@ 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)( + if (plugin.middleware === undefined) + throw new Error("expected middleware plugin"); + const handler = plugin.middleware( next("secrets/.env:1:TOKEN=supersecretvalue"), ); const result = await handler( @@ -64,10 +85,34 @@ 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(); + if (plugin.middleware === undefined) + throw new Error("expected middleware plugin"); + 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(); @@ -99,14 +144,16 @@ 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."; + 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, ); 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..0c28ec78c 100644 --- a/src/plugins/tool-result-secret-scrub.ts +++ b/src/plugins/tool-result-secret-scrub.ts @@ -70,3 +70,22 @@ 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/prompts.test.ts b/src/prompts.test.ts index 5e2df2f6a..83313fe15 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -432,6 +432,21 @@ 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 diff --git a/src/session/archive-uri.test.ts b/src/session/archive-uri.test.ts new file mode 100644 index 000000000..e68e2e276 --- /dev/null +++ b/src/session/archive-uri.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatArchiveRef, + isArchiveLike, + parseArchiveRef, + parseArchiveTarget, +} 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(); + }); + + 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 new file mode 100644 index 000000000..11e9c1569 --- /dev/null +++ b/src/session/archive-uri.ts @@ -0,0 +1,26 @@ +export const ARCHIVE_URI_PREFIX = "archive:"; +const ARCHIVE_URI_CANONICAL = "archive:///"; + +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 { + 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 {}; + return { occurrenceId }; +} diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index d793b8477..ca9e7797e 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Agent } from "@intx/agent"; import type { - AuditStore, Compactor, ContextStore, ToolDefinition, @@ -129,26 +128,69 @@ 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: () => undefined, + requestContinuation: () => undefined, + getProvider: () => ({ providerName: "test", model: "m" }), + getWorkdir: () => "/build-dir", + getSessionId: () => "test-session", + inferenceDeps: stubInferenceDeps(), + getSources: () => [ + { + id: "s", + provider: "test", + baseURL: "http://localhost", + apiKey: "k", + model: "m", + }, + ], + getDefaultSource: () => "s", + getCompactor: () => stubCompactor("build"), + onBuilt: () => undefined, + ...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; + } as unknown as ContextStore; const fakeAgent = { close: async () => undefined } 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 () => { @@ -160,17 +202,10 @@ describe("assembleChatAgent", () => { _def: unknown, env: { workdir: string; - sessionId?: string; - storage: ContextStore; - audit: AuditStore; 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 +213,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; }, - ], - getDefaultSource: () => "s", - getCompactor: () => { - compactorCalls.push(liveCompactor.name); - return liveCompactor; - }, - onBuilt: () => undefined, - }); + getCompactor: () => { + compactorCalls.push(liveCompactor.name); + return liveCompactor; + }, + }), + ); 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 () => undefined } 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 1f0fea4b0..8ca57bd3e 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -54,6 +54,16 @@ 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, + hashAuthorizedBytes, + wrapAuthorizeWithEvidenceArchive, + wrapCompactorWithCompletenessGate, + type CompactionArchive, +} from "./compaction-archive.js"; +import path from "node:path"; import { loadProjectTrust, isPluginTrusted, @@ -388,6 +398,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 +471,60 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { const buildAgent = async (): Promise => { const workdir = wiring.getWorkdir(); const { storage, audit } = await createSessionStores(workdir); + // 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 storageForAgent: ContextStore = + primaryArchive === undefined + ? 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; + 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, + storage: storageForAgent, workdir, // contextTransforms ride deps: the published @intx/agent forwards deps // into reactor assembly verbatim, and the vendored assembly picks the @@ -467,24 +532,43 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { deps: { ...wiring.inferenceDeps, contextTransforms: [ - createAttachmentRehydrateTransform((key) => storage.readBlob(key)), + createAttachmentRehydrateTransform((key) => + storageForAgent.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: + primaryArchive === undefined + ? wiring.authorize + : wrapAuthorizeWithEvidenceArchive( + wiring.authorize, + () => primaryArchive, + ), directors: createDirectorRegistry({ factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, }), compactors: { - "pruning-compactor": wiring.getCompactor(), + "pruning-compactor": + primaryArchive === undefined + ? wiring.getCompactor() + : wrapCompactorWithCompletenessGate( + wiring.getCompactor(), + primaryArchive, + ), }, }); - wiring.onBuilt(agent, storage); - return agent; + const admittedAgent = + primaryArchive === undefined + ? agent + : createPrimaryDeliveryAdmission(agent, primaryArchive); + wiring.onBuilt(admittedAgent, storageForAgent); + return admittedAgent; }; return { directorHolder, buildAgent }; 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/attachment-store.ts b/src/session/attachment-store.ts index 679a695ef..6860e938e 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 { 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,18 @@ 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 +72,8 @@ export async function ageImageBlocks( bytes, contentType: block.source.mimeType, }); + // 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-schema.ts b/src/session/compaction-archive-schema.ts new file mode 100644 index 000000000..26b9ce073 --- /dev/null +++ b/src/session/compaction-archive-schema.ts @@ -0,0 +1,41 @@ +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..3d75c7a97 --- /dev/null +++ b/src/session/compaction-archive.test.ts @@ -0,0 +1,1001 @@ +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 { base64Encode } from "@intx/types"; +import { createInboundTurn } from "@intx/inference"; +import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js"; +import { + admitPrimaryInboundMessage, + applyRecordingPolicyToText, + applyRecordingPolicyToValue, + authorizedToolArgsRepresentation, + createCompactionArchive, + createPrimaryDeliveryAdmission, + 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-")); +} + +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 { + 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(content: string | InboundMessage) { + if (typeof content === "string") { + delivered.push(inbound({ content })); + return { ok: true as const }; + } + delivered.push(content); + 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(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 () => { + 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(content: string | InboundMessage) { + if (typeof content === "string") { + delivered.push(inbound({ content })); + return { ok: true as const }; + } + delivered.push(content); + 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 = 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(required(occurrences[0], "occurrence").kind).toBe("user_message"); + const archived = await archive.readAuthorizedPayload( + required(occurrences[0], "occurrence").occurrenceId, + ); + 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, + ); + 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 () => { + 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( + required(occurrences[0], "occurrence").occurrenceId, + ), + ).toBe(`[From: user@local]\n\n${required(sent[0], "sent")}`); + }); +}); + +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"); + }); + + 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", + ); + }); + + 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", () => { + 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"); + }); + + 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 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", + 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"); + + 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: "user_message", + payload: "do work", + }); + await archive.recordAuthorizedPayload({ + 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 new file mode 100644 index 000000000..bad0a8c64 --- /dev/null +++ b/src/session/compaction-archive.ts @@ -0,0 +1,927 @@ +// 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 { 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, + CompletenessCertificate, + HistoricalImportResult, + 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"; + +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). 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; + const content = message.content ?? ""; + if (content.length === 0) return message; + const admittedContent = applyRecordingPolicyToText(content); + if (admittedContent === content) return message; + 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; + for (const historyText of historyUserTexts(admitted)) { + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: historyText, + 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", + }); + } +} + +// 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; + send: (content: string | 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(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 from = sendFromHeader(rest as unknown[]); + const run = async () => { + await archiveAdmittedInbound( + archive, + createInboundMessage({ + from, + to: "agent@local", + content: admitted, + interchangeType: "conversation.message", + }), + ); + return agent.send(admitted, ...rest); + }; + return run(); + } + const admitted = admitPrimaryInboundMessage(content); + 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 { + // ContextStore.writeBlob rejects slash-containing keys (sanitizeCallId). + 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 { + 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 = assertSafeBlobKey( + 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) { + const blobKey = assertSafeBlobKey(input.blobKey); + let gap = false; + try { + const bytes = await readBlob(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, + 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; +} + +function textKindForRole(role: ConversationTurn["role"]): ArchiveKind { + if (role === "assistant") return "assistant_text"; + 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 ContentUnit[], + occurrences: readonly ArchiveOccurrence[], + attachmentByteHashes: ReadonlyMap, +): { 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 === "image") { + if (occ.kind !== "attachment") return false; + if (unit.blobKey !== undefined) return occ.blobKey === unit.blobKey; + if (unit.data === undefined) return false; + 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; + 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 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); + 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" }); + } + } + } + } + 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 units = uncoveredContentUnits(turns, proposed.output); + if (units.length === 0) return proposed; + const occurrences = await archive.listOccurrences(); + const covering = coveringOccurrence( + units, + occurrences, + await attachmentByteHashes(archive, 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; + }, + }; +} diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 0b4baa155..83bc6724b 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,20 @@ 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 +859,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 +879,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 +959,9 @@ 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 +980,18 @@ 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/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index 435818ba4..3b1bf8690 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -722,3 +722,145 @@ 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("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); + 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..377bb78a0 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"]); @@ -437,8 +438,35 @@ 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; + + 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,10 @@ export async function createSessionStores( }, async commit(options, signal) { return withResolvedDirLock(dir, async () => { + const stagedRewrite = unpublishedRewrite; + if (stagedRewrite !== null) { + await writeSegmented(writeTurnsSegmented, stagedRewrite); + } const toAdd: string[] = []; const toRemove: string[] = []; @@ -596,6 +661,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), @@ -616,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/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 47c319009..9f6f661c0 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,22 @@ 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..4526f37ba 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -4,15 +4,19 @@ // 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 +58,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 +144,13 @@ 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 +194,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 +212,12 @@ 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 +226,24 @@ 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..08070e8be --- /dev/null +++ b/src/session/summary-excerpt.test.ts @@ -0,0 +1,150 @@ +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("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 () => [ + 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"); + expect(excerpt).toMatch(/1 occurrence omitted/); +}); + +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"); + 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 new file mode 100644 index 000000000..fd2946ccc --- /dev/null +++ b/src/session/summary-excerpt.ts @@ -0,0 +1,100 @@ +// 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", + "attachment", + "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; + 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) { + omitted++; + continue; + } + + let body: string | undefined; + if (occ.gap === true) { + body = "(payload not stored)"; + } else { + try { + body = await archive.readAuthorizedPayload(occ.occurrenceId); + } catch { + omitted++; + continue; + } + } + + const section = `${heading(occ)}\n${body}`; + const separator = sections.length > 0 ? 2 : 0; + if (section.length + separator > remaining) { + omitted++; + continue; + } + sections.push(section); + used += section.length + separator; + } + } + + 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/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..d4ff6d6c6 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,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, + ); }); }); @@ -266,8 +265,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/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/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 8f8bb898c..74f16d64f 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", @@ -499,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(); @@ -557,7 +561,6 @@ export async function assembleTUISession( : state.liveSource.id, getCompactor: () => createSessionPruningCompactor({ - compactionMode: state.liveCompactionMode, summarize: compactionSummarize, summaryContext, telemetry: liveTelemetry, @@ -568,6 +571,7 @@ export async function assembleTUISession( state.currentAgent = agent; state.currentStorage = storage; }, + evidenceArchiveHolder, }); const sessionCost = createSessionCostAccumulator({ 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..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, @@ -234,9 +233,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 +369,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/session-chrome.ts b/src/tui/session-chrome.ts index 985fb0d90..e23ff383d 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,36 @@ 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", + 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 +155,7 @@ export function resolveTurnLabel( } if (!occupied) return undefined; void isStalled; + void activityStateForTool(input.currentToolName); return liveActivityWord(input.nowMs ?? 0); } 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-atomicity.test.ts b/tests/integration/compaction-atomicity.test.ts new file mode 100644 index 000000000..ee606ab53 --- /dev/null +++ b/tests/integration/compaction-atomicity.test.ts @@ -0,0 +1,226 @@ +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/tests/integration/compaction-baseline.test.ts b/tests/integration/compaction-baseline.test.ts new file mode 100644 index 000000000..8c2dd75c8 --- /dev/null +++ b/tests/integration/compaction-baseline.test.ts @@ -0,0 +1,384 @@ +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 — 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 === 1, + continuedAtCall: inferenceCount >= 2 ? requestCount + 2 : null, + }; + folds.push(observation); + expect(qualifyingFold(observation)).toBe(true); + expect(summaryInputs.length).toBe(fold + 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`, + ); + } + 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..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, 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,7 +36,24 @@ 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, + wrapCompactorWithCompletenessGate, + 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 { + buildCompactionContinuationMessage, + createSessionPruningCompactor, +} from "../../src/session/runtime-assembly.js"; export const INTEGRATION_SOURCE: InferenceSource = { id: "anthropic:claude-integration", @@ -52,6 +73,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, @@ -71,11 +94,24 @@ 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({ @@ -85,6 +121,12 @@ export async function openIntegrationSession( createChatDirector(agentCtx.systemPrompt, [...agentCtx.toolDefinitions], { onTasksChange: () => undefined, inactivityTimeoutMs: 750_000, + ...(opts.compactionCompletion !== undefined + ? { + requestContinuation: () => + agent.deliver(buildCompactionContinuationMessage()), + } + : {}), }), }); @@ -111,11 +153,59 @@ 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, @@ -124,15 +214,34 @@ export async function openIntegrationSession( : {}), }, audit: noopAuditStore(), - ...(opts.authorize !== undefined - ? { authorize: opts.authorize } - : { authorize: permissiveAuthorize() }), + authorize, directors: createDirectorRegistry({ factories: [chatDirectorDef.factory], defaultId: `${ID_PREFIX}/chat`, }), + ...(opts.compactionCompletion !== undefined && primaryArchive !== undefined + ? { + compactors: { + "pruning-compactor": wrapCompactorWithCompletenessGate( + createSessionPruningCompactor({ + summarize: createModelSummarizer({ + getSource: () => INTEGRATION_SOURCE, + deps: harness.deps, + complete: opts.compactionCompletion, + getArchive: () => evidenceArchiveHolder.current, + }), + }), + primaryArchive, + ), + }, + } + : {}), 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); }); diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 447733607..087156c13 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -308,6 +308,22 @@ 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. 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 +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 +384,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..b8a56a0b4 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,18 @@ function truncatingCompactor(name: string): Compactor { }; } -function makeRecordingContextStore(): { +function makeRecordingContextStore(opts?: { + failCommit?: boolean; + failCommitRemaining?: { n: number }; + 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 +5295,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 +5311,13 @@ function makeRecordingContextStore(): { /* noop */ }, async commit(options) { + 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], @@ -5339,6 +5357,7 @@ function makeRecordingContextStore(): { manifests.push([...records]); }, async writeTurns(turns) { + writeTurnsCalls.push([...turns]); lastWrittenTurns = [...turns]; }, async writeMetadata(m) { @@ -5358,6 +5377,7 @@ function makeRecordingContextStore(): { manifests, metadata, blobs, + writeTurnsCalls, get lastWrittenTurns() { return lastWrittenTurns; }, @@ -5621,6 +5641,102 @@ 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("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 5570cfd15..b9cd1e332 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,12 +1087,20 @@ 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( `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; }