feat(core): stop tools from flooding the model, and stop losing what they said - #268
Open
oratis wants to merge 2 commits into
Open
feat(core): stop tools from flooding the model, and stop losing what they said#268oratis wants to merge 2 commits into
oratis wants to merge 2 commits into
Conversation
…they said Two verified defects, one cause: nothing central bounds what a tool result puts into the model's context, so each tool improvises. WebFetch improvised by not bounding at all — `content: body`, capped only at 5 MiB of bytes upstream. A 5 MiB page is roughly 1.5M tokens; one call ends the session. Bash improvised by slicing at 30 KB and appending a marker, writing the remainder nowhere. The tail of a failing test run is exactly the part worth reading, and it was the part being discarded. The spill policy now runs on every tool result on its way to the model. Results at or under the threshold pass through untouched — identical object, no copy — so this is invisible for almost every call. Oversized results become a head+tail preview naming the file where the full text was saved, under the session directory beside its snapshots. Head AND tail, weighted toward the tail: stack traces, assertion diffs, and exit lines live at the end. Head-only truncation throws away the answer and keeps the preamble. Bash's capture buffer had to grow for there to be anything to save, so it now uses the same bounded head+tail primitive at a much larger limit. Memory stays bounded no matter how much a runaway command prints — a gigabyte costs the same as a kilobyte. Hosts without a filesystem (the Tauri renderer) still get the bound; they just cannot offer retrieval, and the preview says so rather than implying a file exists. Saving failures degrade the same way instead of failing the call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`new SessionManager(sessionsRoot)` passes a string where the constructor wants
`{ root }`, so `opts.root` was undefined and the manager silently fell back to
the real `~/.deepcode/sessions`. The test still passed — it reads the file back
through the locator it was handed — while writing a session and a spill artifact
into the user's actual home directory on every run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oratis
force-pushed
the
feat/tool-output-spill
branch
from
August 14, 2026 04:51
21c07f2 to
f2ee091
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First implementation PR from
docs/DSH_ADOPTION_PLAN.md§1.1. Depends on nothing; #267 carries the reasoning.The two defects
Both verified against our own source during the DeepSeek Harness comparison, and both trace to the same cause: nothing central bounds what a tool result puts into the model's context, so each tool improvises.
WebFetchimprovised by not bounding at all.web-fetch.tsreturnedcontent: body; the only ceiling was 5 MiB of bytes upstream. A 5 MiB HTML page is roughly 1.5M tokens — a single call ends the session.Bashimprovised by destroying evidence.bash.tssliced each stream at 30 KB and appended... [stdout truncated]. The remainder went nowhere. The tail of a failing test run is the part worth reading, and it was precisely the part being discarded.What this adds
applySpillPolicyruns on every tool result on its way to the model:Retrieval needs no new tool — the preview tells the model to
Readthe path, andoffset/limitalready page through it.Two choices worth flagging
The preview keeps both ends, weighted toward the tail (40/60). dsh only requires a bounded preview. Stack traces, assertion diffs, and exit codes live at the end; head-only truncation keeps the preamble and throws away the answer, which is what we were doing.
Bash's capture buffer had to grow for there to be anything to save. It now uses the same bounded head+tail primitive at a much larger limit, so memory stays bounded regardless of how much a runaway command prints — a gigabyte costs the same as a kilobyte. This is a capture limit, not the model-visible limit; those are now separate numbers with separate jobs.
Degradation
A host with no filesystem (the Tauri renderer, where the loop runs in the webview) gets the bound but cannot offer retrieval. The preview says the output was not saved and suggests narrowing the command, rather than printing a path that does not exist. A save that throws degrades identically instead of failing the tool call — a partly-readable result beats an error.
The store is resolved once and memoized on the promise, not the result: tools run concurrently, and a second caller arriving mid-import must wait for the same answer rather than read "not resolved yet" as "no store".
Verification
pnpm typecheck,lint,format:check,docs:checkall clean. Full suite green — core 1056 passed / 28 skipped, cli 242, desktop 104, server 49, protocol 34, lsp 13, vscode 12, scripts 42.26 new tests. The ones that carry weight:
HEADand ends withTAILimport('./spill/local.js')path, not a stub