Chapter 2 of 6 · 15 min read
'Create folder Q3' and 'upload into Q3' arrive in one turn
On this page
Some pairs of tool calls are not merely unsafe to overlap; the second one has no meaning at all until the first has returned. Creating a folder and uploading a file into it is the plain case, and the dependency between them is invisible to every component that handles the calls. A tool definition — the name, description and argument schema an agent is given for one of your functions — describes that function alone, so there is no place in it to record that another function must run first. This chapter is about where that ordering can be written down instead, and what each of the three available places costs.
The request that produced it was ordinary: "make a folder called Q3 and put the three regional reports in it." The document agent's canary — a small suite of real user phrasings replayed on every deploy to catch behaviour that unit tests do not — ran that phrasing two hundred times against a staging tenant, and the outcomes sorted into three piles. In 127 runs everything worked. In 42 runs the first upload came back 404 parent_not_found and the model retried it, so the user got their folder and their files roughly two seconds later than expected. In 31 runs the tenant ended up with two folders named Q3, one holding three reports and one holding none, because the storage backend auto-creates missing parents by name and does not enforce uniqueness among siblings. Those three counts sum to the two hundred runs, so every run landed in one of them and nothing else happened.
The turn that produced all three outcomes was identical every time:
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "toolu_A", "name": "create_folder",
"input": {"parent": "/reports", "name": "Q3"}},
{"type": "tool_use", "id": "toolu_B", "name": "upload_file",
"input": {"path": "/reports/Q3/emea.pdf", "source_ref": "att_991"}},
{"type": "tool_use", "id": "toolu_C", "name": "upload_file",
"input": {"path": "/reports/Q3/amer.pdf", "source_ref": "att_992"}},
{"type": "tool_use", "id": "toolu_D", "name": "upload_file",
"input": {"path": "/reports/Q3/apac.pdf", "source_ref": "att_993"}}
]
}Which of the three piles a run landed in was decided by whether create_folder's round trip to the metadata service finished before the first upload_file resolved the parent path, a race of roughly 30 to 60 milliseconds against roughly 25 to 90. Those two ranges overlap across most of their span, which is why the outcome is a coin toss rather than a rare event: the create wins only when its draw comes in under the upload's draw, and there is no ordering in the system that makes that more likely than the reverse. Chapter 1's collision — two apply_credit calls that each read the same balance and then overwrote one another — was two calls that were each meaningful on their own and disagreed about a stored number. This one is different in kind: upload_file has no meaning at all until create_folder has returned, and no amount of atomicity inside either handler changes that.
Nothing in the schema has a slot for "after"
A tool definition is a name, a description, and a JSON Schema for that one call's inputs. JSON Schema can say path is a string matching a pattern, that source_ref is required, that retention_days is an integer between 1 and 3650. It has no keyword that refers to another tool, and the reason is worth following rather than accepting, because it explains why no amount of schema authoring will fix this. JSON Schema was designed to validate a single document held in hand, which means answering one question about it: is this object acceptable. Answering that requires nothing outside the document. A statement of the form "this call is acceptable only after that other call has returned" is a statement about a history of events, and there is nothing in a document validator that could observe a history, let alone constrain one.
The transport has the same shape one level up. The assistant message that carries the calls is a flat list of content blocks, and while the blocks do arrive in an order, that order is the order the model happened to write them in. Nothing in the protocol says the client must respect it, and a dispatcher built on gather explicitly does not.
The model, for its part, wrote them in the sensible order and would tell you, if asked, that the folder has to exist first. That belief is not expressible in the only channel it has. It emitted four blocks because batching is what a capable model does when a task has four steps it can see, and the alternative behaviour — one call per turn, waiting each time — is the behaviour everybody spent two years engineering away for latency reasons.
There is a version of this system where the bug never appears, and it is worth naming because a lot of teams are living in it without knowing. If your dispatcher executes blocks sequentially in list order, awaiting each before starting the next, the folder is created first and the uploads succeed. The dependency is satisfied by accident, held up by an implementation detail nobody wrote down, and it breaks the afternoon somebody switches to concurrent dispatch for the latency. It also breaks earlier than that, in a subtler way, if the model ever emits the uploads before the create, which happened in 3 of the 200 canary runs. A sequential dispatcher inherits the model's ordering wholesale, so it is only as reliable as the model's own decision about which block to write first, and that decision is made by a component that has been given no reason to treat it as load-bearing.
Walk the arrows in that diagram as a chain of causes, because the silent outcome is produced by a backend behaving helpfully rather than by anything failing. The upload is given a path rather than a handle, so before it can write bytes it has to turn /reports/Q3 into a folder the storage service can address. It asks the metadata service to resolve that path. The path does not exist yet, and the metadata service's policy for a missing parent is to create it rather than to refuse. Nothing at that point compares the new folder against a folder with the same name under the same parent, because siblings are not required to be unique. So a second folder called Q3 comes into existence, the upload writes into it, and both calls report success — one having created fld_7c2 and the other having filled fld_9a4. Every step in that chain is a component doing what it was built to do, and the accommodating step in the middle is the one that converts a loud failure into a quiet one.
The two failing piles are not equally bad, and the ranking is the opposite of what the error counts suggest. Read the counts as a signal about the system rather than as a score: 42 runs raised an error and 31 did not, so a dashboard tracking tool errors ranks the two-folder outcome as the healthier of the two. It is the worse one. The 42 runs that returned 404 parent_not_found are the good outcome, because the system said no, the model saw an error in the tool_result, and it retried the upload in the following turn, costing about two seconds and 1,400 extra tokens. The 31 runs that produced two folders returned success on every call, so the model reported the task complete, and the only observer who could notice was the user, later, wondering why the folder they opened was empty. A duplicate that reports success is the expensive shape of this bug, and it is the shape that a backend which is helpful about missing parents will hand you.
Putting the dependency where something can enforce it
Three encodings actually hold, and they hold in different places. The first collapses the pair into one tool, so there is nothing to order:
async def upload_to_folder(
parent: str, folder_name: str, source_refs: list[str]
) -> dict:
folder = await metadata.get_or_create_folder(parent, folder_name) # unique index on (parent_id, name)
uploaded = [await storage.put(folder.id, ref) for ref in source_refs]
return {"folder_id": folder.id, "uploaded": uploaded}The ordering is now a fact about a function body, which is the kind of fact that survives a scheduler change: the second line cannot begin until the first has produced a value, because it uses that value. The uniqueness of the folder is a database constraint on (parent_id, name) rather than a hope. That constraint is a closing identity, and it is precise about what it settles. What it licenses is that at most one folder with a given name exists under a given parent, no matter how many writers there are, including writers that know nothing about this code — so two concurrent invocations of this tool on the same name produce one folder and one IntegrityError that get_or_create_folder catches and turns into a fetch. What it leaves untouched is ordering: the index has no opinion about whether an upload happens before or after a create, and it would not have prevented a single one of the 42 404 runs. It removes duplicates, and duplicates are only one of the two failure piles.
What this encoding costs is tool surface: combine every dependent pair and you get a tool per user intent, descriptions that no longer fit in a reasonable system prompt, and a model choosing between fourteen near-identical upload variants. The rule that keeps it bounded is to combine only where the pair is one thing a user would ask for, which "make a folder and put these in it" plainly is.
The second encoding keeps the tools separate and makes the second one impossible to call early, by requiring an input that only the first call can produce:
{
"name": "upload_file",
"input_schema": {
"type": "object",
"properties": {
"folder_id": {"type": "string", "pattern": "^fld_[0-9A-HJKMNP-TV-Z]{26}$",
"description": "Returned by create_folder. Cannot be constructed from a folder name."},
"source_ref": {"type": "string"}
},
"required": ["folder_id", "source_ref"]
}
}The identifier is randomly generated and opaque, which is the entire mechanism, and the mechanism is worth taking apart because it converts a timing question into a possession question. If the handle were the path /reports/Q3, the model could fill it in from the user's sentence, since the sentence contains everything needed to construct it, and the race would come straight back. Because it is a 26-character ULID — a Universally Unique Lexicographically Sortable Identifier, which for this purpose is a random string with no derivable relationship to the folder's name — the model cannot construct it from anything it has been told. The only way to obtain one is to have read a tool_result from create_folder, and reading a tool_result requires the assistant turn to have ended and a new one to have begun. So the ordering is enforced by the shape of the conversation itself: possession of the identifier is evidence that the create already returned. A model under pressure will occasionally invent something schema-shaped, and the server answers that with a typed failure rather than a silent misdirect:
{"type": "tool_result", "tool_use_id": "toolu_B", "is_error": true,
"content": "unknown_folder: fld_01J0000000000000000000 does not exist in this tenant. Call create_folder first and use the folder_id it returns."}That message is doing two jobs, and the second one matters more than it looks: it fails closed, meaning that the case it cannot resolve is refused rather than guessed at, and it tells the model what to do next, so the recovery is a single extra turn instead of a retry loop. Guessable identifiers fail open, which is the difference between a 404 and an upload into a stranger's folder.
The third encoding leaves the schemas alone and constrains the dispatcher. Rather than the global disable_parallel_tool_use flag, which pays the sequential latency on every turn including the many that have no dependencies at all, the dispatcher can be given a small declaration of which tools mutate what:
CONFLICT_KEYS = {
"create_folder": lambda a: ("path", f"{a['parent']}/{a['name']}"),
"upload_file": lambda a: ("path", a["path"].rsplit("/", 1)[0]),
"list_folder": None, # read-only, never serialised
}
def partition(blocks):
"""Yield groups of blocks that may run concurrently; groups run in order."""
groups, seen = [], set()
for b in blocks:
key_fn = CONFLICT_KEYS.get(b.name)
key = key_fn(b.input) if key_fn else None
if key and key in seen:
groups.append([b]); seen = {key}
else:
if not groups: groups.append([])
groups[-1].append(b); seen.add(key) if key else None
return groupsThe two key functions are what make this work on the Q3 turn, and they are worth reading against the arguments in the assistant message above. create_folder is keyed on its parent joined to its name, which for that turn is /reports/Q3. upload_file is keyed on its path with the final segment removed, which for /reports/Q3/emea.pdf is also /reports/Q3. The two calls therefore produce the same key, the partition function sees the key already in seen, and it opens a new group — so the create runs in the first group and the uploads in a later one. Two calls whose conflict keys touch the same path land in different groups and run in sequence, while the four read-only lookups in the next turn still run together. It is coarser than the other two and it is the only one of the three you can apply to tools you do not own, which is why it is the answer for a third-party MCP server — Model Context Protocol, the standard by which somebody else's tools are exposed to your agent, schemas included — whose definitions you cannot change.
The cost of that approach is a table somebody has to maintain, and tables that describe other code drift away from it. A tool added in a hurry with no entry in CONFLICT_KEYS is treated as unconflicting and runs concurrently with everything, which is the failure mode of every allowlist that defaults to permissive. Inverting the default helps more than it hurts: an unknown tool is serialised until somebody declares it read-only, so the penalty for forgetting is latency rather than corruption, and latency shows up in a dashboard within a day. The key function is also only as good as its resolution — keying upload_file on the parent directory catches the case above and misses a rename that moves the whole subtree, so the declaration is a model of your conflicts rather than a derivation of them.
| Combined operation | Opaque handle | Dispatcher policy | |
|---|---|---|---|
| Enforced by | Function body plus DB constraint | Schema plus server-side lookup | Your dispatcher |
| On violation | Cannot occur | Typed is_error result, one extra turn | Calls serialised, no error |
| Latency cost | None; fewer round trips | One extra turn per dependency | Only on conflicting pairs |
| Tool surface | Grows with each combined pair | Unchanged | Unchanged |
| Works on tools you do not own | No | No | Yes |
| Covers a second concurrent run | Yes, via the constraint | Partly | No |
Read that table carefully down the last two rows rather than across the columns, because those two rows are where the three encodings genuinely disagree and the rest is a matter of taste. Two separate agent runs, each doing "make a folder called Q3", still collide, and only the unique index catches them — the dispatcher policy sees one run's blocks and knows nothing about the other run's, and the opaque handle only helps the run that has one. The gap being measured there is the difference between a rule enforced where the calls are scheduled and a rule enforced where the data lives, and only the second kind survives a caller you did not write.
Two other mechanisms that do not solve this
None of this is idempotency, which is the other thing people reach for. An idempotency key is a value a caller attaches to a request so that a server which sees the same key twice performs the effect once. On upload_file it would stop the same upload being applied twice after a retry, and it has nothing to say about an upload that resolved a parent path before that path existed. The two problems differ in what is being repeated: an idempotency key protects against the same operation happening more than once, and the Q3 failure is a single operation happening too early.
It is also not the ordering problem that a workflow engine solves. Topic 10.16 gave you a way to express that step B follows step A across process restarts, and that machinery is downstream of the decision being made here: the model has to name one call, get a result, and then name the next, or the two have to become one call. A durable engine will faithfully re-run whatever your dispatcher did, including starting both at once. Durability is a property of what survives a crash, and the dependency being broken here is broken on a run where nothing crashed.
There is a third case the three encodings genuinely do not reach, and it is the last row of the table read at its harshest. Two runs, started by different people, each emitting a well-ordered turn, still race each other on the same folder name. The combined operation survives that because its guarantee lives in the database. The other two do not, because their guarantees live in a schema and in a dispatcher, and both of those are per-run.
What the three encodings share is that they work on a dependency somebody could see. Both calls were in the same message, from the same run, and a person reading the trace can point at the arrow. The ordering was always a fact about the world — a file cannot live in a folder that does not exist — and the work of this chapter is moving that fact out of the model's head and into a function body, an identifier or a table, which are the three places in this stack where a machine will act on it.
The failures in the next chapter have no such arrow: two independent runs, started minutes apart by different users, each doing something entirely reasonable to the same record, colliding in a window measured in milliseconds that appears in production about one time in three hundred and never once on a developer's machine.
Agent state and concurrency — read the current version at https://daviddominguez.dev/learn/en/agent-state-and-concurrency/create-folder-q3-and-upload-into-q3-arrive-in-one-turn