Skip to content

feat(harness): retained tool outputs — big results live on disk, not in context - #64

Open
pierreprudh wants to merge 2 commits into
mainfrom
feat/retained-values
Open

feat(harness): retained tool outputs — big results live on disk, not in context#64
pierreprudh wants to merge 2 commits into
mainfrom
feat/retained-values

Conversation

@pierreprudh

@pierreprudh pierreprudh commented Aug 27, 2026

Copy link
Copy Markdown
Owner

What

Prime-Agent-inspired (arXiv 2608.23552) retained-values store for the Rust harness:

  • A tool result over 20KB is written once to <runs_dir>/<run_id>.values/<call_id>.txt; the provider messages carry a [retained #id] stub with a head/tail preview instead of the full text.
  • New peek_value tool (conversation-history capability) reads numbered line ranges (≤400/call) or searches the stored text by substring. Peek output is capped at 16KB — below the retention threshold by construction, so a peek can never itself be retained.
  • The ToolCallFinished event keeps the full result: the durable transcript is unchanged, Mission Control and the UI still show everything, and both replay shapes (text-fold and structured) rebuild the identical stub deterministically from the transcript — a resume can't re-poison the context.
  • If the disk write fails, the result stays in context verbatim — never a stub pointing at nothing. Value ids are derived from the tool call id through one sanitizer shared by retention, replay, and peek, which also makes path traversal unrepresentable.

Compaction remains the second line of defense; retained results are no longer lost when it runs.

Before / after

Before — every byte of a tool result rides in the provider messages until auto-compaction guts it to a 600-char excerpt; the original is gone and the model must re-run the tool:

flowchart LR
    T["tool executes<br/>(103KB output)"] --> C["provider messages<br/>full 103KB in context"]
    C -->|"turns pass"| K["compaction<br/>600-char excerpt"]
    K -->|"original lost"| R["model re-runs the tool<br/>to see it again"]
    T --> E["transcript event<br/>full result"]
Loading

Now — the full text lands on disk once; context carries a small stub; the model reads slices on demand and nothing is ever lost:

flowchart LR
    T["tool executes<br/>(103KB output)"] --> G{"> 20KB?"}
    G -->|no| C["provider messages<br/>verbatim, as before"]
    G -->|yes| D["runs/&lt;run&gt;.values/&lt;call&gt;.txt<br/>full text, atomic write"]
    G -->|yes| S["provider messages<br/>[retained #id] stub<br/>head + tail preview"]
    S -.->|"model calls peek_value<br/>(range or query, ≤16KB)"| D
    T --> E["transcript event<br/>full result — UI, replay,<br/>Mission Control unchanged"]
Loading

The same stub is rebuilt deterministically on replay/resume (pure stub_for over the transcript event), so a continuation turn sees exactly what the live turn saw.

Verified live

Dogfooded in a real Goal run (deepseek-v4-flash): a 103KB package-lock.json read was retained, the model searched it with peek_value(query: "node_modules/vite"), drilled into lines 2445–2458, and answered vite 7.3.6 — correct to the integrity hash — without the file ever entering context.

Tests

  • 9 new unit tests in retained.rs (threshold, stub shape, peek range/query/caps, traversal, live-vs-replay stub equality)
  • 1 replay test pinning that a large prior result folds back as its stub in both replay shapes
  • cargo test --lib: 604 passed · cargo check + npx tsc --noEmit clean

🤖 Generated with Claude Code

pierreprudh and others added 2 commits August 27, 2026 16:33
… context

Prime-Agent-inspired L2 store: a tool result over 20KB is written to
<runs_dir>/<run_id>.values/<call_id>.txt and the provider messages carry
a [retained #id] stub with a head/tail preview instead. The transcript
event keeps the full result, so the durable record is unchanged and both
replay shapes (text-fold and structured) rebuild the same stub
deterministically. A new peek_value tool (conversation-history
capability) reads numbered line ranges or searches the stored text, with
caps that keep any peek below the retention threshold — a peek can never
itself be retained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est replay, gated stubs

Review of the retained-values slice surfaced four fixable gaps; all closed:

- peek_value now numbers lines with the same `N: ` gutter read_file uses,
  and write_file's tolerant matcher strips *stacked* gutters — so a line
  quoted from a peeked slice of a retained read (two gutters deep) still
  edits cleanly. This was the one real interop break: editing a >20KB file
  went read → stub → peek → paste, and the paste failed to match.
- Replay only stubs a prior result when its value file is actually on disk;
  otherwise the full text rides along. A stub can no longer point at a value
  that never landed (failed live write) — impossible by construction now,
  not just on the live turn.
- Retention is gated on peek_value being in this run's tool list (mode +
  disabled tools): no stub may instruct the model to call a tool it
  doesn't have. Live and replay share the same gate.
- The text-fold replay learns tool names from the assistant turns, so its
  stub reads exactly as the live loop wrote it ("read_file returned …",
  not "tool returned …") — both replay shapes now byte-match the live stub.
- value ids that sanitization actually altered get a short FNV-1a suffix,
  so two distinct call ids can't collapse onto one file; clean provider ids
  pass through untouched.

New coverage: a run_loop e2e test drives the real loop through
read → stub → peek_value → answer and pins the provider view, the value
file, and the untouched transcript; replay tests pin the no-file-full-text
fallback and the stacked-gutter edit match. 609 lib tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pierreprudh

Copy link
Copy Markdown
Owner Author

Review follow-up (a4191a3) — the four fixable cons from the pros/cons pass are closed:

  1. Edit-contract interoppeek_value now emits the same N: gutter as read_file, and the write_file tolerant matcher strips stacked gutters, so pasting a peeked line of a retained read into old_str edits cleanly (new locate_tolerates_stacked_gutters_from_a_peeked_read test).
  2. Stub honesty on resume — replay stubs a prior result only when its value file exists on disk; otherwise the full text rides along. "Stub pointing at nothing" is now unrepresentable, not just unlikely.
  3. Retention gating — no peek_value in the run's tool list (mode/disabled) → no stubs, live or replay.
  4. Stub determinism — the text-fold replay learns tool names from assistant turns; both replay shapes now byte-match the live stub.
  5. Id collisions — sanitized/truncated value ids get a short FNV-1a suffix; clean provider ids are untouched.

Plus a run_loop e2e test driving the real loop through read → stub → peek → answer. 609 lib tests pass, cargo check clean.

Deliberately left open (noted, not blocking): the 20KB threshold isn't in harness settings, .values/ dirs are never GC'd (transcripts aren't either), and small-local-model behavior against stubs still wants a live dogfood run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant