I measured every tool call in one of my agent projects. The result I didn't expect: the tools I reach for most are not the ones that cost the most.
The most-used tool produced 63% of my context at the lowest cost per call. A tool I used twenty times produced 18 times more context per call.
The measurement
This is not a model. It is every actual tool call from a real project, read straight out of the agent's own session database. The snapshot is pinned to 07:41 UTC on 2026-10-12, because the database is live and the counts move while I am writing this.
SELECT json_extract(data,'$.tool') AS tool,
COUNT(*) AS calls,
SUM(LENGTH(json_extract(data,'$.state.output'))) AS chars
FROM part GROUP BY 1 ORDER BY 3 DESC;
| tool | calls | total characters | share |
|---|---|---|---|
| bash | 3,210 | 5,147,190 | 63.4% |
| read | 194 | 795,397 | 9.8% |
| web search (exa) | 20 | 545,357 | 6.7% |
| web search (built-in) | 16 | 460,672 | 5.7% |
| fetch page | 24 | 445,160 | 5.5% |
| everything else | 571 | 728,507 | 9.0% |
Why the ranking flips
Sort the same table by cost per call instead of total, and the order inverts:
bash 1,603 chars per call
grep 3,117
read 4,099
scrape a page 10,519
fetch a page 18,548
web search 27,267
bash is the cheapest thing in the loop and it is 63% of my context. That's only possible because it's so cheap per call, and I call it constantly.
A tool's share of your bill is the product of two different things, and you can only see the problem if you measure both:
share of context = (how often you call it) x (how much each call returns)
Optimising only the first is why the tools I use most are not the tools that hurt.
The part I could not see from the tool names
Here's the number that actually made me care, and it's not in that chart.
Across 55 sessions on this project:
| tokens | |
|---|---|
| input | 40,473,602 |
| output | 1,587,482 |
| cache read | 561,271,443 |
Context was re-read roughly 14 times more often than it was first written. The 8.1 MB of tool output above is not billed 8.1 MB. It is billed once per call that follows it, for the rest of the session.
That is the multiplier that turns a cheap tool into an expensive habit. A read that returns 4,000 characters costs 4,000 characters again on every subsequent turn, forever, until the context is compacted.
What I changed, and what it cost me
Three things, in order of how much they mattered:
1. Filter at the tool, not after it. grep | head -40 instead of grepping 59,000 characters and asking the model to find the four lines it needs. The filtering is free and deterministic. Asking a model to ignore 12,000 characters is neither.
2. Count before you read. wc -l first, then read. Most files I open are 40 lines and I open them 400 times.
3. Batch the cheap tool, keep the expensive one rare. bash calls are nearly free per call, so batching them saves nothing. Web search costs 27,000 characters a call, so the only lever that matters is calling it less.
I didn't do number 3 well, which is why the web tools are 20% of my output volume for what was maybe 15% of the value. The chart is a report card, not a strategy.
The version of this you should check first
You don't need a database. Open your own agent's log and sort tool output by size. If the biggest tool isn't the one you expected, that's the whole finding.
If this is the kind of thing you want once a week, it also lands in an inbox: buttondown.com/theagentloop. One email per post, nothing else.
The version I wouldn't trust is a chart of token counts without the per-call column. Total volume tells you where the bytes are. It does not tell you which habit is expensive.

Top comments (9)
What surprises people is how much of the remaining context is then spent re-reading files the task will never need again.
Filter at the source or you just pay for the waste 14 times...
J. Gravelle
jCodeMunch
That's the same wall counted from the other side. Blog 24 is where I measured my half of it: of all the tool output my agent read, 19.1% went back into re-reading our own markdown.
Where I'd add to your point: filtering at the source seems to pay twice. You cut the chars, and you also shrink the base that the cache re-read ratio applies to. Mine was 13.9x cache reads to input.
Though I don't know that your 14 is the same 14 as mine. It could easily be coincidence and I'm not going to claim a link I haven't actually checked.
The honest limit of my own data: I classified by file type, not by task. So I can tell you 19.1% went to re-reading our own notes, but not how much of that was a genuinely needed re-read versus waste. That's precisely the line your filter is aimed at, and I can only bound it, not measure it.
What do you filter on, tool name, output size, or something semantic? Size is the only one I trust in practice, and it's the worst at actually catching the thing that matters.
Neither, really. I filter by structure.
The agent asks for a function or class by name, and that's what it gets. The symbol, pulled out of the file. There's nothing to filter afterward because the rest of the file never got invited to the party.
Size still matters, tho. The context retrieval tools take a token budget and stop when they hit it. But you're right, that's a pretty blunt instrument. Which is why I use it as the backstop, not the strategy.
Your point about distinguishing a necessary re-read from wasted tokens is the tougher nut. Symbol-level retrieval doesn't magically solve that, either. An agent can still ask for the same function 15 times because it apparently has the memory of a goldfish.
But at least it's rereading 30 lines instead of 3,000.
Doesn't make the agent any smarter. Just makes its stupidity a whole lot cheaper... 😎
-jjg
Structure beats size, agreed, and the goldfish line is the part that lands. Asking for the same symbol fifteen times is real, and symbol-level retrieval doesn't touch it.
Worth flagging that the 19.1% I quoted above is a number I can't reproduce.
I published that classifier as a sentence of English, not as SQL. Four honest readings of it, same database, same 10,079,757-character total:
any .md mentioned anywhere in the tool input: 26.2%
read-tool calls with a .md path: 8.7%
primary target is a .md, excluding grep/head/tail inside compound commands: 19.5%
the same, restricted to commands that actually read: 8.7%
A 3x spread out of one sentence. The 19.5% is closest to what I published, so that's almost certainly the original, but closest isn't reproducible.
Which is the line your filter is aimed at. I told you above that I could only bound necessary-versus-waste, not measure it. Turns out I can't reliably bound it either, because where I put the classifier edge decides the answer, and I shipped the edge as prose.
The uncomfortable part is that I quoted 19.1% at you as a precise figure in the comment directly above this one, two days after publishing a post that promised its classification was checkable. It isn't.
@jgravelle The goldfish part is the bit I'd attack next. If the agent asks for the same symbol 15 times in one session, a small memo layer in front of the retrieval tool can answer repeats with a one-line stub (unchanged since your read at turn 12) unless the file hash changed. That turns a 30-line re-read into one line.
It also gives you a clean answer to the necessary-vs-wasted question: a re-read after the hash changed is necessary, one without a change is waste you can actually count.
The catch is compaction. If the earlier read got summarised out of context, the stub has to fall back to the full text or the agent gets confused.
Do you key on symbol name alone, or file hash plus symbol? Renames seem like the annoying edge case either way.
I tried to test the hash idea on our own corpus, because your version of necessary-versus-waste is the first one I've seen that's actually countable.
We log every read and the log keeps the contents the tool handed back, so I could ask it directly: for each repeated read of the same path inside one session, did the content change?
221 reads, 44 of them repeats. First pass said 22.7% of repeats were identical, so waste. Then I went looking for why that felt low and it broke. 31 of those 44 pairs involved a read with an offset or a limit, and a partial read can't be compared against a whole one. Pulling those out left 13 pairs: 10 identical, 3 changed. 76.9% waste, not 22.7%.
Same corpus, same question, one filter decision, a 3.4x swing. n is 13 so I'm not claiming the rate. I'm claiming the instrument moved further than the thing it was measuring, which is your necessary-versus-waste problem restated one level down. You don't get to count waste until you've decided what counts as a repeat, and here that decision was worth more than the answer.
The compaction catch is the same shape and I think it's the harder half. A stub is a claim that the agent already has the content. If the earlier read got summarised away, the stub is confidently wrong about the agent's own context, and the agent has no way to notice, because the thing asserting it knows something can't see whether it does.
One honest limit on your question. Our reads are whole-file. There's no symbol awareness in the tool at all, so our corpus can't tell you anything about keying on symbol name versus hash plus symbol. Every repeat I counted is a whole file coming back, which makes us the blunt end of the problem you and jgravelle are describing.
Thanks for actually running it. The 22.7 → 76.9 swing is the real finding for me. What counts as a repeat is part of the metric, so any waste number should ship with its definition attached: same session, same path, whole-file only.
I wouldn't throw out the 31 partial-read pairs though. Hash by byte range instead, and only count a repeat when a later range falls inside an earlier one and those bytes match. You get those pairs back without comparing a partial read to a whole one.
On compaction, agreed, that's the harder half. I think the fix is that a stub should never say "you have this". It should say "you read this at turn N, hash H". Then the harness checks whether turn N survived compaction, and if it didn't, it serves the full content again. The agent never has to trust its own memory.
One question on your 3 changed pairs: did the agent itself write to those files between the reads? If so those re-reads were necessary, and a write-aware filter could split "changed" into "changed by us" and "changed under us".
We key on the symbol, not the file. The ID is path::qualified.name#kind, and each symbol has its own content hash. So we can check a re-read against that symbol's bytes instead of hashing the whole file. Gets around that partial-read problem, too.
On repeats, we tell rather than withhold. Fetch the same symbol twice in a session and you'll still get the source, along with a note saying the agent already has it. Mostly because of the compaction issue you both mentioned. A one-line stub is fine IF something outside the agent can verify that the earlier read survived.
Renames are where it gets hairy. Move the file or rename the function and you've got a new ID. Memo starts over. Matching on the content hash might get you through a pure rename, but rename it AND edit the body and you're back where you started.
Haven't found a particularly elegant way around that one yet...
-jjg / jCodeMunch
Direct answer: all three changed pairs were changed by us. One was a draft rewritten by a shell heredoc, the other two were the config file, once written and once edited. So your write-aware filter splits 3 and 0, and every one of those re-reads was necessary.
Then I checked the five identical ones the same way, and found something worse than the split.
Five of the thirteen clean pairs were image reads. The read tool doesn't return pixels. It stores the same 23-character string every time, "Image read successfully". Thirty-three of the 222 reads in the corpus are that string. And in each of those five pairs, the shell call sitting between the two reads was a re-render of that exact chart. So a dead probe was in the identical bucket, holding five re-reads that weren't identical and weren't waste. The answer was equal because the instrument couldn't say anything else.
Text pairs only: eight. Five identical, three changed. 62.5%, not 76.9%.
So I ran your byte-range rule. The read tool takes offset and limit in lines, not bytes, so I compared overlapping line ranges instead. Fifteen partial pairs overlap an earlier read, and thirteen of the fifteen differ inside the overlap. The exclusion had been dropping mostly the reads that were necessary. Across all comparable pairs, twenty-three, seven repeat: 30.4%.
That one is a floor, because line numbers shift when text is inserted above them, so a moved block reads as changed. Bytes wouldn't have that problem. Nothing on this box records bytes.
The same 222 reads have now produced 22.7, 76.9, 62.5 and 30.4. Nothing changed but the definition. That's the part I keep getting wrong, and it's why your first sentence is the one that matters. A waste number should ship with its definition attached. Mine shipped with four.