Skip to content

Commit 60a0501

Browse files
Merge pull request #842 from corbitsdev/cl-7521-preserve-compaction-evidence-with-fresh-working-context-and
Preserve evidence across primary agent compaction
2 parents 7267001 + 793ee3f commit 60a0501

64 files changed

Lines changed: 6002 additions & 511 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,8 @@ tool call
364364
**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.
365365

366366
- **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.
367-
- **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.
367+
- **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.
368+
- **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`.
368369
- **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched).
369370
- **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.
370371
- **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.

docs/IMPLEMENTATION.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ src/
7979
index.ts Session lifecycle
8080
state.ts RunState JSON save/load
8181
compactor.ts Context compactor
82-
summarizer.ts Model-backed structured compaction summary (+ deterministic fallback)
82+
summarizer.ts Model-backed structured compaction summary (fails closed)
83+
summary-excerpt.ts Token-budgeted archive excerpt for the summary call
84+
compaction-archive.ts Primary-only authorized evidence archive (post-policy capture)
85+
compaction-archive-schema.ts Archive occurrence / completeness certificate schemas
8386
run-sink.ts Run-level event sink
8487
stream-consumer.ts Async stream consumer with error handling
8588
hooks.ts Lifecycle hooks: discovery, turn collector, run summary
@@ -114,6 +117,8 @@ src/
114117
data-only-agent.ts Markdown-only agent plugins (agents/*.md)
115118
loader.ts Plugin discovery + loadPluginEntry
116119
path-escape-plugin.ts Path sandboxing (first)
120+
evidence-archive-search-plugin.ts archive:/// search/read via posix tools
121+
evidence-archive-path-guard.ts Block dump-path reads of the archive sidecar
117122
tool-output-uri-plugin.ts Normalize read_file tool-output URIs
118123
secret-guard-plugin.ts Hard-deny path-keyed secret files
119124
authz-plugin.ts Catastrophic command blocking (thin wrapper)

evals/compaction/README.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Primary compaction mechanics baseline
2+
3+
This is the frozen, offline component baseline for the primary compaction
4+
replacement. It is **not** a complete TUI/exec, permission-isolation, attachment
5+
security, or live model-quality evaluation. The bounded integration-harness
6+
scope has Greybeard approval; production `src/` remains unchanged.
7+
8+
## Reproduce
9+
10+
```bash
11+
bun test ./evals/compaction/metrics.test.ts ./tests/integration/compaction-baseline.test.ts
12+
```
13+
14+
The serial integration test uses the existing `openIntegrationSession`,
15+
`runUntilDone`, and `closeIntegrationSession`. Optional test-only wiring registers
16+
`createSessionPruningCompactor` with `createModelSummarizer`, and supplies the
17+
normal `buildCompactionContinuationMessage()` delivery callback. The actual
18+
primary director, governor, reactor, toolset, and optimized git-backed store run.
19+
No test calls the compactor directly, rewrites history, or uses a substitute
20+
compaction implementation. Existing harness callers remain unchanged.
21+
22+
The harness defaults to `permissiveAuthorize`; this fixture also bypasses tool
23+
permission prompts in its temporary workspace. **It proves no authorization,
24+
approval-resume, or permission-isolation property.** No production permission or
25+
repeat guard is changed. There is no production hook, second host, network
26+
server, or paid provider request.
27+
28+
## Frozen protocol
29+
30+
- Product revision: `6ea596945657f3a0d3af5bfb0277b94af19e1589`, package `0.3.18`.
31+
- Research revision `b92dad53` is not the baseline. No version bump is included.
32+
- Protocol/model script: `primary-component-mechanics-v1` in the integration test.
33+
- External inference: `@intx/inference-testing` `0.3.0`, Anthropic wire format,
34+
source/model `anthropic:claude-integration` / `claude-integration`.
35+
- Vendored Interchange base: `0205b07b64d03f0fec2e4be3593c764070a9ba8a`, with
36+
repository-local patches recorded in `docs/VENDORING.md` and the patch ledger.
37+
- Runtime of the captured sample: Bun `1.3.14`, Darwin arm64.
38+
- Production policy: six recent turns; production anchor and no-op rules;
39+
model summary limit 4,000 characters and deterministic factory limit 2,500.
40+
- Trigger schedule: primary inference calls **19, 31, 43** report **synthetic**
41+
input 200,000; other explicitly scripted setup/growth calls report synthetic
42+
input 100 and output 1. Zero cache/thinking fields are synthetic wire fields,
43+
not measurements. Evidence-response wire frames use harness defaults, not
44+
measured provider usage. Production thresholds and hysteresis are unchanged.
45+
- Each phase adds ten distinct user/assistant audit-item exchanges, then a
46+
distinct real `read_file` call. The governor intercepts the post-tool infer and
47+
resumes through the same agent's contentless inbound channel.
48+
- Time bounds: 30 seconds per send; 120 seconds for the positive fixture.
49+
50+
Git blob identities freeze the uncommitted harness additions without inventing
51+
a commit revision. Recompute with `git hash-object` on these paths:
52+
53+
- `evals/compaction/fixtures.ts`: `67f1fdfb45272464efc62111c1c7525ed067264b`
54+
- `evals/compaction/metrics.ts`: `3cc4efadb5dad1f8078b6db412287b0ab5c25e8f`
55+
- `tests/integration/compaction-baseline.test.ts`: `abed36b077cb16e30bc5015852144bb6bb7fb5b2`
56+
- Original captured-run evaluator: `80a627549e0e009ca7c3079e26875baf3407e8e9`
57+
- `tests/integration/harness.ts`: `4567ac03433b70f1eed4f3238e2a3b5e1f5b4557`
58+
59+
The fixture module deterministically generates the exact input bytes: an early
60+
constraint, a later corrected decision, a failing `bun diagnose.ts` with decisive
61+
output after 250 preamble lines, and an oversized diagnostic with the decisive
62+
value after 1,500 lines. A full read and a targeted middle-line read exercise
63+
real tools. Before growth, the test verifies that all four facts reached
64+
persisted history. Generated workspace files contain no grader expectations.
65+
66+
## Evidence and scoring
67+
68+
The summarizer responder extracts only evidence markers in the **actual excerpt
69+
received from the production summarizer**. It never reads the original fixture
70+
or discarded turns. The primary response matcher selects an answer only when
71+
its exact set of source/value/id triples is present in the actual wire request,
72+
independent of their order. A real-agent reversed-order regression recovers all
73+
four facts without weakening source/value matching.
74+
All 16 subsets include an explicit all-missing response. A separate real-agent
75+
negative test supplies no evidence and verifies that fixture answers do not
76+
appear. These controlled responders measure transport/loss, not model judgment.
77+
78+
`metrics.ts` scores exact source and value, separately from artifact completion.
79+
Its tests reject altered artifacts, wrong sources/answers, absent evidence,
80+
repeated work, requested-only folds, no-ops, and missing continuation. Denominators
81+
remain four required facts per observation; the failed recovery task is retained.
82+
83+
At complete `runUntilDone` boundaries the fixture reads and validates the small
84+
`turns.jsonl` directly, without an in-flight `store.load()` or recovery read. A
85+
qualifying fold requires changed persisted SHA-256 bytes, fewer persisted turns,
86+
an additional production compacted-context marker, a new summarizer invocation,
87+
and primary continuation inference. Requests alone cannot qualify. This proves
88+
persisted replacement in a completed run, not crash atomicity or restart recovery.
89+
90+
The work counters derive from actual tool start/done events. Failed shell calls
91+
include the production `exit code <nonzero>\n` content prefix, even without
92+
`isError`. A regression executes `exit 7` twice through real tools and observes
93+
two failures and one repeated failed attempt. The three fixed
94+
phase-end reads are labelled verification by their frozen call IDs, not by a
95+
model-provided excuse. Other repeated reads/searches, repeated failed attempts,
96+
and duplicated edits are distinct metrics. No search or edit is prescribed here;
97+
zero repetition is not evidence of capable live problem-solving.
98+
99+
## Captured outcome
100+
101+
`results/baseline.json` retains one successful mechanics run, including all three
102+
observations, persisted hashes, phase latencies, and the failed recovery result.
103+
104+
- Mechanics task qualification: **1/1**; persisted folds **3/3**.
105+
- Persisted turn counts: **36 → 8**, **28 → 10**, **30 → 12**.
106+
- Continuation primary calls: **20, 32, 44**.
107+
- Required-fact recovery after each fold: **1/4**; full-recovery tasks **0/1**.
108+
- Only the initial constraint survives. The corrected decision, failed-command
109+
evidence, and decisive oversized-output fact are lost from the primary reply.
110+
- Three actual summarizer calls; six tool calls; three verification reads;
111+
zero observed repeated reads/searches, repeated failed attempts, or duplicated edits.
112+
- Captured phase latencies: approximately **757, 796, 801 ms**. They include the
113+
tool call, folding/persistence, continuation and reply, not compaction alone.
114+
- Positive fixture duration: approximately **12.12 seconds**, including setup and growth.
115+
116+
Primary/summarizer token totals, real cache reads/writes, monetary cost,
117+
compaction-only latency, and live completion quality are **unavailable**, not
118+
zero. Persisted hashes include runtime timestamps and legitimately vary between
119+
runs; the frozen source hashes identify the repeatable protocol.
120+
121+
The test characterizes the observed baseline loss; passing tests do not mean
122+
factual recovery passes. Replacement comparison must reuse these fixture bytes,
123+
trigger schedule, budgets, and exact-source grader. Keep this captured result
124+
unchanged and report improved recovery separately rather than weakening the
125+
grade or excluding the baseline failure.
126+
127+
## Remaining scope
128+
129+
Real TUI/exec host continuity, workflow/controller state, approvals, worker/task
130+
ownership, attachments, concurrent incoming messages, recovery, and finalization
131+
belong to Unit 6 and the Unit 8 cross-surface matrix. Archive exactness and
132+
security belong to Units 2–4. Live quality and spend-approved token/cache/cost
133+
comparison belong to Unit 8. These requirements moved; they were not removed.
134+
135+
## Verification
136+
137+
The focused command above passes (9 tests, 47 assertions with the evaluator
138+
regressions; the original captured run has 7 tests and 44 assertions). Results
139+
retain the original sample and record corrected-evaluator verification separately;
140+
fixture bytes, trigger schedule, and the observed 1/4 baseline recovery are unchanged.
141+
Required regression and repository gates:
142+
143+
```bash
144+
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
145+
bun run typecheck
146+
bun run build
147+
bun run test
148+
bun run check
149+
```
150+
151+
No commit or release action is part of this fixture.

evals/compaction/fixtures.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Evidence } from "./metrics.js";
2+
3+
export const BASELINE = {
4+
productRevision: "6ea596945657f3a0d3af5bfb0277b94af19e1589",
5+
packageVersion: "0.3.18",
6+
protocol: "primary-component-mechanics-v1",
7+
model: "claude-integration",
8+
provider: "anthropic",
9+
folds: 3,
10+
growthTurnsPerFold: 10,
11+
syntheticLowInput: 100,
12+
syntheticTriggerInput: 200000,
13+
outputTokens: 1,
14+
keepRecentTurns: 6,
15+
summaryMaxChars: 4000,
16+
wallTimeoutMs: 30000,
17+
} as const;
18+
19+
export const REQUIRED_EVIDENCE: readonly Evidence[] = [
20+
{ id: "constraint", source: "operator:initial", value: "no-schema-change" },
21+
{ id: "decision", source: "operator:correction", value: "west-not-east" },
22+
{ id: "failure", source: "command:diagnose", value: "unsupported-format-7" },
23+
{
24+
id: "decisive",
25+
source: "file:diagnostic.log:middle",
26+
value: "route-cobalt",
27+
},
28+
];
29+
30+
export function evidenceText(facts: readonly Evidence[]): string {
31+
return facts
32+
.map((fact) => `[[evidence:${fact.id}|${fact.source}|${fact.value}]]`)
33+
.join("\n");
34+
}
35+
36+
export const INITIAL =
37+
"Audit the deployment. Preserve this constraint: " +
38+
evidenceText(REQUIRED_EVIDENCE.slice(0, 1));
39+
export const CORRECTION =
40+
"Correction: target west instead of east. " +
41+
evidenceText(REQUIRED_EVIDENCE.slice(1, 2));
42+
export const FAILED_OUTPUT =
43+
"Diagnostic preamble.\n".repeat(250) +
44+
evidenceText(REQUIRED_EVIDENCE.slice(2, 3));
45+
export const OVERSIZED_OUTPUT =
46+
"Unrelated diagnostic row.\n".repeat(1500) +
47+
evidenceText(REQUIRED_EVIDENCE.slice(3)) +
48+
"\nUnrelated trailing row.".repeat(1500);

evals/compaction/metrics.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { type } from "arktype";
3+
import {
4+
grade,
5+
Measurement,
6+
qualifyingFold,
7+
recoverEvidence,
8+
repeatedWork,
9+
type Fold,
10+
} from "./metrics.js";
11+
12+
const fact = { id: "region", source: "operator:correction", value: "west" };
13+
const folds: Fold[] = [10, 20, 30].map((call) => ({
14+
requestedAtCall: call,
15+
beforeHash: `before-${call}`,
16+
afterHash: `after-${call}`,
17+
beforeTurns: 20,
18+
afterTurns: 10,
19+
persisted: true,
20+
continuedAtCall: call + 1,
21+
}));
22+
const baseline = {
23+
expected: [fact],
24+
recovered: [fact],
25+
expectedArtifact: "west\n",
26+
artifact: "west\n",
27+
folds,
28+
trace: [],
29+
};
30+
31+
describe("compaction baseline grading", () => {
32+
test("requires evidence actually visible to the scripted responder", () => {
33+
expect(
34+
recoverEvidence("[[evidence:region|operator:correction|west]]"),
35+
).toEqual([fact]);
36+
expect(recoverEvidence("The region was mentioned earlier.")).toEqual([]);
37+
expect(
38+
grade({ ...baseline, recovered: recoverEvidence("evidence removed") })
39+
.factualRecovery,
40+
).toBe(false);
41+
});
42+
43+
test("rejects wrong values, sources, and altered artifacts independently", () => {
44+
expect(grade(baseline).passed).toBe(true);
45+
expect(
46+
grade({ ...baseline, recovered: [{ ...fact, value: "east" }] }).passed,
47+
).toBe(false);
48+
expect(
49+
grade({ ...baseline, recovered: [{ ...fact, source: "invented" }] })
50+
.passed,
51+
).toBe(false);
52+
const altered = grade({ ...baseline, artifact: "east\n" });
53+
expect(altered.completion).toBe(false);
54+
expect(altered.factualRecovery).toBe(true);
55+
});
56+
57+
test("keeps failed fold denominators and rejects requests, no-ops, and missing continuation", () => {
58+
expect(grade({ ...baseline, folds: [] }).qualifying).toBe(false);
59+
for (const fold of folds) {
60+
expect(qualifyingFold({ ...fold, persisted: false })).toBe(false);
61+
expect(qualifyingFold({ ...fold, afterHash: fold.beforeHash })).toBe(
62+
false,
63+
);
64+
expect(qualifyingFold({ ...fold, continuedAtCall: null })).toBe(false);
65+
expect(qualifyingFold({ ...fold, afterTurns: fold.beforeTurns })).toBe(
66+
false,
67+
);
68+
}
69+
expect(grade({ ...baseline, recovered: [] }).requiredFacts).toBe(1);
70+
});
71+
72+
test("counts repeated work separately from legitimate scheduled verification", () => {
73+
const read = {
74+
name: "read_file",
75+
argumentsKey: "a",
76+
outcome: "success",
77+
purpose: "action",
78+
} as const;
79+
const search = { ...read, name: "grep" };
80+
const failure = { ...read, name: "run_shell", outcome: "failure" } as const;
81+
const edit = { ...read, name: "edit_file" };
82+
const trace = [
83+
read,
84+
read,
85+
search,
86+
search,
87+
failure,
88+
failure,
89+
edit,
90+
edit,
91+
{ ...read, purpose: "verification" } as const,
92+
];
93+
expect(repeatedWork(trace)).toEqual({
94+
repeatedReads: 1,
95+
repeatedSearches: 1,
96+
repeatedFailedAttempts: 1,
97+
duplicatedEdits: 1,
98+
verificationCalls: 1,
99+
});
100+
expect(grade({ ...baseline, trace }).passed).toBe(false);
101+
});
102+
103+
test("missing usage is unavailable, not zero or an unlabelled estimate", () => {
104+
expect(
105+
Measurement({ status: "unavailable", reason: "offline" }) instanceof
106+
type.errors,
107+
).toBe(false);
108+
expect(
109+
Measurement({ status: "reported", value: -1, unit: "tokens" }) instanceof
110+
type.errors,
111+
).toBe(true);
112+
expect(
113+
Measurement({ value: 0, unit: "tokens" }) instanceof type.errors,
114+
).toBe(true);
115+
expect(
116+
Measurement({
117+
status: "synthetic",
118+
value: 200000,
119+
unit: "trigger tokens",
120+
}) instanceof type.errors,
121+
).toBe(false);
122+
});
123+
});

0 commit comments

Comments
 (0)