Skip to content

Python: [Feature]: No way to serialize (or order) same-message function calls — stateful tools with write→read dependencies race #7386

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=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:

  1. exchange_write("briefs/devops-aks-go-eventdriven.md", …)
  2. 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:

  1. 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.
  2. Run/agent-level option: function_call_execution="parallel" | "sequential" on the agent or per-run options, for agents whose tools are predominantly stateful.
  3. 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.
  4. (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

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentpythonUsage: [Issues, PRs], Target: Python

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions