Skip to content

Python: feat(core): add tool concurrency groups and sequential execution order - #7881

Open
pratik wayase (PratikWayase) wants to merge 5 commits into
microsoft:mainfrom
PratikWayase:feat/serialize-same-message-function-calls
Open

Python: feat(core): add tool concurrency groups and sequential execution order#7881
pratik wayase (PratikWayase) wants to merge 5 commits into
microsoft:mainfrom
PratikWayase:feat/serialize-same-message-function-calls

Conversation

@PratikWayase

Copy link
Copy Markdown
Contributor

Motivation & Context

Currently, the framework executes all tool calls requested in a single assistant message concurrently. While this is a great default for independent calls (like parallel document lookups), models routinely emit dependent calls in one batch. Because the tool author has no way to serialize these calls, dependent reads race the still-running writes, leading to "not found" errors and contradictory agent states.

This PR closes that gap by providing declarative, framework-level control over tool execution order, preventing stateful tool race conditions without relying on fragile, tool-side asyncio.Lock workarounds.

Fixes #7386

Description & Review Guide

What are the major changes?

  • Per-Tool Serialization Group: Added an optional concurrency_group: str parameter to FunctionTool and the @tool decorator. Tools sharing the same concurrency_group execute sequentially in call order within a message batch, while ungrouped tools remain fully concurrent.
  • Run-Level Sequential Execution: Added tool_execution_order: Literal["parallel", "sequential"] to _ChatOptionsBase and FunctionInvocationConfiguration. Setting this to "sequential" forces all tool calls in a batch to execute one-by-one.
  • API Wiring: Wired the user-facing tool_execution_order chat option through the FunctionInvocationLayer down to the execution engine so the setting actually takes effect at runtime.
  • Serialization & Docs: Updated FunctionTool.to_dict() to ensure concurrency_group survives serialization, and added docstrings documenting the ordering guarantee.
  • Cancellation Safety: Integrated main's task.cancel() + return_exceptions=True pattern at the group-level gather to preserve fail-closed middleware behavior.

What is the impact of these changes?

This is fully backward compatible. The default behavior remains "parallel" with no concurrency_group set, ensuring existing agents behave exactly as before. It provides tool authors a safe, declarative way to handle stateful dependencies.

What do you want reviewers to focus on?

Please review the grouping algorithm in _try_execute_function_call_groups (_tools.py). Specifically, verify that:

  1. The ordered_results array correctly maps indices to ensure results are returned in the exact order the model requested them.
  2. contextvars.copy_context() is still applied correctly per-call inside _execute_group to preserve agent span observability.
  3. The cancellation handler correctly wraps the group-level asyncio.gather (not individual call tasks).

Note: This is a rebased replacement for the previously closed PR #7523. All prior review feedback has been addressed and verified post-rebase.

Related Issue

Fixes #7386

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue.
  • This is not a breaking change.

Copilot AI balanced review requested due to automatic review settings August 26, 2026 10:49
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Aug 26, 2026
@github-actions github-actions Bot changed the title feat(core): add tool concurrency groups and sequential execution order Python: feat(core): add tool concurrency groups and sequential execution order Aug 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chetantoshniwal

Copy link
Copy Markdown
Contributor

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (5 commit(s)): e3efb1e57d81, b8b1f034fbdc, 7783ec2d9c37, fb4cb4f75eb1, e55413014eb4
Model: gpt-5.6-sol

Overview

The PR adds per-tool concurrency groups and a run-level sequential mode while preserving result order, per-call context propagation, and cancellation of in-flight group tasks. The new grouping loop has gaps around fail-closed middleware, middleware-requested termination, and approval replay ordering, and its configuration/key handling can silently violate the requested execution policy. These issues can start side-effecting calls after a policy stop, reverse dependent operations, or unexpectedly serialize or parallelize a batch.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
5 verified findings remained after source verification (1 high, 4 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_tools.py

live_tools=live_tools,
),
)
res = await task

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When one group raises MiddlewareFailure, another group that has just completed a call can advance to its next queued tool before the outer gather() observes the failure and cancels the group tasks. That lets a side-effecting call start after a fail-closed middleware abort, contrary to the function-loop contract that no further tool call starts. Please propagate a batch-wide abort signal at individual-call completion and check it before dequeuing each subsequent call.

