Two agents ran in parallel. One had orders to move twenty-two files of research notes from directory A to directory B. Its counterpart had orders to delete whatever was left in A once the move was done. Nothing enforced "once the move was done." The cleanup agent finished first, deleted the source files, and by the time the transfer agent got there, there was nothing left to move. Twenty-two files, roughly 7,400 lines, gone. No git repository. No trash folder. No backup. Unrecoverable is the reporter's own word for it, in a Claude Code GitHub issue filed in April 2026.
The postmortem the reporter wrote is precise about the cause: "The Agent tool allows spawning parallel agents with no dependency awareness... there is no mechanism to enforce ordering." Their requested fix is a parameter - depends_on: [agent_id] - so agents can be sequenced when one genuinely needs to wait for another.
That is the shape of this article's subject, at the coarsest grain there is: two operations dispatched together, one needing a fact the other had not yet produced. This piece is about the same defect one level down, where it decides something narrower and more common: what value a single tool call's argument gets when that argument depends on a sibling call in the same batch. The failure mode is smaller in scope and larger in frequency, because it fires whenever a dependency actually exists between two calls and nobody checked for it before batching them.
What a round of parallel tool calls actually promises, and what it cannot
A "round," in the vocabulary this series has been using since Part 1, is one generation pass in which a tool-using LLM emits every call it intends to make right now. Anthropic's own documentation states plainly what a round contains and does not prescribe: "the response has a stop_reason of tool_use and can contain several tool_use blocks in a single assistant turn. How you run those calls is your decision. The API doesn't prescribe an execution order." What to call and with what arguments is the model's decision. How those calls actually run - in parallel, in sequence, or some mix - belongs to whoever dispatches them.
Here is the part that does not get said out loud often enough: every argument for every call in that round has to be a literal value by the time the round is emitted, because no new information enters the model's context during that turn. This is not a claim about the model's own generation mechanics - decoding one token after another is nothing but deferred output, and a model can in principle stream call B's arguments while call A is still executing. The constraint is about the protocol around it: a tool result only arrives in the next message, after the turn that emitted the calls has already ended. Whatever the model writes for call B, it writes without call A's result in context, because by construction that result has not arrived yet. OpenAI's function-calling guide states the same mechanic from a different angle, laying out the loop as five fixed steps: request with tools, receive a tool call, execute it, send the result back, then get a final response. Step 4 is a second request. There is no partial step 2 where the model asks to see step 3's answer before finishing step 2's own output. Google's Gemini documentation draws the identical line by giving the two situations different names entirely: "parallel function calling" for when you "call multiple functions at once when they are independent," and "compositional function calling" for chaining calls where a later one needs an earlier one's result - and the compositional case, verified against the current API, is not a single generation pass at all. It is a loop: the model emits a call, the caller executes it, the result comes back, and only then does the model emit the next call.
So the precise claim is this: at the moment a round is generated, only the facts already in context exist for the model to use. A call's argument can only be a literal drawn from what is already known, or a value the model invents because the schema requires something be there. Nothing in between. If call B's correct argument is a fact that only call A's result can supply, and A is a sibling in the same round rather than something that already ran, B's argument cannot be that fact - not because the model failed to notice the dependency, but because the fact does not exist in context at the point the round is generated. One honest exception is worth naming rather than hiding: server-side tools (Anthropic's hosted web search, OpenAI's built-in tools) can put a result into context within a single turn, because the provider runs them between the model's internal steps rather than waiting for the caller to round-trip a result back. That exception proves the point instead of denying it - it took a separate mechanism, built by the provider, to get a mid-turn result at all. The ordinary tool-call protocol this article is about does not have one.
This is not the compositionality gap, the retitled "Two-Hop Curse" work, or Composition Collapse. Those three lines of research all study whether a model can join two facts it already has - in its weights or already sitting in its own context - into a correct final answer. Press et al. define the compositionality gap as "how often models can correctly answer all sub-problems but not generate the overall solution," with both sub-answers already available to the model when it fails to combine them. This article's claim sits one layer below that: it is about what happens when one of the two facts is not available yet, because of how the round is structured, not because the model cannot join facts it holds. That is an architecture-selection question, not a model-capability one.
The wrong way: a round that fills in what it does not have
Picture a support-triage system with two tools: find_user, which looks up an account by email and returns a user ID, and get_orders, which returns a customer's order history given that ID. A user writes in asking about a delayed order. The obvious move looks like a single efficient round:
from dataclasses import dataclass@dataclass(frozen=True)class ToolCall: name: str arguments: dictdef plan_round(user_email: str) -> list[ToolCall]: """One generation pass. Every call emitted before either result is in context.""" return [ ToolCall("find_user", {"email": user_email}), ToolCall("get_orders", {"user_id": "usr_4471"}), ]>>> plan_round("dana@example.com")[ToolCall(name='find_user', arguments={'email': 'dana@example.com'}), ToolCall(name='get_orders', arguments={'user_id': 'usr_4471'})]Read that second argument again. "usr_4471" is not a value the model retrieved. find_user has not run yet - it is sitting in the same list, waiting for the harness to dispatch it, same as get_orders. The model generated a user ID that looks exactly as confident as a real one, for a user it has not looked up. If the harness executes both calls as written, get_orders returns either an error, an empty result, or - worse - somebody else's order history, because usr_4471 is a syntactically valid ID that happens to belong to whichever account was luckiest.
I could not find a reported case of a model fabricating a specific argument value for exactly this reason - a call in the same round guessing a fact only a sibling's result could supply. What is reported, repeatedly, is the coarser sibling of it: two calls dispatched together where the second call's correctness - and its arguments - depended on the first having already happened. A developer on OpenAI's community forum reported one in January 2025: asking a model to write a C program and then compile it produced two tool calls issued together, write and compile, with no way for the harness to know the compile step needed the write step to land first. Their diagnosis is worth quoting because it is the whole article in one sentence: "There is no dependency between the functions, it is the model that assumes the side effects of these functions are ordered." The model is not confused about the order. It has correctly inferred that write comes before compile. It has no field in the tool-call schema to say so.
The same developer tried the fix that seems obvious - ask the model to reference the earlier call's index or ID explicitly - and reported what happened: "When I tried to give me the index or call id of dependencies it no longer ran them in parallel." The moment the protocol was given a way to express the dependency, the model stopped trying to satisfy both calls in one round at all. That single data point says more about where the defect lives than a paragraph of argument could: the model already knows the dependency exists. What it lacked was a slot to put that knowledge in.
Argument Obligation: the schema asks first and listens never
Part 2 of this series named Answer Obligation: the unstated requirement that a decision path return an answer for every input it receives, inherited from the interface rather than chosen by anyone. A router's signature says it returns a decision, the caller has no branch for anything else, and the router invents one when its rules run out.
Every tool schema has a close cousin of that shape, one layer down, at the level of a single argument rather than a whole decision. A JSON Schema tool definition declares its arguments under a required list:
{ "name": "get_orders", "input_schema": { "type": "object", "properties": { "user_id": { "type": "string" } }, "required": ["user_id"] }}required: ["user_id"] says: any call to get_orders in this round carries a user_id, full stop. It says nothing about whether that value has to be known yet, because the schema was never asked that question. So when a round needs get_orders and the fact it needs is still inside a sibling call that has not returned, the schema does not refuse the call and does not leave the field blank. It obligates a value, and the model supplies one, guessed or not, because supplying one is the only shape the schema accepts.
Every orchestration system computing has built over the last thirty years already has a slot for this. Apache Airflow's scheduler will not start a downstream task until its upstream parents have succeeded, under its default all_success trigger rule - it refuses, rather than filling in a placeholder value and running anyway. (Airflow also ships all_done, one_failed, and none_failed for when a team wants different behavior, which only underscores the point: refusal is a choice Airflow's model makes room for. Tool-call schemas do not have the equivalent choice to make.) No tool-calling protocol in production today has that refusal, in any of its forms. A round does not say "not yet." It says something.
Call this Argument Obligation: the design requirement, inherited from the tool-call schema rather than chosen by the model, that every declared parameter in a round receives a literal value in that same generation pass, whether or not the fact behind it has arrived yet. Almost nobody decides this is how it should work. It falls out of writing required fields the ordinary way, closely - though not exactly - as Answer Obligation falls out of writing a function that returns one type.
The analogy is close and worth being precise about where it stops, because the gap is the useful part. Answer Obligation has no escape: a function typed to return one thing has no code path that returns nothing, on any input. Argument Obligation has one - the model can simply decline to batch the two calls - which is exactly why Anthropic can ship a system-prompt request ("only batch tool calls that are independent of each other") as a partial mitigation, and nobody can ship an equivalent system-prompt request for a router with a default branch. That difference also tells you how much to trust the mitigation. It is a request the model can decline to honor, not a type the schema enforces, and it fails the same way Part 2 said requests fail: silently, on the call where it mattered.
The naming has an older cousin worth crediting rather than pretending past. Thomas Green and Marian Petre's Cognitive Dimensions of Notations framework, from human-computer interaction research in the 1990s, names premature commitment: a notation that forces a decision before the information needed to make it correctly is available, with "being forced to declare identifiers too soon" as one of their own examples. Argument Obligation is that dimension's instance inside a tool-call schema. The schema is a notation. Its required list is the premature commitment. Nothing about naming it here claims the underlying idea is new; what is new is locating it at the exact point in an LLM architecture where it decides whether you need one round or two.
Mechanical dependency vs. reasoning dependency: only one forces a new round
Not every case where B needs something from A is the same case, and collapsing them is where my own first pass at this argument went wrong. I had one bucket - "B needs a fact from A" - and Anthropic's own documentation is where I found out that bucket hides two different problems with two different fixes.
Mechanical data dependency. B's argument, or a well-defined way of computing it, can be written down in advance - a field sitting inside A's future result, a formula over that field, or an ordinary conditional whose test does not itself require interpreting what A's result means ("if find_user returned an account, call get_orders"). The model does not need to exercise judgment about A's output to use it; it only needs the value, or a fixed rule for deriving one, to exist. This is escapable without a second round at all, because the model can express the dependency as a reference or as code instead of as a guessed value.
Reasoning dependency. B cannot be planned - which tool to call, whether to call one at all, what shape its arguments should take - because the decision requires interpreting what A's result means, past reading a field out of it or applying a fixed rule to it. There is no reference and no snippet of code you could write in advance, because what is missing is judgment, not data. This is the case that genuinely cannot be resolved inside one round, at any protocol design, because resolving it requires a generation pass with A's result already in context, and a round that has not run A yet cannot supply that.
The dividing line is not "does B's argument need a field of A's result" - both kinds of dependency can involve that. It is whether producing the argument needs the model to judge what the result means, or only to read or compute from it. "If the account exists, call get_orders" is a rule you can state before you have run anything, so it is mechanical, however many conditionals it takes to write. "Does this look like the kind of complaint that needs a refund tool instead of an order-status tool" is not a rule you can state in advance - it needs the result read and interpreted - so it is reasoning, and no amount of code sophistication moves it into the other bucket.
Anthropic's own documentation for its newer code-execution-based tool calling mode draws this exact line when describing where that mode does not help: "strictly sequential workflows where each call depends on Claude reasoning over the previous result," because "the script cannot skip the model round-trip in that case." That sentence is the mechanical/reasoning boundary, stated by the people who build the protocol, in the middle of explaining a feature meant to route around the mechanical case - and it is also why "loops and conditionals" belong on the mechanical side despite sounding like decisions: a conditional the model can write in advance is a rule, not a judgment.
flowchart TD
R["Round: every argument<br/>must be generated now"] --> Q1{"Does call B need a value<br/>only call A's result supplies?"}
Q1 -->|"No"| L4["One parallel round.<br/>Level 4 - nothing to defer."]
Q1 -->|"Yes"| Q2{"Can you write down how to get it<br/>now - field, formula, or a plain<br/>conditional? Or does it need<br/>judgment about what A means?"}
Q2 -->|"Mechanical -<br/>writable in advance"| D["Defer the binding.<br/>Placeholder ($1) or code.<br/>Still one round."]
Q2 -->|"Reasoning -<br/>needs judgment"| S["Split the round.<br/>Level 5 - reissue B once<br/>A's result is in context."]
style R fill:#FFD93D,color:#2C2C2A
style Q1 fill:#7B68EE,color:#FFFFFF
style Q2 fill:#7B68EE,color:#FFFFFF
style L4 fill:#6BCF7F,color:#2C2C2A
style D fill:#98D8C8,color:#2C2C2A
style S fill:#4A90E2,color:#FFFFFF
That diagram is the whole argument compressed into two questions. The first question is the one every tool-calling protocol currently skips, which is why the failure is common. The second question is the one that decides whether skipping the first one costs you a placeholder or a whole extra round trip - and it is a question about whether a rule exists yet, not about how complicated the rule is.
The dependency test, and who actually said it
The test itself is not owned by anyone in particular, and the clearest phrasing I found for it is on an unattributed explainer site, ai-tldr.dev, updated 2026-06-13: "The dependency test is simple: can you write down the arguments for call B before call A returns? If yes, the calls are independent and can run in parallel. If B's arguments contain a value you'll only know after A finishes, they must run in order." Weigh that as a secondary source rather than an authority, since the site carries no named author.
The authoritative version of the same rule comes from Anthropic's own troubleshooting documentation, under a section heading that names the failure directly: "Calls in a batch appear to depend on each other." Its guidance: "If you run in parallel and a call fails because its prerequisite hadn't completed, return is_error: true with the natural error message. Claude will reissue the call on the next turn. To reduce dependent calls appearing together, add this to your system prompt: 'Only batch tool calls that are independent of each other.'" Read closely, that is Anthropic documenting the fix for reasoning dependencies - detect the failure, let the model retry as a second round - inside its own product's help text, without naming the mechanism that makes the failure predictable in the first place.
A separate and genuinely distinct problem sometimes gets folded into this one, and it deserves its own name rather than absorption. Tian Pan's 2026 piece on parallel tool calls is about coupling between tool implementations at execution time - two tools quietly sharing a context variable, or racing to read-modify-write the same resource - not about whether an argument's value exists yet at generation time. Pan's own framing: "Tool A silently reads from a shared context variable that tool B is supposed to have populated... A runs before B populates the context, reads stale or empty data, and returns a result that looks valid but is computed on the wrong input." That is a real failure mode, and a harness should audit for it. It is not this article's failure mode. This one lives entirely inside the model's own turn, before either tool has executed at all; Pan's lives entirely inside the execution layer, after the model has already finished generating. Two audits, two different places to look, and treating them as one leaves half of each unchecked.
The right way: three ways to stop guessing
None of the three fixes below make the model smarter. All three change what the round is allowed to ask of it.
Split the round - the Level 5 answer. Detect the failure and let it recur as a fresh generation pass with the missing fact now in context. This is what Anthropic's is_error guidance already does, and what a harness enforces directly by disabling parallel dispatch for calls flagged as dependent:
from dataclasses import dataclass@dataclass(frozen=True)class ToolResult: name: str ok: bool user_id: str | None error: str | None = None@dataclass(frozen=True)class Deferred: """No call could be planned this round, and why - not an empty list a caller might confuse with 'nothing needed to happen'.""" reason: strdef round_one(user_email: str) -> list[ToolCall]: """Only the calls whose arguments are fully known right now.""" return [ToolCall("find_user", {"email": user_email})]def round_two(prior: ToolResult) -> list[ToolCall] | Deferred: """Dispatched only once round_one's result is in the model's context.""" if not prior.ok: return Deferred(f"find_user failed: {prior.error}") return [ToolCall("get_orders", {"user_id": prior.user_id})]>>> round_one("dana@example.com")[ToolCall(name='find_user', arguments={'email': 'dana@example.com'})]>>> found = ToolResult("find_user", ok=True, user_id="usr_8823")>>> round_two(found)[ToolCall(name='get_orders', arguments={'user_id': 'usr_8823'})]>>> missing = ToolResult("find_user", ok=False, user_id=None, error="no such account")>>> round_two(missing)Deferred(reason='find_user failed: no such account')usr_8823 is a real value here, read out of prior.user_id after find_user actually ran - not typed by the model into an argument slot before the lookup happened. And a failed lookup returns a labelled Deferred, not an empty list a caller could mistake for "nothing needed to happen" - the same move Part 2 made when it gave refusal its own return type instead of a sentinel value. This is the one genuine escape for a reasoning dependency, because it is the only option that gives the model a fresh generation pass with A's result actually present. It is also the option with a real, unavoidable cost: another model call, another round trip, and the latency of waiting for it. Splitting the round is correct for a reasoning dependency and wasted effort for a mechanical one, which is exactly why the next two options exist.
Defer the binding, not the round - the LLMCompiler answer. Kim et al.'s 2024 LLMCompiler, published at the International Conference on Machine Learning, lets the model plan the entire dependency graph in one pass by emitting a placeholder instead of a value: "If a task is dependent on a preceding task, it incorporates a placeholder variable, such as $1 in Task 3." The planner still emits the whole graph in a single generation pass. It just stops pretending to know a fact it does not have, and writes down a reference to where that fact will be instead:
from dataclasses import dataclass@dataclass(frozen=True)class PlannedCall: name: str arguments: dict # a value, or a "$N.field" reference into an earlier call's resultdef plan_round_with_placeholders(user_email: str) -> list[PlannedCall]: """One generation pass, same as before - but the second call defers its binding instead of guessing a value for a fact it does not have yet.""" return [ PlannedCall("find_user", {"email": user_email}), PlannedCall("get_orders", {"user_id": "$1.user_id"}), ]>>> plan_round_with_placeholders("dana@example.com")[PlannedCall(name='find_user', arguments={'email': 'dana@example.com'}), PlannedCall(name='get_orders', arguments={'user_id': '$1.user_id'})]$1.user_id is not a guess and not a literal - it is a name for a value that will exist once call 1 returns. The harness resolves $1.user_id once find_user actually returns, and only then dispatches get_orders. No second model call, no guessed ID, and the calls still get planned together. LLMCompiler reports up to a 3.7x latency improvement, 6.7x cost reduction, and an accuracy improvement of up to roughly 9 percent over a sequential ReAct baseline on their benchmark tasks - the placeholder is not merely safer, it is also the version that does not throw away the efficiency a single round bought in the first place. This is the correct fix for a mechanical dependency and the wrong one for a reasoning dependency, because a placeholder can stand in for a field the model already knows the shape of, and it cannot stand in for a decision the model has not made yet. It has its own limit worth naming: a static $N graph can reference a field, but it cannot express a branch. "If the account exists, call get_orders" is mechanical by this article's own definition, and a placeholder scheme has no way to write it down. That case needs the next fix, not this one.
Skip the schema entirely - the code-execution answer. Anthropic's programmatic tool calling, released in November 2025, lets the model write ordinary code instead of emitting a flat list of calls: "Chained calls, loops, and conditionals are ordinary Python control flow instead of a series of model round trips."
user = find_user(email=user_email)orders = get_orders(user_id=user["user_id"])Here the dependency was never a schema problem to begin with, because user["user_id"] is not obligated to exist until the line above it has actually run - Python's own execution order enforces that for free. Anthropic reports roughly 38 percent fewer billed input tokens with no accuracy change on a 75-tool internal benchmark, and a further, separately measured 20 to 40 percent token reduction on production traffic carrying 10 to 49 tool definitions per request. Their own caveat matters as much as the number: this mode is a poor fit precisely for "strictly sequential workflows where each call depends on Claude reasoning over the previous result," because the code can sequence execution but cannot sequence the model's own thinking about what to write next - and on exactly that kind of workflow, their own benchmark found it loses to plain round-splitting: "programmatic tool calling left scores unchanged and cost roughly 8% more. Sequential single-call workflows do not benefit." That is Anthropic's own numbers saying the split-round answer wins the one comparison where the workload is strictly sequential - the 38 percent and 20-to-40-percent figures above are separate measurements, on broader benchmarks with more parallel structure to exploit, and neither contradicts this one. Mechanical dependency, including the conditional case a placeholder cannot express: code-as-action removes the problem outright, and cheaply. Reasoning dependency: it still needs the round split, because a script cannot think on the model's behalf, and paying for both a script and a round trip buys nothing.
To see what all three buy over doing nothing, compare against what a required schema field actually returns in a standard tool-calling protocol today, regardless of which of these three cases the round is actually facing:
def bind_argument_current_practice(fact_available_now: bool, literal_guess: str) -> str: """What a required schema field returns today - a bare literal, always, with no way for the caller to learn whether fact_available_now was true.""" return literal_guess>>> bind_argument_current_practice(fact_available_now=False, literal_guess="usr_4471")'usr_4471'>>> bind_argument_current_practice(fact_available_now=True, literal_guess="usr_8823")'usr_8823'Identical return values. This is Argument Obligation stated as code: the caller gets a string every time, and the type signature gives it no way to ask whether that string was known or invented. None of the three fixes above changes what the model is capable of computing. Each of them changes what the caller is allowed to get back:
from dataclasses import dataclass@dataclass(frozen=True)class Obligated: """What a protocol WITH a slot for this would return instead - a value, plus whether it was actually known when the round was generated.""" value: str guessed: bool>>> Obligated(value='usr_4471', guessed=True)Obligated(value='usr_4471', guessed=True)>>> Obligated(value='usr_8823', guessed=False)Obligated(value='usr_8823', guessed=False)The first is what round_one's guess should have been forced to admit. The second is what round_two's real lookup can honestly claim.
Obligated is not a proposal for a new wire format. It is what every fix in this article is trying to approximate from outside the protocol - a placeholder, a script, or a second round, each buying the caller a way to tell a known value from an invented one, without the schema itself ever having to say so.
What nobody has measured
The honest gap in this argument is that no benchmark isolates the accuracy cost of this specific failure. ToolScan, a 2025 taxonomy of tool-calling errors built from real model traces, catalogs seven categories: insufficient API calls, incorrect argument value, incorrect argument name, incorrect argument type, repeated API calls, incorrect function name, and invalid format. One of them, "Incorrect Argument Value," is reported as among the most common in at least some of the models ToolScan evaluated, though I could not confirm from the paper alone that it is the single most frequent category across every model tested. What is unambiguous either way: nothing in the taxonomy separates a wrong value invented under Argument Obligation from a wrong value produced by an ordinary mistake. The field's own error catalog has no bucket for "guessed because the round obligated one." That absence is itself worth reporting rather than papering over: the Berkeley Function-Calling Leaderboard scores parallel and multi-turn calling as separate categories but does not publish a comparison isolating this exact failure, and I found no benchmark that does.
What exists instead is a small set of dated, specific incidents rather than a measured rate: the Claude Code data-loss report that opened this article, the OpenAI community thread describing write-then-compile, and a feature request on LangGraph's JavaScript package still open as of this writing, asking for exactly the field every DAG scheduler already has and no tool-calling protocol does - a way to mark one call as depending on another. The nearest request on the Strands Agents SDK did not stay open: Strands shipped it, splitting tool dispatch into a SequentialToolExecutor and a ConcurrentToolExecutor so a harness author can choose per-agent rather than accept whatever the framework's default concurrency happens to be. That the fix looks like "let the caller pick an executor" rather than "let the model declare a dependency" is itself informative - the field is patching around the missing slot in tool-call schemas from the harness side, because nobody has patched the schema.
How this connects to the rest of the ladder
Part 2 named Answer Obligation at the level of a whole decision: a router's return type promises an answer for every input, so it invents one when its rules do not reach. Part 3 named the Compounding Alibi: a number produced to justify a decision rather than to predict an outcome, which nobody scores afterwards because it was never offered as a prediction. Both of those are about a thing that looks resolved and is not. Argument Obligation is a cousin one layer further down, at the level of a single value inside a single call: the schema does not ask whether the fact exists, so the model cannot say no - though, as the escape clause above shows, it can at least decline to be asked.
Part 5 closed the series on the opposite direction of travel - the Induction Gap, where removing a layer requires evidence your logs structurally cannot contain, because the system has never run without it. Climbing and descending turn out to share a shape after all. Climbing from Level 4 to Level 5 is authorized by two tests, not one: does this argument need a fact that has not been generated yet, and does producing that argument need judgment about what the earlier result means, rather than a rule you could write down in advance? Only a "yes" to both forces the climb. Get both tests right, and the round you build matches the dependency you actually have, instead of the one your schema quietly assumed away.
What to check before you parallelize a round
-
Run the dependency test on paper before you run it in code. For every pair of calls you plan to dispatch together, ask: can I write down every argument for the second call without having seen the first call's result? If the honest answer is no, you have a dependency, and the round as planned will obligate a guess.
-
Classify what you found. Can you write down, right now, how to get the missing fact - a field, a formula, or a plain conditional whose test does not require interpreting what the result means? That is mechanical, however many steps it takes to write. Does answering the question require judgment about what the result means, with no rule you could state in advance? That is reasoning. Treating a reasoning dependency as mechanical (or the reverse) sends you to the wrong fix.
-
Match the fix to the classification. Mechanical and a straight field reference: a placeholder scheme will do. Mechanical but a branch or a transform: a placeholder cannot express it, so push the step into code the model writes instead. Reasoning: split the round and pay for the extra round trip, because there is no cheaper way to get the fact into context before the decision that needs it.
-
Add the guard Anthropic already tells you to add. "Only batch tool calls that are independent of each other," in the system prompt, plus
is_error: truehandling that lets a failed dependent call recur as its own round rather than silently returning garbage. -
Audit tool implementations separately from tool arguments. A shared-state race between two tools at execution time is Pan's problem, not this one. Checking one does not check the other.
-
When you add a new tool, ask what it obligates, beyond what it returns. Every
requiredfield in its schema is a promise your harness has to keep on the model's behalf. If the fact behind that field can legitimately be absent when the round is generated, the schema needs a way to say so - a placeholder convention, an optional field with a documented "unknown" sentinel, or a note that this argument is never safe to parallelize against. -
Correct sequencing is not authorization. Every fix above still routes a tenant-scoping identifier through a value the model supplied or helped resolve. If an argument scopes access to one tenant's data, it has to come from the authenticated session, never from the model - a correctly-sequenced call carrying an attacker-influenced
user_idleaks exactly as much as a guessed one did.
References
- Anthropic. Parallel tool use. Claude Platform Docs. https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use
- Anthropic. Programmatic tool calling. Claude Platform Docs. https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling
- OpenAI. Function calling. OpenAI API Docs. https://developers.openai.com/api/docs/guides/function-calling
- Google. Function calling with the Gemini API. Google AI for Developers. https://ai.google.dev/gemini-api/docs/function-calling
- Apache Airflow. Tasks. Airflow Documentation. https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/tasks.html
- Kim, S., Moon, S., Tabrizi, R., Lee, N., Mahoney, M. W., Keutzer, K. and Gholami, A. (2024). "An LLM Compiler for Parallel Function Calling." ICML 2024. arXiv:2312.04511. https://arxiv.org/abs/2312.04511
- Press, O. et al. (2023). "Measuring and Narrowing the Compositionality Gap in Language Models." Findings of EMNLP 2023. arXiv:2210.03350. https://arxiv.org/abs/2210.03350
- Balesni, M., Korbak, T. and Evans, O. (2025). "Lessons from Studying Two-Hop Latent Reasoning." arXiv:2411.16353. https://arxiv.org/abs/2411.16353
- Yang, S., Gribovskaya, E., Kassner, N., Geva, M. and Riedel, S. (2024). "Do Large Language Models Latently Perform Multi-Hop Reasoning?" ACL 2024. arXiv:2402.16837. https://arxiv.org/abs/2402.16837
- "Composition Collapse." (2026). arXiv:2605.26789. https://arxiv.org/abs/2605.26789
- Kokane, S., Zhu, M., Awalgaonkar, T. et al. (2025). "ToolScan: A Benchmark for Characterizing Errors in Tool-Use LLMs." arXiv:2411.13547. https://arxiv.org/abs/2411.13547
- Green, T. R. G. and Petre, M. (1996). "Usability Analysis of Visual Programming Environments: A 'Cognitive Dimensions' Framework." Journal of Visual Languages and Computing, 7(2):131-174.
- Green, T. R. G. and Blackwell, A. F. (2001). "Cognitive Dimensions of Notations: Design Tools for Cognitive Technology." https://www.cl.cam.ac.uk/~afb21/publications/CT2001.pdf
- anthropics/claude-code, GitHub issue #47005, "Agent tool: parallel agents with destructive operations cause data loss - needs sequencing guardrails" (2026-04-12). https://github.com/anthropics/claude-code/issues/47005
- OpenAI Developer Community. "Parallel tool calling where there is an ordering dependency" (2025-01-10). https://community.openai.com/t/parallel-tool-calling-where-there-is-an-ordering-dependency/1086995
- strands-agents/sdk-python, GitHub issue #614, "[FEATURE] Add ability to disable parallel tool calling" (opened 2025-08-05, resolved via
SequentialToolExecutor/ConcurrentToolExecutor, PR #658). https://github.com/strands-agents/sdk-python/issues/614 - langchain-ai/langgraphjs, GitHub issue #861, "ToolNode should support executing tools sequentially" (2025-02-12). https://github.com/langchain-ai/langgraphjs/issues/861
- Pan, T. (2026, April 10). "Parallel Tool Calls in LLM Agents: The Coupling Test You Didn't Know You Were Running." TianPan.co. https://tianpan.co/blog/2026-04-10-parallel-tool-calls-hidden-coupling
- "Parallel Tool Calls in AI Agents Explained." (updated 2026-06-13). AI/TLDR. https://ai-tldr.dev/learn/ai-agents/tool-use/parallel-tool-calls/
- Berkeley Function-Calling Leaderboard V3. "Multi-Turn & Multi-Step Function Calling" (2024-09-19, updated 2024-12-10). https://gorilla.cs.berkeley.edu/blogs/13_bfcl_v3_multi_turn.html
- Kumar, R. (2026). The 7 GenAI Architectures: A Field Guide to Choosing the Right AI System - Before You Overbuild It. Chapter 8, "Architecture 5 - Multi-Step Reasoning." https://7genai.ranjankumar.in/
Related Articles
- Descending: Why You Cannot Delete an Idle AI Agent Layer
- Why Your Default Branch and Your LLM Are the Same Architecture
- Five Stages at 95 Percent Is Not 77 Percent
- The 7 GenAI Architectures Every AI Engineer Should Know



