Skip to content

Commit 9899e37

Browse files
committed
Python: Preserve approval precedence in mixed tool batches
1 parent 6a3633e commit 9899e37

2 files changed

Lines changed: 85 additions & 7 deletions

File tree

‎python/packages/core/agent_framework/_tools.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,10 +1758,10 @@ async def _try_execute_function_call_groups(
17581758
function_call for function_call in function_calls if _is_actionable_function_call(function_call)
17591759
]
17601760

1761-
# Classify the entire batch first: any required user interaction pauses the batch before execution.
1761+
# Classify the complete batch before choosing a control-flow path. A pause must
1762+
# not depend on where its call appears in the model's batch.
17621763
requires_approval = False
17631764
has_declaration_only_call = False
1764-
# A user-input pause takes precedence over unknown-call termination in mixed batches.
17651765
for function_call in actionable_calls:
17661766
function_name = function_call.name
17671767
logger.debug(
@@ -1773,12 +1773,8 @@ async def _try_execute_function_call_groups(
17731773
if function_name in approval_tool_names:
17741774
logger.debug("Approval needed for function: %s", function_name)
17751775
requires_approval = True
1776-
break
1777-
if function_name in declaration_only_tool_names or function_name in additional_tool_names:
1776+
elif function_name in declaration_only_tool_names or function_name in additional_tool_names:
17781777
has_declaration_only_call = True
1779-
break
1780-
if config.get("terminate_on_unknown_calls", False) and function_name not in tool_map:
1781-
raise KeyError(f'Error: Requested function "{function_name}" not found.')
17821778
if requires_approval:
17831779
# Surface only the approvals the host must decide; session-backed safe siblings wait for that resume.
17841780
# approval can only be needed for Function Call Content, not Approval Responses.
@@ -1828,6 +1824,14 @@ async def _try_execute_function_call_groups(
18281824
declaration_only_calls.append(function_call)
18291825
return [[function_call] for function_call in declaration_only_calls], False
18301826

1827+
if config.get("terminate_on_unknown_calls", False):
1828+
unknown_call = next(
1829+
(function_call for function_call in actionable_calls if function_call.name not in tool_map),
1830+
None,
1831+
)
1832+
if unknown_call is not None:
1833+
raise KeyError(f'Error: Requested function "{unknown_call.name}" not found.')
1834+
18311835
# Only a fully executable batch reaches this point; run calls concurrently but retain per-call result groups.
18321836
# Create each task inside a copied context so the active agent span is
18331837
# preserved for every parallel tool invocation.

‎python/packages/core/tests/core/test_function_invocation_logic.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2292,6 +2292,80 @@ def known_func(arg1: str) -> str:
22922292
assert exec_counter == 0
22932293

22942294

2295+
@pytest.mark.parametrize("declaration_first", [True, False], ids=["declaration-first", "approval-first"])
2296+
async def test_mixed_batch_always_require_approval_takes_precedence_over_declaration_only(
2297+
declaration_first: bool,
2298+
) -> None:
2299+
"""An approval pause must not depend on the position of a declaration-only sibling."""
2300+
from agent_framework import FunctionTool
2301+
from agent_framework._tools import _try_execute_function_call_groups
2302+
2303+
@tool(name="guarded", approval_mode="always_require")
2304+
def guarded() -> str:
2305+
return "should not run"
2306+
2307+
declaration_only = FunctionTool(name="external", description="Handled by the host", func=None)
2308+
guarded_call = Content.from_function_call(call_id="guarded-call", name="guarded", arguments={})
2309+
declaration_call = Content.from_function_call(call_id="external-call", name="external", arguments={})
2310+
calls = [declaration_call, guarded_call] if declaration_first else [guarded_call, declaration_call]
2311+
2312+
result_groups, should_terminate = await _try_execute_function_call_groups(
2313+
custom_args={}, function_calls=calls, tools=[guarded, declaration_only], config={}
2314+
)
2315+
2316+
assert not should_terminate
2317+
approval_requests = [
2318+
content for group in result_groups for content in group if content.type == "function_approval_request"
2319+
]
2320+
assert any(request.function_call.name == "guarded" for request in approval_requests)
2321+
2322+
2323+
async def test_mixed_batch_always_require_approval_takes_precedence_over_unknown_termination() -> None:
2324+
"""An unknown call cannot mask an approval pause when unknown-call termination is enabled."""
2325+
from agent_framework._tools import _try_execute_function_call_groups
2326+
2327+
@tool(name="guarded", approval_mode="always_require")
2328+
def guarded() -> str:
2329+
return "should not run"
2330+
2331+
result_groups, should_terminate = await _try_execute_function_call_groups(
2332+
custom_args={},
2333+
function_calls=[
2334+
Content.from_function_call(call_id="unknown-call", name="unknown", arguments={}),
2335+
Content.from_function_call(call_id="guarded-call", name="guarded", arguments={}),
2336+
],
2337+
tools=[guarded],
2338+
config={"terminate_on_unknown_calls": True},
2339+
)
2340+
2341+
assert not should_terminate
2342+
approval_requests = [
2343+
content for group in result_groups for content in group if content.type == "function_approval_request"
2344+
]
2345+
assert any(request.function_call.name == "guarded" for request in approval_requests)
2346+
2347+
2348+
async def test_mixed_batch_declaration_only_takes_precedence_over_unknown_termination() -> None:
2349+
"""A declaration-only pause also takes precedence over an unknown-call termination."""
2350+
from agent_framework import FunctionTool
2351+
from agent_framework._tools import _try_execute_function_call_groups
2352+
2353+
declaration_only = FunctionTool(name="external", description="Handled by the host", func=None)
2354+
result_groups, should_terminate = await _try_execute_function_call_groups(
2355+
custom_args={},
2356+
function_calls=[
2357+
Content.from_function_call(call_id="unknown-call", name="unknown", arguments={}),
2358+
Content.from_function_call(call_id="external-call", name="external", arguments={}),
2359+
],
2360+
tools=[declaration_only],
2361+
config={"terminate_on_unknown_calls": True},
2362+
)
2363+
2364+
assert not should_terminate
2365+
paused_calls = [content for group in result_groups for content in group]
2366+
assert all(content.type == "function_call" and content.user_input_request for content in paused_calls)
2367+
2368+
22952369
async def test_function_invocation_config_additional_tools(chat_client_base: SupportsChatGetResponse):
22962370
"""Test that additional_tools are available but treated as declaration_only."""
22972371
exec_counter_visible = 0

0 commit comments

Comments
 (0)