M3.5
Shaping results for a token budget
1,072 words · about 7 min
Concept
A tool result enters the context window and stays there for the rest of the
session. An unbounded result is therefore not merely expensive once; it is
expensive on every subsequent request, and it displaces the working memory the
model needs to finish the task. A cat of a 50,000-line log can end a session.
The design goal is not "return less." It is: return a bounded amount that is self-describing about what was omitted and states the exact next call that retrieves more. A truncated result that does not say it was truncated is worse than no result, because the model reasons confidently from a partial view.
Mechanism
Three complementary techniques.
Bounded windows with explicit continuation. Cap the result and tell the model
precisely how to get the next page. dsh's read uses three independent caps —
per-line characters, total bytes, and line count — each with its own footer:
// packages/fs/tool-fs/src/read-render.ts
export const READ_MAX_LINE_LENGTH = 2000 // readMaxLineLength config
export const READ_MAX_BYTES = 50 * 1024 // readMaxBytes configWhy three? Because a single cap fails on the adversarial case. A line cap alone loses to a 50 MB file of short lines; a byte cap alone loses to a minified bundle with one enormous line; a line-count cap alone loses to both. The docstring is explicit about the memory consequence: "Chunk scanning caps the current line, so even one newline-free giant line cannot grow memory without bound." That is a denial-of-service control, not a formatting preference.
Spill-to-file for the capped remainder. When a result is capped, save the
complete data somewhere and report the path. grep returns "the first 250 matches
inline; a capped result reports where the complete match list was saved." bash
truncates to the tail — the opposite choice from read, and correct, because
a command's error and exit status appear last — and reports the saved full output
path. The saved path converts a hard cap into a soft one: the model can still get
everything, but must spend a deliberate call to do it.
Structured summaries in place of raw data. glob "returns file paths never
directories, up to 100 paths in mtime order"; beyond that it returns 100 sampled
paths, says it sampled, and reports where the complete sorted list was saved.
Sampling over truncation is the better choice for a listing because a
lexicographic prefix of a large tree is systematically unrepresentative.
Worked example — the line-number/offset idiom
Worked exampleThis is the single most copied pattern in agent harnesses, and it is worth understanding exactly why each element exists:
// packages/fs/tool-fs/src/read-render.ts
export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
let footer: string
if (outcome.truncatedByBytes) {
footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
} else if (endLine < outcome.totalLines) {
footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
} else {
footer = `(End of file - total ${outcome.totalLines} lines)`
}
const body = outcome.lines.length > 0
? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
: footer
return `<path>${displayPath}</path>\n<type>file</type>\n<content>\n${body}\n</content>`
}Four deliberate decisions:
- Every line is prefixed
N:. This gives the model stable coordinates it can cite in its reasoning and in later calls. It also costs real tokens on every line — a genuine trade accepted because the alternative is a model that says "around the middle of the file." - The footer names the next call literally. Not "more content available" but
Use offset=451 to continue.The model does not have to compute anything. - The three footers are distinguishable. "Output capped" (byte limit), "Showing lines X-Y of Z" (line limit, total known), and "End of file" mean different things and imply different next actions.
- The result is wrapped in
<path>/<content>tags. Delimiting file content is a prompt-injection mitigation: it gives the model a structural cue for where untrusted data begins and ends. It is not a guarantee — nothing stops a file from containing a fake closing tag — but the honest framing is that tagging raises the cost of an injection rather than eliminating it. This is an unsettled area of the field; no harness has a complete answer.
The web fetch tool uses the same pattern with a domain-appropriate remedy:
// packages/web/tool-web/src/fetch.ts
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'There is no offset for a web page, so the footer teaches a different recovery:
narrow the request. Note also that renderFetchOutput is the single source of both
the render text and the fetch card's truncated flag, so the card never
disagrees with the text the model saw — the UI and the model are shown the same
truth by construction.
Tradeoffs
Aggressive truncation causes the model to make more calls, and each call is a full
round trip with the entire context resent. There is a real optimum, and it is
task-dependent: a 2,000-line default suits code files and is far too generous for
logs. Line-number prefixes cost roughly 3-5% of tokens on typical source and are
almost always worth it. Spill-to-file assumes the model can read the spill path,
which is only true when a filesystem tool is also mounted — dsh routes capped
results through an optional ctx.spillStore, and the tools degrade gracefully when
it is absent.
How `dsh` does it
How dsh does itResult shaping is owned by the tool through output.render(args, value), a pure
projection from canonical value to model-visible content. Purity is enforced by
convention and rationale: presenters run on live streaming and on replay, so they
must do no I/O, read no session state, and consult no clock or random source. The
same value must always render to the same content. This is what makes session
replay and snapshot testing possible at all.
How others differ
Claude Code's Read returns line-numbered content with an explicit warning that
the numbers are a display convention and must not be included in Edit arguments —
a documented failure mode dsh avoids structurally by having edit match on
literal text rather than line ranges. Aider avoids the whole problem differently:
its repo map uses tree-sitter to extract only signatures and a graph ranking
algorithm to select the most relevant ones, so the model receives a dense structural
summary of a large codebase rather than paginated file contents.
Check yourself
2 questions on M3.5