Certified Agent Harness Engineer
Course sections

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 config

Why 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 example

This 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:

  1. 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."
  2. 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.
  3. 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.
  4. 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 it

Result 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

Start drill