Skip to content

feat(context): oversized tool outputs spill to a session artifact (#409) - #436

Merged
justrach merged 6 commits into
mainfrom
feat/prime-409-spill
Aug 6, 2026
Merged

feat(context): oversized tool outputs spill to a session artifact (#409)#436
justrach merged 6 commits into
mainfrom
feat/prime-409-spill

Conversation

@justrach

@justrach justrach commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Implements #409 — spill, don't truncate. The RLM 'context as variables' payoff without adopting a kernel: the per-result cap (#193/#201) no longer destroys the elided bytes.

Mechanism

When the session is durable, the full pre-truncation output is written to .graff/sessions/<session>/artifacts/tool-<n>.txt and the elision marker cites the absolute path + byte count, telling the model to read/grep the slice it needs instead of re-running the tool. The spill lives inside truncateStrField — the one place still holding the pre-truncation string — which forced a healthy split: the cap primitives moved from agent_compact.zig (598→564) into the new tool_spill.zig.

Safety rails: subagents (history never persisted) keep plain truncation; session names are traversal-validated; a 64 MiB per-session budget reserved atomically per whole artifact (a marker can never overstate what's on disk); a marker that can't fit inside the cap falls back to the short note so the cap never grows an output. Artifact dirs whose session file is gone are swept at the next spill (1h orphan grace for a concurrent pre-autosave graff; deliberately not hooked at rename, which would strand paths already cited in the live transcript). The artifact path resolves through the dir handle it was written with — the $PWD fallback produced a dead path in a chdir'd caller, and the E2E caught it.

Experiment

scripts/test-spill-artifact.py, CI-wired: a seeded session carries an over-cap output with a needle past the cap; asserts the artifact holds all 30,000 bytes byte-for-byte, the request carries the marker (and not the needle), the mock then reads the exact path the marker cited and the needle comes back in the next request. Negative control: sink unwired → test fails. 3× stable.

Evidence

Tests 994 → 1000 (+6 exactly: durability gate, budget bounds incl. exact-fit/one-over/saturation, traversal names, real disk spill, sweep keep/reclaim). All 9 golden eval files PASS byte-identical. fmt + line-guard clean. Changelog under v0.0.241 (unreleased).

Noted for a possible follow-up: #163's trimOldestToolOutputs still truncates destructively — one field away from spilling too.

Part of the prime-agent adoption batch.

theprimeradiant and others added 4 commits August 6, 2026 13:45
The per-output cap (#193/#196) destroyed the elided bytes: the model's only
recovery was to re-run the tool and guess a better slice. Now, when the agent
has a durable session, the FULL output is written to
`.graff/sessions/<session>/artifacts/tool-<n>.txt` before the cap shrinks it,
and the marker cites the absolute path and the byte count, so the next turn can
read or grep exactly what it needs.

Same call site and same safety class as #196 (no WS-close bracketing): the cap
still only shrinks strings in place. A subagent has no persisted history, so it
has no durable session and keeps the plain truncation, as does any process that
never wired a sink (every unit test).

Growth is bounded twice: `session_cap_bytes` (64 MiB, all-or-nothing per
artifact so a marker can never lie about its byte count), and reclamation of
the artifact dirs whose `<session>.session.json` is gone — the session file is
the ground truth for "this session was deleted", so an rm, the AI-title rename
and /new all reclaim what they left behind. The sweep runs once, at the first
spill, so a run that never spills does no extra I/O.

The cap's truncation primitives move to the new module with it: the spill has
to happen inside truncateStrField, the one place still holding the
pre-truncation string, and agent_compact.zig sits at the 600-line cap.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
scripts/test-spill-artifact.py drives a real graff against the repo's scripted
model (scripts/eval/mock_model.py on the lmstudio port) with a seeded session
whose last tool output is over the per-output cap, and asserts all three halves
of the claim: the request the harness sent carries the marker (absolute path +
byte count) and NOT the elided needle, the artifact on disk holds the original
bytes byte for byte, and a follow-up `bash` call against THE PATH THE MARKER
CITED brings the needle back. Negative control: with the sink unwired the test
fails with "no #409 marker" and no read-back.

The marker's path is now resolved with realPathFile through the same dir handle
the artifact was written with. The declared base (g_cwd_display) falls back to
$PWD, which a caller that changed directory without exporting it gets wrong —
and that produced a path that pointed at nothing, exactly what the e2e caught.

The user-facing cap note now distinguishes the two outcomes: bytes elided but
kept as an artifact, versus the pre-#409 destructive truncation (#202).

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
CHANGELOG entry for v0.0.240 and the README's context-management paragraph,
which described the tool-result preview pointer but stopped at the send-time
cap, where the bytes used to simply go.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
…ename (#409)

Moving a renamed session's artifacts (or dropping them at the rename) would
strand every path already handed to the model in that transcript.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
justrach and others added 2 commits August 6, 2026 14:57
…an forge

The marker embeds the artifact's REAL absolute path, and std.testing.tmpDir
names its directory with 16 random base64-url characters. 'N' is in that
alphabet, so "the needle is gone from the transcript" failed whenever the
random name happened to contain one: 4 failures in 15 runs, measured
identically on this integration AND on feat/prime-409-spill alone.

Test-only; the assertion's intent is unchanged.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
…hs are not /-rooted (#409)

The test matched 'are at /' and posix separators in the marker; on windows
the resolved path is drive-rooted with backslashes and the windows CI job
failed on exactly this test. The path is now extracted from the marker and
asserted absolute via std.fs.path.isAbsolute, with the artifact name
matched separator-free.

Co-Authored-By: Codegraff <blackfloofie@codegraff.com>
@justrach
justrach merged commit 5e53018 into main Aug 6, 2026
6 checks passed
justrach added a commit that referenced this pull request Aug 6, 2026
integration: the prime-adoption batch + engine slice 1c (merges #433 #434 #435 #436 #437 #438)
@justrach
justrach deleted the feat/prime-409-spill branch August 6, 2026 09:26
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.

2 participants