One tool call can destroy your agent's context window
What a tool returns is a budget decision. Measured savings from scoping one payload, and why a thin result is more dangerous than a fat one.
I ran a directory listing today to check one file. It returned 112 lines, and I read all of them, because that is what happens when a result arrives.
The file I wanted was not among them.
TL;DR
- A single over-generous tool result can consume the context the agent needed to finish the job.
- The fix belongs in the TOOL, not the prompt: return what the caller asked for, not everything you have.
- How to size a tool result, and why pagination beats truncation.
The case this post is about
One status line, returned by one tool:
161 post(s) · planned 48 · drafting 1 · ready 0 · medium-draft 29 · medium-live 83
Six numbers, one line. The version before it returned every post with its full frontmatter — about forty kilobytes to answer how is this going, of which the answer was these six numbers and the rest was attention spent on rows nobody would read.
Trimming it is the obvious half. The dangerous half is in the other direction: a tool that returns too little gives no indication that it has, so a caller acts on a partial answer believing it is complete.
This post follows that one line, because I measured the trimming and the saving was a fifth of what I had estimated — and the same measurement found a surface that had never been included at all.
The symptom
An agent's context is a fixed budget spent on whatever arrives in it. A tool result does not ask permission — it lands, it is read, and the tokens are gone whether or not they were useful.
The costs are easy to underestimate because each individual result looks reasonable:
- a listing that returns every row when the question was about one
- a file read that returns 800 lines when the answer is in 12
- a status command that prints the whole matrix when the caller asked for one section
- a search that returns full file contents rather than the matching lines
None of these is a bug. Every one of them answers the question asked. They just answer several other questions at the same time, and the agent pays for all of them.
Why the obvious fix failed
The obvious fix is to truncate. Cap the result at N rows, add an ellipsis, move on.
This is the worst of the available options and it took me a while to see why. A truncated result is indistinguishable from a complete one unless the truncation is announced in a way the caller cannot skim past. The agent receives 25 rows, has no reason to doubt them, and reasons confidently about a set that is missing 75 members.
I watched exactly this happen. A scroll loop collecting rows from a paginated page stopped when the count stopped growing, which sounds like a correct termination condition and happens to coincide with a pagination boundary. It reported 25 items. There were 100.
The second instinct — return less by default and let the caller ask for more — is right, and it has a failure mode that is worse than the one it fixes. I will come back to that, because it is the real subject of this post.
The actual mechanism
Three shapes, in the order I now reach for them.
Summarise by default, with the counts that let the caller decide. A status command should lead with the shape of the answer, not the answer:
161 post(s) · planned 48 · drafting 1 · ready 0 · medium-draft 29 · medium-live 83
That is one line. From it the caller knows whether it needs the 48 or the 83, and can ask for precisely that. The full matrix is still available; it is just not the default.
Page explicitly, with a cursor the caller has to use. A result carrying next_cursor is honest
about being partial in a way an ellipsis is not, because the caller must do something with it. The
contract is legible: there is more, here is how to get it.

Refuse rather than truncate silently. When a result is unexpectedly smaller than the known set, that is a signal, not a smaller answer:
const TRUNCATION_RATIO = 0.2;
if (recorded.size >= 10 && missing.length > recorded.size * TRUNCATION_RATIO) {
console.error(`${missing.length} known rows absent — refusing to report a partial list`);
Deno.exit(3);
}

The threshold is a judgement call and 20% is not special. What matters is that the failure mode changes from quietly wrong to loudly absent, and only one of those gets investigated.
The fix
Here is the part I did not expect: measure before you optimise the payload, because estimates about context are badly wrong.
A shared instruction bundle was being loaded by every generator subagent. It contained material for several project types, so a subagent working on one type carried guidance for the others. Obvious waste. My estimate before measuring was that scoping it would cut roughly 40%.
Measured:
before 305,942 B
after 280,963 B
saved ~6.2k tokens per subagent (8.2%)

