Repository navigation
Description
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=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, 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.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:
- Per-tool serialization group (preferred): an optional
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. - 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=falserequest 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
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
[-][Feature]: No way to serialize (or order) same-message function calls — stateful tools with write→read dependencies race[/-][+]Python: [Feature]: No way to serialize (or order) same-message function calls — stateful tools with write→read dependencies race[/+]