From c040c47161d6e254277d706e6fe20d8d2e1469c4 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:36:21 +0100 Subject: [PATCH 1/3] fix(python): enforce tool selection locally Some provider protocols cannot represent every tool-selection constraint. Revalidate fresh calls at the local dispatch boundary while preserving correlated results, execution budgets, and approval resumes. --- .../specs/004-python-function-calling-loop.md | 1 + .../packages/core/agent_framework/_tools.py | 63 +++++++- .../core/test_function_invocation_logic.py | 149 +++++++++++++++++- 3 files changed, 205 insertions(+), 8 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index bdf2fc8ac23..1f734c71d16 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -582,6 +582,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | String input | Flexible string input follows the same loop behavior. | `test_base_client_with_function_calling_string_input` | | Multiple sequential rounds | Each round retains one call/result pair. | `test_base_client_with_function_calling_resets` | | Streaming call | Call chunks, one result update, and final text are emitted in order. | `test_base_client_with_streaming_function_calling` | +| Tool-selection policy | Fresh model calls execute locally only when permitted by `tool_choice`; `none`, `required_function_name`, and `allowed_tools` are enforced again before local dispatch in both response modes even when a provider does not enforce them. Rejected calls retain correlated error results without consuming the executed-function budget. Valid session-bound approval resumes retain their recorded authority. | `test_fresh_function_dispatch_enforces_tool_choice_policy`, `test_tool_choice_rejection_does_not_consume_function_call_budget`, `test_local_approval_response_executes_with_authoritative_session`, `test_streaming_approval_resume_yields_terminal_result_before_model_text` | | Function-call occurrence identity | Actionable calls gain one stable `Content.id`; a safe local empty-`call_id` fallback uses that id with a migration warning, and streaming aggregation preserves provider-assigned occurrence ids across interleaved fragments. OpenAI Chat Completions scopes fragment correlation to each request and `(choice.index, tool.index)`. | `test_actionable_function_call_gets_stable_occurrence_identity`, `test_actionable_function_call_uses_occurrence_identity_for_empty_call_id`, `test_streaming_empty_call_id_keeps_occurrence_identity_through_approval`, `test_streaming_empty_call_id_delta_reuses_opening_call_identity`, `test_streaming_interleaved_indexed_call_fragments_coalesce_by_occurrence`, `packages/core/tests/core/test_types.py::test_function_call_occurrence_id_roundtrips_without_regeneration`, `packages/openai/tests/openai/test_openai_chat_completion_client.py::test_streaming_tool_call_identity_is_request_local_and_scoped_by_choice_index` | | Reasoning-bound call | Finalized output retains reasoning, function call, function result, and final text. | `test_streaming_function_calling_response_includes_reasoning_and_tool_results` | | Calls across response messages | Every actionable call is executed once. | `test_base_client_executes_function_calls_across_multiple_response_messages` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 4ab15a52f79..a8bc3f17995 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -4395,6 +4395,45 @@ class _FunctionProcessingResult: _FunctionCallExecutor: TypeAlias = Callable[..., Awaitable[_FunctionExecutionBatch]] +def _fresh_function_calls_allowed_by_tool_choice( + function_calls: Sequence[Content], + options: dict[str, Any] | None, +) -> tuple[list[Content], set[int]]: + """Return fresh calls permitted for local dispatch and the blocked object identities.""" + from ._types import validate_tool_mode + + tool_mode = validate_tool_mode(options.get("tool_choice")) if options else None + if tool_mode is None: + return list(function_calls), set() + + allowed_names: set[str] | None = None + if tool_mode.get("mode") == "none": + allowed_names = set() + elif required_name := tool_mode.get("required_function_name"): + allowed_names = {required_name} + elif (configured_names := tool_mode.get("allowed_tools")) is not None: + allowed_names = set(configured_names) + + if allowed_names is None: + return list(function_calls), set() + + # Provider-side selection is advisory for some protocols. Recheck fresh model + # output here so an ignored policy can never become a local side effect. + allowed_calls = [call for call in function_calls if call.name in allowed_names] + allowed_call_ids = {id(call) for call in allowed_calls} + return allowed_calls, {id(call) for call in function_calls if id(call) not in allowed_call_ids} + + +def _tool_choice_rejection_result(function_call: Content) -> Content: + from ._types import Content + + return Content.from_function_result( + call_id=function_call.call_id or "", + result="Error: The requested function is not permitted by the active tool_choice policy.", + exception="FunctionInvocationPolicyError", + ) + + def _messages_and_updates_for_terminal_contents( contents: Sequence[Content], ) -> tuple[tuple[Message, ...], tuple[ChatResponseUpdate, ...]]: @@ -4830,16 +4869,32 @@ async def _process_model_function_calls( return _FunctionProcessingResult(errors_in_a_row=errors_in_a_row, action="return") # 2. Execute the batch once while preserving each call's result group. - execution = await execute_function_calls( - function_calls=function_calls, - options=options, + allowed_calls, blocked_call_ids = _fresh_function_calls_allowed_by_tool_choice(function_calls, options) + execution = ( + await execute_function_calls( + function_calls=allowed_calls, + options=options, + ) + if allowed_calls + else _FunctionExecutionBatch(result_groups=[]) ) + # Synthetic policy failures preserve call/result balance but did not run a tool + # body, so capture the real execution count before merging those result groups. + executed_call_count = execution.executed_call_count + if blocked_call_ids: + allowed_result_groups = iter(execution.result_groups) + execution.result_groups = [ + [_tool_choice_rejection_result(function_call)] + if id(function_call) in blocked_call_ids + else next(allowed_result_groups) + for function_call in function_calls + ] # 3. Fold results into the response and translate errors or middleware termination into the next loop action. processing_result = _handle_function_call_results( response=response, execution_results=execution.contents, - function_call_count=execution.executed_call_count, + function_call_count=executed_call_count, function_call_messages=function_call_messages, errors_in_a_row=errors_in_a_row, had_errors=execution.had_errors, diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 243dd0dcff0..32da61a4b44 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -28,6 +28,7 @@ ResponseInvalidatedException, ResponseStream, SupportsChatGetResponse, + ToolMode, chat_middleware, tool, ) @@ -1005,6 +1006,140 @@ def ai_func(arg1: str) -> str: assert response.messages[2].text == "done" +@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) +@pytest.mark.parametrize( + ("tool_choice", "expected_executions"), + [ + ({"mode": "auto", "allowed_tools": ["allowed_tool"]}, ["allowed"]), + ({"mode": "required", "required_function_name": "allowed_tool"}, ["allowed"]), + ({"mode": "none"}, []), + ], + ids=["allowed_tools", "required_function", "none"], +) +async def test_fresh_function_dispatch_enforces_tool_choice_policy( + chat_client_base: SupportsChatGetResponse, + streaming: bool, + tool_choice: ToolMode, + expected_executions: list[str], +) -> None: + """A provider response cannot dispatch local functions excluded by tool_choice.""" + executions: list[str] = [] + + @tool(name="allowed_tool", approval_mode="never_require") + def allowed_tool() -> str: + executions.append("allowed") + return "allowed" + + @tool(name="blocked_tool", approval_mode="never_require") + def blocked_tool() -> str: + executions.append("blocked") + return "blocked" + + calls = [ + Content.from_function_call(call_id="allowed", name="allowed_tool", arguments={}), + Content.from_function_call(call_id="blocked", name="blocked_tool", arguments={}), + ] + options: ChatOptions = {"tools": [allowed_tool, blocked_tool], "tool_choice": tool_choice} + if streaming: + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ChatResponseUpdate(role="assistant", contents=calls)], + [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])], + ] + stream = chat_client_base.get_response( + [Message(role="user", contents=["run tools"])], + options=options, + stream=True, + ) + response = await stream.get_final_response() + else: + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=calls)), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + response = await chat_client_base.get_response( + [Message(role="user", contents=["run tools"])], + options=options, + ) + + assert executions == expected_executions + results = { + content.call_id: content + for message in response.messages + for content in message.contents + if content.type == "function_result" + } + assert results["blocked"].exception == "FunctionInvocationPolicyError" + assert results["allowed"].exception == (None if expected_executions else "FunctionInvocationPolicyError") + + +@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) +async def test_tool_choice_rejection_does_not_consume_function_call_budget( + chat_client_base: SupportsChatGetResponse, + streaming: bool, +) -> None: + """A policy-rejected call does not prevent a later allowed call from using the execution budget.""" + from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY + + executions: list[str] = [] + + @tool(name="allowed_tool", approval_mode="never_require") + def allowed_tool() -> str: + executions.append("allowed") + return "allowed" + + @tool(name="blocked_tool", approval_mode="never_require") + def blocked_tool() -> str: + executions.append("blocked") + return "blocked" + + blocked_call = Content.from_function_call(call_id="blocked", name="blocked_tool", arguments={}) + allowed_call = Content.from_function_call(call_id="allowed", name="allowed_tool", arguments={}) + if streaming: + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ChatResponseUpdate(role="assistant", contents=[blocked_call])], + [ChatResponseUpdate(role="assistant", contents=[allowed_call])], + [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])], + ] + else: + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=[blocked_call])), + ChatResponse(messages=Message(role="assistant", contents=[allowed_call])), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + budget_state: dict[str, int] = {} + chat_client_base.function_invocation_configuration["max_function_calls"] = 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + options: ChatOptions = { + "tools": [allowed_tool, blocked_tool], + "tool_choice": {"mode": "auto", "allowed_tools": ["allowed_tool"]}, + } + if streaming: + stream = chat_client_base.get_response( + [Message(role="user", contents=["run tools"])], + options=options, + stream=True, + client_kwargs={_FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state}, + ) + response = await stream.get_final_response() + else: + response = await chat_client_base.get_response( + [Message(role="user", contents=["run tools"])], + options=options, + client_kwargs={_FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state}, + ) + + assert executions == ["allowed"] + assert budget_state["total_function_calls"] == 1 + results = { + content.call_id: content + for message in response.messages + for content in message.contents + if content.type == "function_result" + } + assert results["blocked"].exception == "FunctionInvocationPolicyError" + assert results["allowed"].exception is None + + async def test_function_call_with_length_finish_reason_executes_and_continues( chat_client_base: SupportsChatGetResponse, ) -> None: @@ -8663,7 +8798,10 @@ def guarded_stream_tool() -> str: first_stream = chat_client_base.get_response( [Message(role="user", contents=["run guarded"])], stream=True, - options={"tools": [guarded_stream_tool]}, + options={ + "tools": [guarded_stream_tool], + "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_stream_tool"]}, + }, client_kwargs={"session": session}, ) first_updates = [update async for update in first_stream] @@ -8677,7 +8815,10 @@ def guarded_stream_tool() -> str: resumed_stream = chat_client_base.get_response( [Message(role="user", contents=[approval_request.to_function_approval_response(approved=approved)])], stream=True, - options={"tools": [guarded_stream_tool]}, + options={ + "tools": [guarded_stream_tool], + "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_stream_tool"]}, + }, client_kwargs={"session": session}, ) resumed_updates = [update async for update in resumed_stream] @@ -11029,7 +11170,7 @@ async def test_local_approval_response_executes_with_authoritative_session( paused = await chat_client_base.get_response( [Message(role="user", contents=["please continue"])], - options={"tools": [guarded_tool]}, + options={"tools": [guarded_tool], "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_tool"]}}, client_kwargs={"session": session}, ) approval_request = next( @@ -11044,7 +11185,7 @@ async def test_local_approval_response_executes_with_authoritative_session( *paused.messages, Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), ], - options={"tools": [guarded_tool]}, + options={"tools": [guarded_tool], "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_tool"]}}, client_kwargs={"session": session}, ) From b208f7b795ca2d8ee52d0129888db2ecb80007fa Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:09 +0100 Subject: [PATCH 2/3] fix(python): clarify Foundry tool ownership Preconfigured Foundry agents own tool selection server-side, so per-request tool controls cannot be propagated with an agent reference. Warn when callers provide ignored controls and document the boundary. Remove the generic core dispatch enforcement because it could not govern server-side tools and changed behavior for every provider. Copilot-Session: c2489530-5c17-4230-af9a-3b4448b5da1a --- .../specs/004-python-function-calling-loop.md | 1 - .../packages/core/agent_framework/_tools.py | 63 +------- .../core/test_function_invocation_logic.py | 149 +----------------- python/packages/foundry/README.md | 12 ++ .../foundry/agent_framework_foundry/_agent.py | 20 +++ .../tests/foundry/test_foundry_agent.py | 39 +++++ 6 files changed, 79 insertions(+), 205 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 1f734c71d16..bdf2fc8ac23 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -582,7 +582,6 @@ that manually replay messages own the equivalent rule: do not resend an approval | String input | Flexible string input follows the same loop behavior. | `test_base_client_with_function_calling_string_input` | | Multiple sequential rounds | Each round retains one call/result pair. | `test_base_client_with_function_calling_resets` | | Streaming call | Call chunks, one result update, and final text are emitted in order. | `test_base_client_with_streaming_function_calling` | -| Tool-selection policy | Fresh model calls execute locally only when permitted by `tool_choice`; `none`, `required_function_name`, and `allowed_tools` are enforced again before local dispatch in both response modes even when a provider does not enforce them. Rejected calls retain correlated error results without consuming the executed-function budget. Valid session-bound approval resumes retain their recorded authority. | `test_fresh_function_dispatch_enforces_tool_choice_policy`, `test_tool_choice_rejection_does_not_consume_function_call_budget`, `test_local_approval_response_executes_with_authoritative_session`, `test_streaming_approval_resume_yields_terminal_result_before_model_text` | | Function-call occurrence identity | Actionable calls gain one stable `Content.id`; a safe local empty-`call_id` fallback uses that id with a migration warning, and streaming aggregation preserves provider-assigned occurrence ids across interleaved fragments. OpenAI Chat Completions scopes fragment correlation to each request and `(choice.index, tool.index)`. | `test_actionable_function_call_gets_stable_occurrence_identity`, `test_actionable_function_call_uses_occurrence_identity_for_empty_call_id`, `test_streaming_empty_call_id_keeps_occurrence_identity_through_approval`, `test_streaming_empty_call_id_delta_reuses_opening_call_identity`, `test_streaming_interleaved_indexed_call_fragments_coalesce_by_occurrence`, `packages/core/tests/core/test_types.py::test_function_call_occurrence_id_roundtrips_without_regeneration`, `packages/openai/tests/openai/test_openai_chat_completion_client.py::test_streaming_tool_call_identity_is_request_local_and_scoped_by_choice_index` | | Reasoning-bound call | Finalized output retains reasoning, function call, function result, and final text. | `test_streaming_function_calling_response_includes_reasoning_and_tool_results` | | Calls across response messages | Every actionable call is executed once. | `test_base_client_executes_function_calls_across_multiple_response_messages` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a8bc3f17995..4ab15a52f79 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -4395,45 +4395,6 @@ class _FunctionProcessingResult: _FunctionCallExecutor: TypeAlias = Callable[..., Awaitable[_FunctionExecutionBatch]] -def _fresh_function_calls_allowed_by_tool_choice( - function_calls: Sequence[Content], - options: dict[str, Any] | None, -) -> tuple[list[Content], set[int]]: - """Return fresh calls permitted for local dispatch and the blocked object identities.""" - from ._types import validate_tool_mode - - tool_mode = validate_tool_mode(options.get("tool_choice")) if options else None - if tool_mode is None: - return list(function_calls), set() - - allowed_names: set[str] | None = None - if tool_mode.get("mode") == "none": - allowed_names = set() - elif required_name := tool_mode.get("required_function_name"): - allowed_names = {required_name} - elif (configured_names := tool_mode.get("allowed_tools")) is not None: - allowed_names = set(configured_names) - - if allowed_names is None: - return list(function_calls), set() - - # Provider-side selection is advisory for some protocols. Recheck fresh model - # output here so an ignored policy can never become a local side effect. - allowed_calls = [call for call in function_calls if call.name in allowed_names] - allowed_call_ids = {id(call) for call in allowed_calls} - return allowed_calls, {id(call) for call in function_calls if id(call) not in allowed_call_ids} - - -def _tool_choice_rejection_result(function_call: Content) -> Content: - from ._types import Content - - return Content.from_function_result( - call_id=function_call.call_id or "", - result="Error: The requested function is not permitted by the active tool_choice policy.", - exception="FunctionInvocationPolicyError", - ) - - def _messages_and_updates_for_terminal_contents( contents: Sequence[Content], ) -> tuple[tuple[Message, ...], tuple[ChatResponseUpdate, ...]]: @@ -4869,32 +4830,16 @@ async def _process_model_function_calls( return _FunctionProcessingResult(errors_in_a_row=errors_in_a_row, action="return") # 2. Execute the batch once while preserving each call's result group. - allowed_calls, blocked_call_ids = _fresh_function_calls_allowed_by_tool_choice(function_calls, options) - execution = ( - await execute_function_calls( - function_calls=allowed_calls, - options=options, - ) - if allowed_calls - else _FunctionExecutionBatch(result_groups=[]) + execution = await execute_function_calls( + function_calls=function_calls, + options=options, ) - # Synthetic policy failures preserve call/result balance but did not run a tool - # body, so capture the real execution count before merging those result groups. - executed_call_count = execution.executed_call_count - if blocked_call_ids: - allowed_result_groups = iter(execution.result_groups) - execution.result_groups = [ - [_tool_choice_rejection_result(function_call)] - if id(function_call) in blocked_call_ids - else next(allowed_result_groups) - for function_call in function_calls - ] # 3. Fold results into the response and translate errors or middleware termination into the next loop action. processing_result = _handle_function_call_results( response=response, execution_results=execution.contents, - function_call_count=executed_call_count, + function_call_count=execution.executed_call_count, function_call_messages=function_call_messages, errors_in_a_row=errors_in_a_row, had_errors=execution.had_errors, diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 32da61a4b44..243dd0dcff0 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -28,7 +28,6 @@ ResponseInvalidatedException, ResponseStream, SupportsChatGetResponse, - ToolMode, chat_middleware, tool, ) @@ -1006,140 +1005,6 @@ def ai_func(arg1: str) -> str: assert response.messages[2].text == "done" -@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) -@pytest.mark.parametrize( - ("tool_choice", "expected_executions"), - [ - ({"mode": "auto", "allowed_tools": ["allowed_tool"]}, ["allowed"]), - ({"mode": "required", "required_function_name": "allowed_tool"}, ["allowed"]), - ({"mode": "none"}, []), - ], - ids=["allowed_tools", "required_function", "none"], -) -async def test_fresh_function_dispatch_enforces_tool_choice_policy( - chat_client_base: SupportsChatGetResponse, - streaming: bool, - tool_choice: ToolMode, - expected_executions: list[str], -) -> None: - """A provider response cannot dispatch local functions excluded by tool_choice.""" - executions: list[str] = [] - - @tool(name="allowed_tool", approval_mode="never_require") - def allowed_tool() -> str: - executions.append("allowed") - return "allowed" - - @tool(name="blocked_tool", approval_mode="never_require") - def blocked_tool() -> str: - executions.append("blocked") - return "blocked" - - calls = [ - Content.from_function_call(call_id="allowed", name="allowed_tool", arguments={}), - Content.from_function_call(call_id="blocked", name="blocked_tool", arguments={}), - ] - options: ChatOptions = {"tools": [allowed_tool, blocked_tool], "tool_choice": tool_choice} - if streaming: - chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - [ChatResponseUpdate(role="assistant", contents=calls)], - [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])], - ] - stream = chat_client_base.get_response( - [Message(role="user", contents=["run tools"])], - options=options, - stream=True, - ) - response = await stream.get_final_response() - else: - chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - ChatResponse(messages=Message(role="assistant", contents=calls)), - ChatResponse(messages=Message(role="assistant", contents=["done"])), - ] - response = await chat_client_base.get_response( - [Message(role="user", contents=["run tools"])], - options=options, - ) - - assert executions == expected_executions - results = { - content.call_id: content - for message in response.messages - for content in message.contents - if content.type == "function_result" - } - assert results["blocked"].exception == "FunctionInvocationPolicyError" - assert results["allowed"].exception == (None if expected_executions else "FunctionInvocationPolicyError") - - -@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) -async def test_tool_choice_rejection_does_not_consume_function_call_budget( - chat_client_base: SupportsChatGetResponse, - streaming: bool, -) -> None: - """A policy-rejected call does not prevent a later allowed call from using the execution budget.""" - from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY - - executions: list[str] = [] - - @tool(name="allowed_tool", approval_mode="never_require") - def allowed_tool() -> str: - executions.append("allowed") - return "allowed" - - @tool(name="blocked_tool", approval_mode="never_require") - def blocked_tool() -> str: - executions.append("blocked") - return "blocked" - - blocked_call = Content.from_function_call(call_id="blocked", name="blocked_tool", arguments={}) - allowed_call = Content.from_function_call(call_id="allowed", name="allowed_tool", arguments={}) - if streaming: - chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - [ChatResponseUpdate(role="assistant", contents=[blocked_call])], - [ChatResponseUpdate(role="assistant", contents=[allowed_call])], - [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])], - ] - else: - chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - ChatResponse(messages=Message(role="assistant", contents=[blocked_call])), - ChatResponse(messages=Message(role="assistant", contents=[allowed_call])), - ChatResponse(messages=Message(role="assistant", contents=["done"])), - ] - - budget_state: dict[str, int] = {} - chat_client_base.function_invocation_configuration["max_function_calls"] = 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - options: ChatOptions = { - "tools": [allowed_tool, blocked_tool], - "tool_choice": {"mode": "auto", "allowed_tools": ["allowed_tool"]}, - } - if streaming: - stream = chat_client_base.get_response( - [Message(role="user", contents=["run tools"])], - options=options, - stream=True, - client_kwargs={_FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state}, - ) - response = await stream.get_final_response() - else: - response = await chat_client_base.get_response( - [Message(role="user", contents=["run tools"])], - options=options, - client_kwargs={_FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state}, - ) - - assert executions == ["allowed"] - assert budget_state["total_function_calls"] == 1 - results = { - content.call_id: content - for message in response.messages - for content in message.contents - if content.type == "function_result" - } - assert results["blocked"].exception == "FunctionInvocationPolicyError" - assert results["allowed"].exception is None - - async def test_function_call_with_length_finish_reason_executes_and_continues( chat_client_base: SupportsChatGetResponse, ) -> None: @@ -8798,10 +8663,7 @@ def guarded_stream_tool() -> str: first_stream = chat_client_base.get_response( [Message(role="user", contents=["run guarded"])], stream=True, - options={ - "tools": [guarded_stream_tool], - "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_stream_tool"]}, - }, + options={"tools": [guarded_stream_tool]}, client_kwargs={"session": session}, ) first_updates = [update async for update in first_stream] @@ -8815,10 +8677,7 @@ def guarded_stream_tool() -> str: resumed_stream = chat_client_base.get_response( [Message(role="user", contents=[approval_request.to_function_approval_response(approved=approved)])], stream=True, - options={ - "tools": [guarded_stream_tool], - "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_stream_tool"]}, - }, + options={"tools": [guarded_stream_tool]}, client_kwargs={"session": session}, ) resumed_updates = [update async for update in resumed_stream] @@ -11170,7 +11029,7 @@ async def test_local_approval_response_executes_with_authoritative_session( paused = await chat_client_base.get_response( [Message(role="user", contents=["please continue"])], - options={"tools": [guarded_tool], "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_tool"]}}, + options={"tools": [guarded_tool]}, client_kwargs={"session": session}, ) approval_request = next( @@ -11185,7 +11044,7 @@ async def test_local_approval_response_executes_with_authoritative_session( *paused.messages, Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), ], - options={"tools": [guarded_tool], "tool_choice": {"mode": "auto", "allowed_tools": ["guarded_tool"]}}, + options={"tools": [guarded_tool]}, client_kwargs={"session": session}, ) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 56d13cbd872..8bf270ed795 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -8,6 +8,18 @@ This package supports `azure-ai-projects>=2.2.0,<2.8.0`. Projects 2.5 and later `openai>=3.0.0`, so `agent-framework-foundry` requires `agent-framework-openai>=1.14.2`, which supports both OpenAI 2.x and 3.x. +## Tool ownership for preconfigured Foundry agents + +`FoundryAgent` connects to a PromptAgent or HostedAgent whose instructions, tool declarations, and tool-selection +behavior are configured in Microsoft Foundry. The `tools=` argument supplies only the local Python implementations +for function declarations already present on that remote agent; it does not replace or restrict the remote tool +inventory. + +Per-run `tool_choice` and `parallel_tool_calls` values cannot be sent with an agent reference and are ignored with a +warning. Configure tool behavior on the remote Foundry agent, or use `FoundryChatClient` when tool selection must vary +per request. `FunctionMiddleware` and tool approval policies can authorize local Python execution, but they do +not change tools that execute inside the remote Foundry agent. + ## Tracing an existing Foundry agent Install Azure Monitor to connect client and service traces: diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 43374b85d29..382ac3742a6 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -103,6 +103,11 @@ class FoundryAgentOptions(OpenAIChatOptions, total=False): Keyword Args: extra_body: Additional request body values sent to the Responses API. + tool_choice: Not supported for pre-provisioned Foundry agents. The remote agent + owns tool selection, so caller-supplied values are ignored with a warning. + Use ``FoundryChatClient`` when tool selection must vary per request. + parallel_tool_calls: Not supported for pre-provisioned Foundry agents. The remote + agent owns this setting, so caller-supplied values are ignored with a warning. isolation_key: Deprecated. This option no longer has any effect. """ @@ -409,6 +414,17 @@ async def _prepare_options( stripped_tools = run_options.pop("tools", None) run_options.pop("tool_choice", None) run_options.pop("parallel_tool_calls", None) + ignored_tool_options = [ + option_name for option_name in ("tool_choice", "parallel_tool_calls") if option_name in options + ] + if ignored_tool_options: + logger.warning( + "Foundry agent '%s' owns tool selection server-side; caller-supplied options %s are ignored. " + "Configure tool behavior on the remote Foundry agent, or use FoundryChatClient for " + "per-request control.", + self.agent_name, + ignored_tool_options, + ) if stripped_tools: logger.warning( "Foundry agent '%s' was provided tools, but tool declarations cannot be sent when an " @@ -927,6 +943,10 @@ class FoundryAgent( # type: ignore[misc] Connects to an existing PromptAgent or HostedAgent in Foundry. This is the recommended class for production use. + The pre-provisioned Foundry agent owns its tool declarations and tool-selection + behavior. ``tools=`` supplies matching local Python implementations only; + per-run ``tool_choice`` and ``parallel_tool_calls`` values are not propagated. + Examples: .. code-block:: python diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 3fe96541499..c0839186b30 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -458,6 +458,45 @@ def my_func() -> str: assert sum("cannot be sent when an agent is specified" in record.message for record in caplog.records) == 1 +@pytest.mark.parametrize("allow_preview", [False, True], ids=["stable", "preview"]) +async def test_raw_foundry_agent_chat_client_warns_when_explicit_tool_control_is_ignored( + caplog: pytest.LogCaptureFixture, + allow_preview: bool, +) -> None: + """Pre-provisioned agents own tool selection, so explicit request overrides are ignored visibly.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + allow_preview=allow_preview, + ) + + with ( + patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={ + "model": "gpt-4.1", + "tool_choice": "none", + "parallel_tool_calls": False, + }, + ), + caplog.at_level("WARNING", logger="agent_framework.foundry"), + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tool_choice": "none", "parallel_tool_calls": False}, + ) + + assert "tool_choice" not in result + assert "parallel_tool_calls" not in result + warning = next(record.message for record in caplog.records if "owns tool selection server-side" in record.message) + assert "tool_choice" in warning + assert "parallel_tool_calls" in warning + + async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools_when_allow_preview( caplog: pytest.LogCaptureFixture, ) -> None: From d405fd2edd24bce9ddd85774d57e2d7d22311274 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:37:20 +0100 Subject: [PATCH 3/3] fix(python): refine Foundry option warnings Ignore the framework-generated unrestricted auto default and detect the public allow_multiple_tool_calls option before Foundry translates it for the provider. Copilot-Session: c2489530-5c17-4230-af9a-3b4448b5da1a --- python/packages/foundry/README.md | 9 ++--- .../foundry/agent_framework_foundry/_agent.py | 19 ++++++---- .../tests/foundry/test_foundry_agent.py | 36 +++++++++++++++++-- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 8bf270ed795..6f93fdf59fa 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -15,10 +15,11 @@ behavior are configured in Microsoft Foundry. The `tools=` argument supplies onl for function declarations already present on that remote agent; it does not replace or restrict the remote tool inventory. -Per-run `tool_choice` and `parallel_tool_calls` values cannot be sent with an agent reference and are ignored with a -warning. Configure tool behavior on the remote Foundry agent, or use `FoundryChatClient` when tool selection must vary -per request. `FunctionMiddleware` and tool approval policies can authorize local Python execution, but they do -not change tools that execute inside the remote Foundry agent. +Per-run `tool_choice` restrictions and `allow_multiple_tool_calls` values cannot be sent with an agent reference and +are ignored with a warning. The framework's unrestricted `tool_choice="auto"` default is omitted silently. Configure +tool behavior on the remote Foundry agent, or use `FoundryChatClient` when tool selection must vary per request. +`FunctionMiddleware` and tool approval policies can authorize local Python execution, but they do not change tools +that execute inside the remote Foundry agent. ## Tracing an existing Foundry agent diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 382ac3742a6..65359d62794 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -106,8 +106,8 @@ class FoundryAgentOptions(OpenAIChatOptions, total=False): tool_choice: Not supported for pre-provisioned Foundry agents. The remote agent owns tool selection, so caller-supplied values are ignored with a warning. Use ``FoundryChatClient`` when tool selection must vary per request. - parallel_tool_calls: Not supported for pre-provisioned Foundry agents. The remote - agent owns this setting, so caller-supplied values are ignored with a warning. + allow_multiple_tool_calls: Not supported for pre-provisioned Foundry agents. The + remote agent owns this setting, so caller-supplied values are ignored with a warning. isolation_key: Deprecated. This option no longer has any effect. """ @@ -414,9 +414,16 @@ async def _prepare_options( stripped_tools = run_options.pop("tools", None) run_options.pop("tool_choice", None) run_options.pop("parallel_tool_calls", None) - ignored_tool_options = [ - option_name for option_name in ("tool_choice", "parallel_tool_calls") if option_name in options - ] + ignored_tool_options: list[str] = [] + tool_choice = options.get("tool_choice") + unrestricted_auto = tool_choice == "auto" + if isinstance(tool_choice, Mapping): + typed_tool_choice = cast(Mapping[str, Any], tool_choice) + unrestricted_auto = typed_tool_choice.get("mode") == "auto" and "allowed_tools" not in typed_tool_choice + if "tool_choice" in options and tool_choice is not None and not unrestricted_auto: + ignored_tool_options.append("tool_choice") + if "allow_multiple_tool_calls" in options: + ignored_tool_options.append("allow_multiple_tool_calls") if ignored_tool_options: logger.warning( "Foundry agent '%s' owns tool selection server-side; caller-supplied options %s are ignored. " @@ -945,7 +952,7 @@ class FoundryAgent( # type: ignore[misc] The pre-provisioned Foundry agent owns its tool declarations and tool-selection behavior. ``tools=`` supplies matching local Python implementations only; - per-run ``tool_choice`` and ``parallel_tool_calls`` values are not propagated. + per-run ``tool_choice`` restrictions and ``allow_multiple_tool_calls`` values are not propagated. Examples: .. code-block:: python diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index c0839186b30..0597f2c9f3c 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -487,14 +487,46 @@ async def test_raw_foundry_agent_chat_client_warns_when_explicit_tool_control_is ): result = await client._prepare_options( messages=[Message(role="user", contents="hi")], - options={"tool_choice": "none", "parallel_tool_calls": False}, + options={"tool_choice": "none", "allow_multiple_tool_calls": False}, ) assert "tool_choice" not in result assert "parallel_tool_calls" not in result warning = next(record.message for record in caplog.records if "owns tool selection server-side" in record.message) assert "tool_choice" in warning - assert "parallel_tool_calls" in warning + assert "allow_multiple_tool_calls" in warning + + +@pytest.mark.parametrize("allow_preview", [False, True], ids=["stable", "preview"]) +async def test_raw_foundry_agent_chat_client_does_not_warn_for_framework_default_tool_choice( + caplog: pytest.LogCaptureFixture, + allow_preview: bool, +) -> None: + """The framework-generated auto mode must not be reported as caller intent.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + allow_preview=allow_preview, + ) + + with ( + patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={"model": "gpt-4.1", "tool_choice": "auto"}, + ), + caplog.at_level("WARNING", logger="agent_framework.foundry"), + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tool_choice": "auto"}, + ) + + assert "tool_choice" not in result + assert not any("owns tool selection server-side" in record.message for record in caplog.records) async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools_when_allow_preview(