live_tools=live_tools,
),
)
res = await task

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_execute_single_function_call returns should_terminate=True for MiddlewareTermination, but this loop records the tuple and immediately starts the next call in the same group. With sequential execution, a policy middleware can request approval or termination and a later side-effecting tool still runs before the flag is checked after the entire batch. Please stop dequeuing calls as soon as a result requests termination and prevent other groups from starting additional queued work.

ordered_results: list[tuple[list[Content], bool] | None] = [None] * len(function_calls)

async def _execute_group(indices: list[int]) -> None:
for idx in indices:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes function_calls remains in model-request order, but approval resume collects explicit responses before appending the previously hidden safe siblings. A batch emitted as [write, approval-required read] can therefore resume as [read, write], and this serial loop executes the dependency backward. Please preserve each call's original batch ordinal through approval storage and merge resumed calls by that ordinal before grouping.

) -> _FunctionExecutionBatch:

run_config = cast("FunctionInvocationConfiguration", dict(config) if config else {})
if custom_args and "tool_execution_order" in custom_args:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assignment runs after the invocation layer has applied the explicit per-run option, so function_invocation_kwargs["tool_execution_order"] silently overwrites options["tool_execution_order"] despite the preceding log saying the explicit option wins. It also lets an ordinary injected tool argument change framework scheduling. Please consume this reserved key when constructing run_config, apply it only when no explicit option was supplied, and avoid forwarding it as a tool runtime argument.

if tool is not None:
group_key = getattr(tool, "concurrency_group", None)
if group_key is None:
group_key = "__sequential_all__" if execution_order == "sequential" else f"__ungrouped_{idx}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synthetic keys share the same string namespace as user-provided concurrency_group values. For example, a named group __ungrouped_1 collides with the ungrouped call at index 1, unexpectedly serializing them and potentially deadlocking when the first waits for the second. Please use structurally distinct internal keys or object sentinels so user group names cannot collide with scheduler keys.

@eavanvalkenburg

Eduard van Valkenburg (eavanvalkenburg) commented Aug 27, 2026

Copy link
Copy Markdown
Member

pratik wayase (@PratikWayase) Thanks for working on this. I think we should narrow the scope of this PR to the batch-wide execution control and make two changes to that API:

  1. Mirror the .NET name: use allow_concurrent_invocation rather than tool_execution_order. This is a boolean policy in .NET (FunctionInvokingChatClient.AllowConcurrentInvocation), and using the same concept and name will make the SDKs easier to understand together.
  2. Define it in FunctionInvocationConfiguration, not chat options. allow_multiple_tool_calls is a provider request option controlling whether the model may emit multiple calls; allow_concurrent_invocation controls how the framework invokes calls after receiving them. The latter belongs to the function-invocation layer and should not need to be removed before forwarding options to the provider. Python should retain its existing parallel default, so the default value here should be True even though .NET defaults to sequential invocation.

I am not yet convinced that we have resolved enough of the design for per-tool concurrency_group controls to include them in this PR. There are open questions around MCP-discovered and provider-hosted tools, middleware failure and termination semantics, approval pause/replay ordering, cancellation guarantees, dynamic tools and MCP reloads, scheduler-key collisions, cross-run scope, and whether .NET and Python should expose the same capability simultaneously.

I opened #7914 to design the selective tool-level controls separately and tagged Roger Barreto (@rogerbarreto) there for input on whether we should address this in .NET and Python together. I suggest removing concurrency_group from this PR and using that issue to settle the cross-SDK API and semantics before implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants