Reordering your tools busts your cache. Here is why.
Tool definitions serialize ahead of the system prompt inside the cacheable span — so one changed tool, or the same tools in a different order, invalidates everything behind them.
Everyone who sets up prompt caching thinks about the system prompt. Almost nobody thinks about the tool array, and the tool array comes first.
On Anthropic's API, the cacheable prefix is laid down in a fixed order: tools, then system, then messages. Your tool definitions — names, descriptions, JSON schemas, the lot — serialize into the span ahead of the system prompt you so carefully froze. Which means every cached byte sits downstream of the tools, and the cache being an exact byte match, one changed character anywhere in the tool JSON invalidates the entire span. The system prompt you kept byte-stable for weeks contributes nothing; it never gets compared, because the mismatch already happened in front of it.
Order is content
The part that catches people: the tools do not have to change to bust the cache. They only have to move.
Call 1: tools = [search, calculator, send_email]
Call 2: tools = [calculator, search, send_email]Same three tools, same definitions, different serialization — different bytes, different prefix, no cache read. And unlike a timestamp, which at least looks volatile, a reordered array looks completely innocent in any diff a human reads, because humans compare sets and caches compare strings.
Where does reordering come from, if nobody is editing tools? From code that builds the array dynamically. Tools registered by iterating a hash map whose ordering is not guaranteed. A plugin system that appends tools in load order, and load order varies. A conditional tool ("only include the refund tool for paying customers") that toggles per request. Two replicas of the same service built from dict iteration that happens to differ. Every one of these produces an agent whose tool set is stable and whose tool bytes are not.
How to see it in your own traffic
You cannot see this failure in a hit-rate number alone — a busted cache from tool reordering looks identical to a busted cache from a timestamp: writes on every call, reads near zero. To tell them apart you need to fingerprint the parts separately, which is what the proxy does on every Anthropic-shaped call. Three hashes per request:
prefix_hash the whole span: serialized system + serialized tools
system_hash the system prompt text alone
tools_order_hash the tool NAMES, in order, and nothing elseThe diagnosis falls out of which hash moves. system_hash stable but prefix_hash flapping — the volatility is in the tools. tools_order_hash flapping too — it is the order, not the definitions. All three stable while hits stay at zero — look elsewhere: a prefix under the model minimum, or a breakpoint that is never being set at all. One agent's mystery we closed this way was two stable orderings alternating: the fleet ran two code paths that registered the same tools differently, and each half kept overwriting the other's cache entry — a pure write workload with a hit rate of almost exactly zero.
Fifteen lines of the same idea, if you want it without a proxy: hash JSON.stringify(tools) and the system prompt separately at your call site and log both. The FNV-1a we use is eight characters and dependency-free; anything deterministic works.
The fix is one sort, and one habit
Sort the array once, at the boundary where tools enter your request builder:
tools.sort((a, b) => a.name.localeCompare(b.name))Deterministic order makes reordering impossible by construction, and tool caching then reduces to the ordinary question of whether the definitions themselves are stable. That is the habit half: treat tool descriptions with the same byte-discipline as the system prompt. A "last synced" value interpolated into a tool description, a schema default that embeds an id, an enum built from a per-request list — each is a cache-buster sitting in the least-inspected part of the request. And a volatile value in a tool is strictly worse than one in the system prompt: a system-prompt timestamp can be split off, with everything above it still cached, but there is no splitting inside the tool block — tools sit at the very front, so nothing can be cached in front of them. The whole span goes uncached. When Vigil declines to inject for this reason it logs volatile_tools against the agent, and that reason code means caching is not degraded but off — the full story of how one noun in a tool description did exactly that is in the timestamp post.
One more ordering trap for the road: conditional tools. If a tool's presence varies per request, you effectively run two agents — with-tool and without-tool — each with its own cache entries, each warming its own prefix. Sometimes that is the right trade. It is at least a trade you should know you are making, and the two-agent view is exactly how a per-agent cache dashboard will render it, because by prompt-shape fingerprint, that is what it is.
What to do
Find where your tool array is built and check whether its order is deterministic — if it comes out of map iteration, plugin registration, or anything conditional, sort it by name before the request goes out. Then diff the serialized tools from two consecutive production requests of the same agent: if the bytes differ at all, you have found the bust, and it was never in your system prompt. Finally, grep your tool descriptions for anything interpolated per request or per deploy — a value that changes in a tool description does not degrade your caching, it switches it off.