Agent tool description flips to its subagent variant in the main conversation while in-process teammates are alive — tools_changed cache miss on every fleet phase boundary
Version: 2.1.225 (also observed 2.1.219-2.1.226 era; unpatched npm bundle used for
source reading; deliberate reproduction attempts ran on 2.1.225.184)
What happens
When a session spawns in-process agents (Agent tool / agent teams), the main
conversation's own requests begin rendering the subagent variant of the Agent
tool description — the one that says:
run_in_backgroundandnameare unavailable here — only synchronous subagents. The agent's final message is returned to you as the tool result; it is not shown to the user — relay what matters.
instead of the main-loop variant ("Subagents run in the background by default… Passrun_in_background: false…", "The agent's final report is not shown to the user…").
The main conversation keeps rendering the subagent variant for the entire duration of
the fleet phase, then reverts when the teammates are torn down. Because tools[] is
part of the cached prefix, each flip invalidates the whole prompt cache for that
conversation — the API's own diagnostics.cache_miss_reason reportstools_changed on exactly these turns.
The text is also simply wrong while it is displayed: named background agents work fine
from the main loop throughout the affected window.
Evidence
Single main conversation, one model (claude-fable-5) throughout, requests walked in
transcript order via each assistant record's requestId and matched to the logged
request bodies (OTEL_LOG_RAW_API_BODIES), same metadata.user_id throughout:
| run | count | window (UTC) |
|---|---|---|
| main-loop variant | 14 | 17:27:29 – 18:17:10 |
| subagent variant | 64 | 18:21:42 – 20:15:16 |
| main-loop variant | 14 | 20:18:02 – |
The subagent-variant run brackets exactly the interval in which in-process teammates
existed (first Agent spawn → team teardown).
Comparing one main request from each window, from the same conversation: system is
byte-identical, and of the 20 tools present in both, only Agent's description
differs.
Cost, measured across a 2-day window of one workstation's sessions: breaks whose
server-reported reason is tools_changed accounted for ~13.3M rebuilt input tokens in
a single bucket of the audit; every probed instance carried this exact signature
(tools[] diff = Agent description only, no tools added or removed).
Reproduction status
The evidence above is from natural sessions. Deliberate reproduction was attempted twice
and failed both times, under both models involved in the historical windows (once with the
main loop on claude-opus-5, once wire-verified claude-fable-5 on every request):
- teammate merely alive (named background agent; the harness classifies it
task_type: in_process_teammate, the exact type whose turn runner enters the store)
- a teammate-to-main
SendMessagedelivery waking an idle main loop - a
tools[]recomposition (ToolSearch schema load) executed inside the delivery-woken
turn, with the teammate still alive
All main-conversation requests stayed on the main-loop variant throughout (28/28 in the
second attempt, confirmed from logged request bodies).
One further observation from the second attempt narrows the mechanism: the teammate's own
requests also rendered the main-loop variant on every round. If in-process teammate turns
composed their requests inside the store's run() scope, teammate requests would carry the
subagent variant unconditionally. They do not, which matches the natural-session data
(main and teammates flip together, per process), and implies description composition
normally executes with the store unset in both directions. The observed multi-hour flipped
windows therefore look like a narrow race at composition time, not a steady-state
inheritance, which is consistent with it resisting on-demand reproduction.
The Agent description builder selects the variant with:
l = !KE() && !Xne()
KE()→ envCLAUDE_CODE_DISABLE_BACKGROUND_TASKSXne()→nhs.getStore() !== undefined, wherenhsis anasync_hooks
AsyncLocalStorage whose only run() entry wraps the in-process teammate turn
runner (task.type === "in_process_teammate")
So the variant is chosen from ambient async context at description-composition time
rather than from the identity of the conversation the request belongs to. When the
main loop's request composition executes on a continuation that originates inside a
teammate's nhs.run(...) scope — or is served a description composed there — the main
conversation gets the subagent text. (Per the reproduction-status section, composition
appears to run with the store unset in the common case for main loop and teammates alike;
the flipped windows are consistent with an occasional composition landing on a
teammate-rooted chain and its result then persisting for the whole process until
recomposed.)
Why it is worth fixing
- Every fleet-phase boundary costs a full prefix rebuild of the main conversation.
Agent-team and multi-agent workflows pay this twice per phase (in and out).
- The description asserts a capability restriction that does not apply to the
conversation reading it.
- The same
AsyncLocalStoragebacksagentId,agentName,teamName, color,
parentSessionId, plan-mode, task-list id, message attribution and the durable-cron
guard. If the leak is whole-turn rather than composition-scoped, those read the
teammate's identity from the main loop too. (We could not settle whole-turn vs
composition-scoped from request bodies alone; a durable-cron attempt from the main
loop while a teammate is alive would discriminate, since that guard consults the same
store.)
Suggested direction
Resolve the description from the conversation/session role captured when the request is
built (or pass the role explicitly into the description builder), rather than from
ambient AsyncLocalStorage state at composition time. If the leak is whole-turn,
re-rooting the main loop's turn execution in its own store context would fix this class
and the identity readers at once.