Eight percent, not forty. Real, worth keeping, and nowhere near the size that would have justified the architectural change I had been considering on the strength of the estimate.
The measurement is three lines and the assertion that came out of it is the valuable half — because a bundle that is too thin fails silently in a way a bundle that is too fat does not:
measure_pack() {
local type="$1" out="$2" bytes=0 included=() excluded=()
for surface in $(list_all_surfaces); do
if surface_applies_to "$surface" "$type"; then
cat_surface "$surface" >> "$out"
bytes=$(( bytes + $(surface_size "$surface") ))
included+=("$(basename "$surface")")
else
excluded+=("$(basename "$surface")")
fi
done
# STAMP both lists. An omitted surface has to be a declared exclusion, or a
# consumer cannot tell "not for you" from "nobody built it".
write_manifest "$out.manifest" \
"type=$type" "bytes=$bytes" \
"included=$(join_commas "${included[@]}")" \
"excluded=$(join_commas "${excluded[@]}")"
echo "$bytes"
}
require_surface() {
local manifest="$1" surface="$2"
[ -f "$manifest" ] || { echo "pack not built" >&2; return 5; }
manifest_lists "$manifest" included "$surface" && return 0
manifest_lists "$manifest" excluded "$surface" && {
echo "$surface is type-excluded from this pack — do not read it directly" >&2; return 3; }
# Neither list: the glob never matched it. This is the silent-thinning failure.
echo "$surface is in NEITHER list — it was never assembled by any glob" >&2
return 4
}
The measurement, and the assertion a generator runs before it writes anything:
$ measure_pack kmp pack-kmp.md
280963
$ measure_pack all pack-all.md
305942
$ require_surface pack-kmp.md.manifest CORE_GAME.md
CORE_GAME.md is type-excluded from this pack — do not read it directly
exit 3
$ require_surface pack-kmp.md.manifest GAME_SCENE_BUILD_GUIDE.md
GAME_SCENE_BUILD_GUIDE.md is in NEITHER list — it was never assembled by any glob
exit 4
Exit 3 and exit 4 are the whole reason to stamp both lists. The first is a correct scoping decision and the generator should proceed without that surface. The second is the one the measurement found while I was looking for something else: a guide the generators needed, matched by no glob, absent from every pack that had ever been built — and before the exclusion list existed there was nothing in the output that could tell those two cases apart.
The same measurement turned up something I was not looking for, which is the usual way: one guide the generators genuinely needed had never been in the bundle at all. The glob that assembled it matched a naming convention that file did not follow, so it had been silently absent since the bundle was created. Nobody noticed, because a missing instruction does not announce itself — the generator just improvises, competently, and produces something slightly wrong for reasons nothing records.
The failure that is worse than a fat result
That is the asymmetry, and it is why "return less" is not simply good advice.
A result that is too large is visible. It costs tokens, the cost is measurable, and anyone looking at the transcript can see where the budget went. It is an expensive problem with an obvious diagnosis.
A result that is too small is invisible. The agent receives something well-formed, has no reference for what is missing, and proceeds with confidence. There is no artifact that says "the thing you needed was not included."
So the trimming has to come with a way to detect its own over-reach. What worked was making the bundle declare its contents, and giving the consumer a way to assert what it requires:
0 present
3 excluded for this project type
4 undeclared — the consumer asked for something nobody scoped
5 bundle not built
Any non-zero exit stops the run and names the missing surface. The rule that makes it work is the unglamorous one: it is equally forbidden to work around the refusal by reading the file directly. An assertion you can satisfy by going around it is a comment.
How we proved it
The truncation guard, against a listing that came back short because a scroll had stopped early:
83 known rows absent — refusing to report a partial list
exit 3
Before the guard, the same condition produced a successful-looking result with 17 rows and no indication that anything was missing.
And the cross-check that costs one line and is worth more than any amount of loop review — comparing what was collected against a total the source itself publishes:
counts: {"published":{"declared":100,"scraped":100}}
The honest limitation: every mechanism here protects against accidental incompleteness. None of them helps when the result is complete and simply wrong for the question — a listing of the right size, from the wrong directory, passes every check above. The declared-versus-scraped cross-check only works because the source publishes its own count, and most sources do not. Where nothing publishes a total, "I collected all of them" remains an assertion rather than a verified claim, and it is worth writing down which of the two you have.
What this generalizes to
Treat a tool's return shape as part of its interface, with the same care you would give its arguments. Default to the summary and the counts; make partial results carry a cursor rather than an ellipsis; refuse when the answer is unexpectedly small.
Then measure what the payloads actually cost before restructuring anything, because the intuition is unreliable in both directions — and give every trimmed payload a way to report that it trimmed too much. The fat result wastes money you can see. The thin one spends correctness you cannot.
Related
- Where the tokens actually went — What the re-derivation after a context blowout costs
- Make the agent return a shape, not a paragraph — Returning a shape instead of a wall of text
Keep reading
One post a week on shipping software with AI agents — what broke, what the fix was, and the evidence. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".