Description
1. Summary
_try_execute_function_calls executes every function call of one assistant message concurrently — one task per call, gathered:
# agent_framework/_tools.py (~L1818, @ 83ba938d)
# Create each task inside a copied context so the active agent span is
# preserved for every parallel tool invocation.
execution_tasks = [
contextvars.copy_context().run(
asyncio.create_task,
invoke_with_termination_handling(function_call, seq_idx),
)
for seq_idx, function_call in enumerate(function_calls)
]
execution_results = await asyncio.gather(*execution_tasks)
Concurrency is a good default for independent calls (parallel doc lookups, independent reads). But models routinely emit dependent calls in one message — "write the file, then hand it to X" — because from the model's point of view the calls are sequential steps. Nothing in the framework lets a tool author say "this tool must not run concurrently with that one":
- no per-tool annotation (e.g. a serialization/concurrency group on
@tool / FunctionTool),
- no agent- or run-level option to execute a message's calls sequentially in call order,
- no pass-through of the provider-side
parallel_tool_calls=false style request option (which would only prevent the model from batching; the execution-side gap would remain for providers without it).
The result is a race the tool author cannot prevent from inside the tool: whether the dependent call sees its prerequisite's effect depends on task interleaving and store latency.
2. Observed failure (downstream, real model)
Advisor agent with two custom tools: exchange_write(file_name, content) writing into a blob-backed store, and share_files_with_subagent(agent, paths) which reads those paths back from the same store to copy them into a sub-agent's workspace. A model that batches tool calls (observed with Hy3 on an OpenAI-compatible endpoint) emitted, in one assistant message:
exchange_write("briefs/devops-aks-go-eventdriven.md", …)
share_files_with_subagent("DevOps Engineer", ["briefs/devops-aks-go-eventdriven.md"])
Both executed concurrently; the share's read raced the still-running blob upload and reported "not found in source store". The model then called a listing tool in its next round — which showed exactly that file, now landed. To the user this reads as a contradiction ("the tool says not found, ls says it's there"), and it recurs on every delegation with a call-batching model, while never reproducing with a model that emits one call per round.
3. Why the tool author can't fix this cleanly
- The dependent tool cannot "wait for the write" — it has no way to know a write is in flight, only that a read returned nothing.
- Prompt-side guidance ("call write, wait for its result, then share") helps but is advisory; batching models still batch.
- Our workaround — a shared per-thread
asyncio.Lock that every dependent tool acquires — works only because _try_execute_function_calls happens to create the batch's tasks in call order, so the write's task reaches the lock before the read's. That ordering is an implementation detail of the current gather block; nothing documents or guarantees it, and any change (e.g. executing via a task group with different scheduling, or shuffling for fairness) silently re-opens the race under every such workaround.
4. Proposal
Any one of these would close the gap; they compose:
- Per-tool serialization group (preferred): an optional
concurrency_group: str (or sequential: bool) on FunctionTool/@tool. Calls in the same group execute in call order relative to each other within a message batch; ungrouped tools keep today's full concurrency. This keeps parallel doc-lookups fast while making write→read tool families safe by declaration.
- Run/agent-level option:
function_call_execution="parallel" | "sequential" on the agent or per-run options, for agents whose tools are predominantly stateful.
- Documented ordering guarantee: at minimum, document that same-message calls are started in call order (the current behavior), so lock-based tool-side serialization has a contract to rely on.
- (Complementary) surface the provider
parallel_tool_calls=false request option where the backing API supports it, so callers can also prevent batching at the source.
5. Reproduction (minimal, stock components)
import asyncio
from agent_framework import InMemoryAgentFileStore, tool
store = InMemoryAgentFileStore()
@tool(name="slow_write", approval_mode="never_require")
async def slow_write(file_name: str, content: str) -> str:
await asyncio.sleep(0.05) # any real store yields before landing
await store.write(file_name, content)
return f"wrote {file_name}"
@tool(name="read_back", approval_mode="never_require")
async def read_back(file_name: str) -> str:
found = await store.read(file_name)
return found if found is not None else f"NOT FOUND: {file_name}"
Run an agent whose model emits both calls in one assistant message (any batching model; or drive _try_execute_function_calls directly with the two function_call contents).
read_back returns NOT FOUND even though slow_write succeeds in the same batch.
6. Environment
agent-framework @ 83ba938d (git install, 1.12.x line), Python 3.12, Linux.
- Provider-agnostic: the race is in framework-side execution, not in any chat client.
Code Sample
Language/SDK
Both
Description
1. Summary
_try_execute_function_callsexecutes every function call of one assistant message concurrently — one task per call, gathered:Concurrency is a good default for independent calls (parallel doc lookups, independent reads). But models routinely emit dependent calls in one message — "write the file, then hand it to X" — because from the model's point of view the calls are sequential steps. Nothing in the framework lets a tool author say "this tool must not run concurrently with that one":
@tool/FunctionTool),parallel_tool_calls=falsestyle request option (which would only prevent the model from batching; the execution-side gap would remain for providers without it).The result is a race the tool author cannot prevent from inside the tool: whether the dependent call sees its prerequisite's effect depends on task interleaving and store latency.
2. Observed failure (downstream, real model)
Advisor agent with two custom tools:
exchange_write(file_name, content)writing into a blob-backed store, andshare_files_with_subagent(agent, paths)which reads those paths back from the same store to copy them into a sub-agent's workspace. A model that batches tool calls (observed with Hy3 on an OpenAI-compatible endpoint) emitted, in one assistant message:exchange_write("briefs/devops-aks-go-eventdriven.md", …)share_files_with_subagent("DevOps Engineer", ["briefs/devops-aks-go-eventdriven.md"])Both executed concurrently; the share's read raced the still-running blob upload and reported "not found in source store". The model then called a listing tool in its next round — which showed exactly that file, now landed. To the user this reads as a contradiction ("the tool says not found,
lssays it's there"), and it recurs on every delegation with a call-batching model, while never reproducing with a model that emits one call per round.3. Why the tool author can't fix this cleanly
asyncio.Lockthat every dependent tool acquires — works only because_try_execute_function_callshappens to create the batch's tasks in call order, so the write's task reaches the lock before the read's. That ordering is an implementation detail of the current gather block; nothing documents or guarantees it, and any change (e.g. executing via a task group with different scheduling, or shuffling for fairness) silently re-opens the race under every such workaround.4. Proposal
Any one of these would close the gap; they compose:
concurrency_group: str(orsequential: bool) onFunctionTool/@tool. Calls in the same group execute in call order relative to each other within a message batch; ungrouped tools keep today's full concurrency. This keeps parallel doc-lookups fast while making write→read tool families safe by declaration.function_call_execution="parallel" | "sequential"on the agent or per-run options, for agents whose tools are predominantly stateful.parallel_tool_calls=falserequest option where the backing API supports it, so callers can also prevent batching at the source.5. Reproduction (minimal, stock components)
Run an agent whose model emits both calls in one assistant message (any batching model; or drive
_try_execute_function_callsdirectly with the twofunction_callcontents).read_backreturnsNOT FOUNDeven thoughslow_writesucceeds in the same batch.6. Environment
agent-framework@83ba938d(git install, 1.12.x line), Python 3.12, Linux.Code Sample
Language/SDK
Both