From d913c8a8e65ddd9c4d071968264034aaedaab849 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 15:13:38 +0200 Subject: [PATCH 1/3] Python: Mark hosted tool calls informational-only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_anthropic/_chat_client.py | 1 + .../anthropic/tests/test_anthropic_client.py | 3 +- .../packages/core/agent_framework/_tools.py | 26 +++- .../packages/core/agent_framework/_types.py | 56 +++++++- .../core/test_function_invocation_logic.py | 86 ++++++++++++ python/packages/core/tests/core/test_types.py | 35 +++++ .../_responses.py | 44 +++++- .../foundry_hosting/tests/test_responses.py | 12 ++ .../agent_framework_gemini/_chat_client.py | 55 +++++++- .../gemini/tests/test_gemini_client.py | 89 ++++++++++++ .../agent_framework_openai/_chat_client.py | 64 ++++++++- .../tests/openai/test_openai_chat_client.py | 129 ++++++++++++++++++ 12 files changed, 584 insertions(+), 16 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index cbfa14ad21..dac42eee14 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -1188,6 +1188,7 @@ def _parse_contents_from_anthropic( call_id=content_block.id, name=resolved_tool_name, arguments=content_block.input, + informational_only=content_block.type == "server_tool_use", raw_representation=content_block, ) ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 6cb1907b0c..a422ee3146 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -1452,9 +1452,10 @@ def test_parse_contents_server_tool_use_input_json_delta_ignored( server_tool_content.input = {} result = client._parse_contents_from_anthropic([server_tool_content]) - # server_tool_use falls through to function_call (not mcp_tool_use / code_execution) + # server_tool_use falls through to informational-only function_call (not mcp_tool_use / code_execution) assert len(result) == 1 assert result[0].type == "function_call" + assert result[0].informational_only is True assert client._last_call_content_type == "server_tool_use" # type: ignore[attr-defined] # input_json_delta events after server_tool_use must be silently ignored diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 76fda45733..a3d9bf1400 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1626,6 +1626,10 @@ def _get_tool_map( return tool_list +def _is_actionable_function_call(content: Content) -> bool: + return content.type == "function_call" and not content.informational_only + + async def _try_execute_function_calls( custom_args: dict[str, Any], attempt_idx: int, @@ -1655,6 +1659,14 @@ async def _try_execute_function_calls( """ from ._types import Content + function_calls = [ + function_call + for function_call in function_calls + if function_call.type != "function_call" or _is_actionable_function_call(function_call) + ] + if not function_calls: + return ([], False) + tool_map = _get_tool_map(tools) # The live tools list (when tools is the run-local list) is exposed on the # FunctionInvocationContext so tools can add/remove tools during the run. @@ -1680,14 +1692,18 @@ async def _try_execute_function_calls( fcc_name, fcc_name in approval_tools, ) - if fcc.type == "function_call" and fcc.name in approval_tools: + if _is_actionable_function_call(fcc) and fcc.name in approval_tools: logger.debug("Approval needed for function: %s", fcc.name) approval_needed = True break - if fcc.type == "function_call" and (fcc.name in declaration_only or fcc.name in additional_tool_names): + if _is_actionable_function_call(fcc) and (fcc.name in declaration_only or fcc.name in additional_tool_names): declaration_only_flag = True break - if config.get("terminate_on_unknown_calls", False) and fcc.type == "function_call" and fcc.name not in tool_map: + if ( + config.get("terminate_on_unknown_calls", False) + and _is_actionable_function_call(fcc) + and fcc.name not in tool_map + ): raise KeyError(f'Error: Requested function "{fcc.name}" not found.') if approval_needed: # approval can only be needed for Function Call Content, not Approval Responses. @@ -2182,7 +2198,7 @@ def _extract_function_calls(response: ChatResponse) -> list[Content]: function_calls: list[Content] = [] for message in response.messages: for item in message.contents: - if item.type != "function_call": + if not _is_actionable_function_call(item): continue if item.call_id and item.call_id in function_results: continue @@ -2793,7 +2809,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: ) if not any( - item.type in ("function_call", "function_approval_request") + item.type == "function_approval_request" or _is_actionable_function_call(item) for msg in response.messages for item in msg.contents ): diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index b947d49582..9fb7670c06 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -496,6 +496,7 @@ def __init__( name: str | None = None, arguments: str | Mapping[str, Any] | None = None, exception: str | None = None, + informational_only: bool = False, result: Any = None, items: Sequence[Content] | None = None, # Hosted file/vector store fields @@ -556,6 +557,7 @@ def __init__( self.name = name self.arguments = arguments self.exception = exception + self.informational_only = informational_only or type == "mcp_server_tool_call" self.result = result self.items = items self.file_id = file_id @@ -805,17 +807,39 @@ def from_function_call( *, arguments: str | Mapping[str, Any] | None = None, exception: str | None = None, + informational_only: bool = False, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, ) -> ContentT: - """Create function call content.""" + """Create function call content. + + Args: + call_id: The model- or service-provided identifier for this function call. Function results use the + same ID to indicate which call they answer. + name: The function name requested by the model or service. + + Keyword Args: + arguments: The arguments for the requested function call. May be a JSON string, a mapping that can be + serialized as arguments, or None when no arguments were provided. + exception: Error information associated with the function call, if the provider returned the call in an + error state. + informational_only: Whether the function call is present only for transcript fidelity and should not be + executed by Agent Framework function invocation. + annotations: Optional annotations attached to this content item. + additional_properties: Extra provider-specific properties to preserve with the content item. + raw_representation: The original provider-specific object or payload this content item was created from. + + Returns: + Function call content. + """ return cls( "function_call", call_id=call_id, name=name, arguments=arguments, exception=exception, + informational_only=informational_only, annotations=annotations, additional_properties=additional_properties, raw_representation=raw_representation, @@ -1184,13 +1208,36 @@ def from_mcp_server_tool_call( additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, ) -> ContentT: - """Create MCP server tool call content.""" + """Create MCP server tool call content. + + MCP server tool calls are provider-hosted tool calls that the model/service + already routed to a remote MCP server. They are recorded for transcript + fidelity and for matching provider-returned MCP tool results, but they are + not local function invocation requests. The returned content is always + marked ``informational_only=True``. + + Args: + call_id: The model- or service-provided identifier for this MCP tool call. + tool_name: The remote MCP tool name that was called. + + Keyword Args: + server_name: The remote MCP server label or name, when provided by the service. + arguments: The arguments sent to the remote MCP tool. May be a JSON string, + a mapping, or None when no arguments were provided. + annotations: Optional annotations attached to this content item. + additional_properties: Extra provider-specific properties to preserve with the content item. + raw_representation: The original provider-specific object or payload this content item was created from. + + Returns: + MCP server tool call content. + """ return cls( "mcp_server_tool_call", call_id=call_id, tool_name=tool_name, server_name=server_name, arguments=arguments, + informational_only=True, annotations=annotations, additional_properties=additional_properties, raw_representation=raw_representation, @@ -1323,6 +1370,7 @@ def to_dict(self, *, exclude_none: bool = True, exclude: set[str] | None = None) "name", "arguments", "exception", + "informational_only", "result", "items", "file_id", @@ -1356,6 +1404,8 @@ def to_dict(self, *, exclude_none: bool = True, exclude: set[str] | None = None) value = getattr(self, field, None) if field in exclude: continue + if field == "informational_only" and (self.type != "function_call" or not value): + continue if exclude_none and value is None: continue result[field] = _serialize_value(value, exclude_none) @@ -1499,6 +1549,8 @@ def _add_function_call_content(self, other: Content) -> Content: name=getattr(self, "name", getattr(other, "name", None)), arguments=arguments, exception=getattr(self, "exception", None) or getattr(other, "exception", None), + informational_only=getattr(self, "informational_only", False) + or getattr(other, "informational_only", False), additional_properties=_combine_additional_props(self.additional_properties, other.additional_properties), raw_representation=_combine_raw_representations(self.raw_representation, other.raw_representation), ) 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 d83846aa87..609d12eb6b 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -767,6 +767,92 @@ def func_with_approval(arg1: str) -> str: assert exec_counter == 0 # Neither function executed yet +async def test_informational_only_function_call_is_not_invoked(chat_client_base: SupportsChatGetResponse): + exec_counter = 0 + + @tool(name="no_approval_func", approval_mode="never_require") + def func_no_approval(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + informational_call = Content.from_function_call( + call_id="1", + name="no_approval_func", + arguments='{"arg1": "value1"}', + informational_only=True, + ) + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=[informational_call])) + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], + options={"tool_choice": "auto", "tools": [func_no_approval]}, + ) + + assert exec_counter == 0 + assert len(response.messages) == 1 + assert response.messages[0].contents == [informational_call] + + +async def test_informational_only_function_call_does_not_request_approval(chat_client_base: SupportsChatGetResponse): + @tool(name="approval_func", approval_mode="always_require") + def func_with_approval(arg1: str) -> str: + return f"Approved {arg1}" + + informational_call = Content.from_function_call( + call_id="1", + name="approval_func", + arguments='{"arg1": "value1"}', + informational_only=True, + ) + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=[informational_call])) + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], + options={"tool_choice": "auto", "tools": [func_with_approval]}, + ) + + assert response.messages[0].contents == [informational_call] + assert not any(content.type == "function_approval_request" for content in response.messages[0].contents) + + +async def test_streaming_informational_only_function_call_is_not_invoked(chat_client_base: SupportsChatGetResponse): + exec_counter = 0 + + @tool(name="no_approval_func", approval_mode="never_require") + def func_no_approval(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + informational_call = Content.from_function_call( + call_id="1", + name="no_approval_func", + arguments='{"arg1": "value1"}', + informational_only=True, + ) + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ChatResponseUpdate(contents=[informational_call], role="assistant")] + ] + + updates = [ + update + async for update in chat_client_base.get_response( + [Message(role="user", contents=["hello"])], + options={"tool_choice": "auto", "tools": [func_no_approval]}, + stream=True, + ) + ] + + assert exec_counter == 0 + assert len(updates) == 1 + assert updates[0].contents == [informational_call] + + async def test_rejected_approval(chat_client_base: SupportsChatGetResponse): """Test that rejecting an approval alongside an approved one is handled correctly.""" diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index edf19b3ff8..8e83454685 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -332,6 +332,7 @@ def test_mcp_server_tool_call_and_result(): call = Content.from_mcp_server_tool_call(call_id="c-1", tool_name="tool", server_name="server", arguments={"x": 1}) assert call.type == "mcp_server_tool_call" assert call.arguments == {"x": 1} + assert call.informational_only is True result = Content.from_mcp_server_tool_result(call_id="c-1", output=[{"type": "text", "text": "done"}]) assert result.type == "mcp_server_tool_result" @@ -342,6 +343,17 @@ def test_mcp_server_tool_call_and_result(): assert call2.call_id == "" +def test_mcp_server_tool_call_is_always_informational_only(): + direct = Content("mcp_server_tool_call", call_id="c-1", tool_name="tool", informational_only=False) + assert direct.informational_only is True + + serialized = direct.to_dict() + assert "informational_only" not in serialized + + restored = Content.from_dict({**serialized, "informational_only": False}) + assert restored.informational_only is True + + # region: Shell tool content @@ -496,11 +508,27 @@ def test_function_call_content(): assert content.type == "function_call" assert content.name == "example_function" assert content.arguments == {"param1": "value1"} + assert content.informational_only is False # Ensure the instance is of type BaseContent assert isinstance(content, Content) +def test_function_call_content_informational_only_serialization(): + content = Content.from_function_call( + call_id="1", + name="example_function", + arguments={"param1": "value1"}, + informational_only=True, + ) + + assert content.informational_only is True + assert content.to_dict()["informational_only"] is True + assert Content.from_dict(content.to_dict()).informational_only is True + assert "informational_only" not in Content.from_function_call(call_id="1", name="f").to_dict() + assert "informational_only" not in Content.from_text("hello").to_dict(exclude_none=False) + + def test_function_call_content_parse_arguments(): c1 = Content.from_function_call(call_id="1", name="f", arguments='{"a": 1, "b": 2}') assert c1.parse_arguments() == {"a": 1, "b": 2} @@ -523,6 +551,12 @@ def test_function_call_content_add_merging_and_errors(): c = a + b assert c.arguments == {"x": 1, "y": 2} + # informational_only is preserved across streamed chunks + a = Content.from_function_call(call_id="1", name="f", arguments='{"x":', informational_only=True) + b = Content.from_function_call(call_id="1", name="f", arguments="1}") + c = a + b + assert c.informational_only is True + # incompatible argument types a = Content.from_function_call(call_id="1", name="f", arguments="abc") b = Content.from_function_call(call_id="1", name="f", arguments={"y": 2}) @@ -691,6 +725,7 @@ def test_function_approval_accepts_mcp_call(): assert isinstance(req.function_call, Content) assert req.function_call.call_id == "c-mcp" + assert req.function_call.informational_only is True # region BaseContent Serialization diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index b0b0955844..12fce3ef63 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -1094,7 +1094,13 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No fc = cast(ItemFunctionToolCall, item) return Message( role="assistant", - contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)], + contents=[ + Content.from_function_call( + fc.call_id, + fc.name, + arguments=fc.arguments, + ) + ], ) if item.type == "function_call_output": @@ -1237,6 +1243,7 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No fs.id, "file_search", arguments=json.dumps({"queries": fs.queries}), + informational_only=True, ) ], ) @@ -1245,7 +1252,7 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No ws = cast(ItemWebSearchToolCall, item) return Message( role="assistant", - contents=[Content.from_function_call(ws.id, "web_search")], + contents=[Content.from_function_call(ws.id, "web_search", informational_only=True)], ) if item.type == "computer_call": @@ -1257,6 +1264,7 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No cc.call_id, "computer_use", arguments=str(cc.action), + informational_only=True, ) ], ) @@ -1272,7 +1280,14 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No ct = cast(ItemCustomToolCall, item) return Message( role="assistant", - contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)], + contents=[ + Content.from_function_call( + ct.call_id, + ct.name, + arguments=ct.input, + informational_only=True, + ) + ], ) if item.type == "custom_tool_call_output": @@ -1304,6 +1319,7 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No ap.call_id, "apply_patch", arguments=str(ap.operation), + informational_only=True, ) ], ) @@ -1367,7 +1383,13 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva fc = cast(OutputItemFunctionToolCall, item) return Message( role="assistant", - contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)], + contents=[ + Content.from_function_call( + fc.call_id, + fc.name, + arguments=fc.arguments, + ) + ], ) if item.type == "function_call_output": @@ -1511,6 +1533,7 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva fs.id, "file_search", arguments=json.dumps({"queries": fs.queries}), + informational_only=True, ) ], ) @@ -1519,7 +1542,7 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva ws = cast(OutputItemWebSearchToolCall, item) return Message( role="assistant", - contents=[Content.from_function_call(ws.id, "web_search")], + contents=[Content.from_function_call(ws.id, "web_search", informational_only=True)], ) if item.type == "computer_call": @@ -1531,6 +1554,7 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva cc.call_id, "computer_use", arguments=str(cc.action), + informational_only=True, ) ], ) @@ -1546,7 +1570,14 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva ct = cast(OutputItemCustomToolCall, item) return Message( role="assistant", - contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)], + contents=[ + Content.from_function_call( + ct.call_id, + ct.name, + arguments=ct.input, + informational_only=True, + ) + ], ) if item.type == "custom_tool_call_output": @@ -1576,6 +1607,7 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva ap.call_id, "apply_patch", arguments=str(ap.operation), + informational_only=True, ) ], ) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 619ff5c0a5..b977016955 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -791,6 +791,7 @@ async def test_function_call(self) -> None: assert msg.contents[0].type == "function_call" assert msg.contents[0].call_id == "call_1" assert msg.contents[0].name == "get_weather" + assert msg.contents[0].informational_only is False async def test_function_call_output(self) -> None: from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam @@ -1004,6 +1005,7 @@ async def test_file_search_call(self) -> None: assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "file_search" assert '"what is AI"' in (msg.contents[0].arguments or "") + assert msg.contents[0].informational_only is True async def test_web_search_call(self) -> None: from azure.ai.agentserver.responses.models import OutputItemWebSearchToolCall, WebSearchActionSearch @@ -1018,6 +1020,7 @@ async def test_web_search_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "web_search" + assert msg.contents[0].informational_only is True async def test_computer_call(self) -> None: from azure.ai.agentserver.responses.models import ComputerAction, OutputItemComputerToolCall @@ -1034,6 +1037,7 @@ async def test_computer_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "computer_use" + assert msg.contents[0].informational_only is True async def test_computer_call_output(self) -> None: from azure.ai.agentserver.responses.models import ( @@ -1068,6 +1072,7 @@ async def test_custom_tool_call(self) -> None: assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "my_tool" assert msg.contents[0].arguments == '{"key": "value"}' + assert msg.contents[0].informational_only is True async def test_custom_tool_call_output(self) -> None: from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput @@ -1124,6 +1129,7 @@ async def test_apply_patch_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "apply_patch" + assert msg.contents[0].informational_only is True async def test_apply_patch_call_output(self) -> None: from azure.ai.agentserver.responses.models import OutputItemApplyPatchToolCallOutput @@ -1265,6 +1271,7 @@ async def test_function_call(self) -> None: assert msg.contents[0].call_id == "call_1" assert msg.contents[0].name == "get_weather" assert msg.contents[0].arguments == '{"city": "NYC"}' + assert msg.contents[0].informational_only is False async def test_function_call_output(self) -> None: from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam @@ -1492,6 +1499,7 @@ async def test_file_search_call(self) -> None: assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "file_search" assert '"what is AI"' in (msg.contents[0].arguments or "") + assert msg.contents[0].informational_only is True async def test_web_search_call(self) -> None: from azure.ai.agentserver.responses.models import ItemWebSearchToolCall @@ -1506,6 +1514,7 @@ async def test_web_search_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "web_search" + assert msg.contents[0].informational_only is True async def test_computer_call(self) -> None: from azure.ai.agentserver.responses.models import ComputerAction, ItemComputerToolCall @@ -1523,6 +1532,7 @@ async def test_computer_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "computer_use" + assert msg.contents[0].informational_only is True async def test_computer_call_output(self) -> None: from azure.ai.agentserver.responses.models import ComputerCallOutputItemParam, ComputerScreenshotImage @@ -1556,6 +1566,7 @@ async def test_custom_tool_call(self) -> None: assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "my_tool" assert msg.contents[0].arguments == '{"key": "value"}' + assert msg.contents[0].informational_only is True async def test_custom_tool_call_output(self) -> None: from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput @@ -1626,6 +1637,7 @@ async def test_apply_patch_call(self) -> None: assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "apply_patch" + assert msg.contents[0].informational_only is True async def test_apply_patch_call_output(self) -> None: from azure.ai.agentserver.responses.models import ApplyPatchToolCallOutputItemParam diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index dfe4a04df1..69310cdd99 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -669,6 +669,21 @@ def _convert_message_contents( parts.append(types.Part(text=content.text or "")) case "function_call": call_id = content.call_id or self._generate_tool_call_id() + raw_part = content.raw_representation + if ( + content.informational_only + and isinstance(raw_part, types.Part) + and raw_part.tool_call is not None + ): + tool_call = raw_part.tool_call.model_copy( + update={ + "id": call_id, + "args": content.parse_arguments() or {}, + }, + deep=True, + ) + parts.append(raw_part.model_copy(update={"tool_call": tool_call}, deep=True)) + continue if content.name: call_id_to_name[call_id] = content.name function_call = types.FunctionCall( @@ -676,11 +691,16 @@ def _convert_message_contents( name=content.name or "", args=content.parse_arguments() or {}, ) - raw_part = content.raw_representation if isinstance(raw_part, types.Part) and raw_part.function_call is not None: parts.append(raw_part.model_copy(update={"function_call": function_call}, deep=True)) else: parts.append(types.Part(function_call=function_call)) + case "function_result": + raw_part = content.raw_representation + if isinstance(raw_part, types.Part) and raw_part.tool_response is not None: + parts.append(raw_part) + else: + logger.debug("Skipping unsupported content type for Gemini: %s", content.type) case "data" | "uri": part = self._convert_data_or_uri_content(content) if part is not None: @@ -749,6 +769,10 @@ def _convert_function_result( if content.type != "function_result": return None + raw_part = content.raw_representation + if isinstance(raw_part, types.Part) and raw_part.tool_response is not None: + return raw_part + name = call_id_to_name.get(content.call_id or "") if not name: logger.warning( @@ -1048,6 +1072,35 @@ def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]: continue if part.text is not None: contents.append(Content.from_text(text=part.text, raw_representation=part)) + elif part.tool_call is not None: + tool_call = part.tool_call + if tool_call.id: + call_id = tool_call.id + else: + call_id = self._generate_tool_call_id() + logger.debug("tool_call missing id; generated fallback call_id=%r", call_id) + if isinstance(tool_call.tool_type, types.ToolType): + tool_name = tool_call.tool_type.value + else: + tool_name = str(tool_call.tool_type or "tool_call") + contents.append( + Content.from_function_call( + call_id=call_id, + name=tool_name, + arguments=tool_call.args or {}, + informational_only=True, + raw_representation=part, + ) + ) + elif part.tool_response is not None: + tool_response = part.tool_response + contents.append( + Content.from_function_result( + call_id=tool_response.id or self._generate_tool_call_id(), + result=tool_response.response, + raw_representation=part, + ) + ) elif part.function_call is not None: function_call = part.function_call if function_call.id: diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 5e9730296c..1b66721c5d 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -46,6 +46,8 @@ def _make_part( text: str | None = None, thought: bool = False, function_call: tuple[str | None, str, dict[str, Any]] | None = None, + tool_call: tuple[str | None, types.ToolType, dict[str, Any]] | None = None, + tool_response: tuple[str | None, types.ToolType, dict[str, Any]] | None = None, executable_code: str | None = None, code_execution_result: str | None = None, ) -> MagicMock: @@ -55,6 +57,8 @@ def _make_part( text: Text content of the part. thought: Whether this is a thinking/reasoning part. function_call: Tuple of (id, name, args) if this is a function call part. + tool_call: Tuple of (id, tool_type, args) if this is a server-side tool call part. + tool_response: Tuple of (id, tool_type, response) if this is a server-side tool response part. executable_code: Source code string for a code execution part. code_execution_result: Output string for a code execution result part. """ @@ -62,6 +66,8 @@ def _make_part( part.text = text part.thought = thought part.function_response = None + part.tool_call = None + part.tool_response = None part.executable_code = None part.code_execution_result = None @@ -72,6 +78,16 @@ def _make_part( else: part.function_call = None + if tool_call: + mock_tool_call = MagicMock() + mock_tool_call.id, mock_tool_call.tool_type, mock_tool_call.args = tool_call + part.tool_call = mock_tool_call + + if tool_response: + mock_tool_response = MagicMock() + mock_tool_response.id, mock_tool_response.tool_type, mock_tool_response.response = tool_response + part.tool_response = mock_tool_response + if executable_code is not None: mock_exec = MagicMock() mock_exec.code = executable_code @@ -725,6 +741,73 @@ def test_function_call_part_preserves_thought_signature_from_raw_part() -> None: assert parts[0].function_call.args == {"location": "Paris"} +def test_server_side_tool_call_part_is_informational_only() -> None: + """Server-side Gemini tool calls are transcript content, not local function invocation requests.""" + client, _ = _make_gemini_client() + raw_part = types.Part( + tool_call=types.ToolCall(id="search-1", tool_type=types.ToolType.FILE_SEARCH, args={"query": "docs"}) + ) + + contents = client._parse_parts([raw_part]) + + assert len(contents) == 1 + assert contents[0].type == "function_call" + assert contents[0].call_id == "search-1" + assert contents[0].name == "FILE_SEARCH" + assert contents[0].parse_arguments() == {"query": "docs"} + assert contents[0].informational_only is True + + +def test_server_side_tool_call_part_preserves_raw_part_on_replay() -> None: + """Informational server-side calls must round-trip as Gemini tool_call parts, not function_call parts.""" + client, _ = _make_gemini_client() + raw_part = types.Part( + tool_call=types.ToolCall(id="search-1", tool_type=types.ToolType.FILE_SEARCH, args={"query": "docs"}) + ) + content = Content.from_function_call( + call_id="search-1", + name="FILE_SEARCH", + arguments={"query": "docs"}, + informational_only=True, + raw_representation=raw_part, + ) + + parts = client._convert_message_contents([content], {}) + + assert len(parts) == 1 + assert parts[0].tool_call is not None + assert parts[0].function_call is None + assert parts[0].tool_call.id == "search-1" + assert parts[0].tool_call.tool_type == types.ToolType.FILE_SEARCH + assert parts[0].tool_call.args == {"query": "docs"} + + +def test_server_side_tool_response_part_preserves_raw_part_on_replay() -> None: + """Server-side Gemini tool responses stay in their native tool_response representation.""" + client, _ = _make_gemini_client() + raw_part = types.Part( + tool_response=types.ToolResponse( + id="search-1", + tool_type=types.ToolType.FILE_SEARCH, + response={"results": ["doc"]}, + ) + ) + content = Content.from_function_result( + call_id="search-1", + result={"results": ["doc"]}, + raw_representation=raw_part, + ) + + parts = client._convert_message_contents([content], {}) + + assert len(parts) == 1 + assert parts[0].tool_response is not None + assert parts[0].function_response is None + assert parts[0].tool_response.id == "search-1" + assert parts[0].tool_response.tool_type == types.ToolType.FILE_SEARCH + assert parts[0].tool_response.response == {"results": ["doc"]} + + # multimodal (data/uri) parts @@ -892,6 +975,8 @@ async def test_unknown_part_type_is_skipped() -> None: unknown_part.text = None unknown_part.function_call = None unknown_part.function_response = None + unknown_part.tool_call = None + unknown_part.tool_response = None unknown_part.executable_code = None unknown_part.code_execution_result = None mock.aio.models.generate_content = AsyncMock(return_value=_make_response([unknown_part, _make_part(text="Hi")])) @@ -910,6 +995,8 @@ async def test_empty_executable_code_part_is_skipped() -> None: mock_part.thought = False mock_part.function_call = None mock_part.function_response = None + mock_part.tool_call = None + mock_part.tool_response = None mock_part.code_execution_result = None mock_part.executable_code = MagicMock() mock_part.executable_code.code = "" @@ -1852,6 +1939,8 @@ async def test_function_response_part_in_response_mapped_to_content() -> None: part.text = None part.thought = False part.function_call = None + part.tool_call = None + part.tool_response = None part.function_response = MagicMock() part.function_response.id = "call-99" part.function_response.response = {"result": "done"} diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 740b484ac3..fa0027ea85 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -64,7 +64,7 @@ ) from agent_framework.observability import ChatTelemetryLayer from openai import AsyncAzureOpenAI, AsyncOpenAI, BadRequestError -from openai.types.responses import FunctionShellToolParam +from openai.types.responses import FunctionShellToolParam, ResponseCustomToolCall, ResponseToolSearchCall from openai.types.responses.file_search_tool_param import FileSearchToolParam from openai.types.responses.function_tool_param import FunctionToolParam from openai.types.responses.parsed_response import ( @@ -2281,6 +2281,38 @@ def _parse_search_tool_result_content(self, item: Any) -> Content: raw_representation=item, ) + def _parse_hosted_function_call_content( + self, + item: ResponseCustomToolCall | ResponseToolSearchCall, + *, + name: str, + arguments: Any = None, + ) -> Content: + """Create informational-only function call content for hosted Responses items.""" + additional_properties: dict[str, Any] = {"item_type": item.type} + match item.type: + case "custom_tool_call": + call_id = item.call_id + if item.id: + additional_properties["item_id"] = item.id + if item.namespace: + additional_properties["namespace"] = item.namespace + case "tool_search_call": + call_id = item.call_id or item.id + additional_properties["item_id"] = item.id + additional_properties["status"] = item.status + additional_properties["execution"] = item.execution + if item.created_by: + additional_properties["created_by"] = item.created_by + return Content.from_function_call( + call_id=call_id, + name=name, + arguments=self._serialize_provider_payload(arguments), + informational_only=True, + additional_properties=additional_properties, + raw_representation=item, + ) + # region Parse methods def _parse_response_from_openai( self, @@ -2486,6 +2518,18 @@ def _parse_response_from_openai( raw_representation=item, ) ) + case "custom_tool_call": + contents.append( + self._parse_hosted_function_call_content(item, name=item.name, arguments=item.input) + ) + case "tool_search_call": + contents.append( + self._parse_hosted_function_call_content( + item, + name="tool_search", + arguments=item.arguments, + ) + ) case "web_search_call" | "file_search_call": contents.append(self._parse_search_tool_call_content(item)) contents.append(self._parse_search_tool_result_content(item)) @@ -3084,6 +3128,24 @@ def _get_ann_value(key: str) -> Any: # Shell items are parsed here (not on `response.output_item.added`) because the # command/output is only populated on the completed item. contents.extend(self._shell_item_to_contents(done_item, local_shell_tool_name)) + elif getattr(done_item, "type", None) == "custom_tool_call": + custom_tool_call = cast(ResponseCustomToolCall, done_item) + contents.append( + self._parse_hosted_function_call_content( + custom_tool_call, + name=custom_tool_call.name, + arguments=custom_tool_call.input, + ) + ) + elif getattr(done_item, "type", None) == "tool_search_call": + tool_search_call = cast(ResponseToolSearchCall, done_item) + contents.append( + self._parse_hosted_function_call_content( + tool_search_call, + name="tool_search", + arguments=tool_search_call.arguments, + ) + ) elif getattr(done_item, "type", None) == _AZURE_AI_SEARCH_CALL_OUTPUT_TYPE: pass case _: diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 108c121cb7..67cc0496fe 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1775,6 +1775,74 @@ def test_response_content_creation_with_function_call() -> None: assert function_call.call_id == "call_123" assert function_call.name == "get_weather" assert function_call.arguments == '{"location": "Seattle"}' + assert function_call.informational_only is False + + +def test_parse_response_from_openai_with_custom_tool_call_is_informational_only() -> None: + """Custom tool calls are hosted Responses items, not local Agent Framework function calls.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + + mock_custom_call_item = MagicMock() + mock_custom_call_item.type = "custom_tool_call" + mock_custom_call_item.id = "ctc_456" + mock_custom_call_item.call_id = "call_123" + mock_custom_call_item.name = "code_exec" + mock_custom_call_item.input = "print('hello')" + mock_custom_call_item.namespace = None + + mock_response.output = [mock_custom_call_item] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + assert len(response.messages[0].contents) == 1 + function_call = response.messages[0].contents[0] + assert function_call.type == "function_call" + assert function_call.call_id == "call_123" + assert function_call.name == "code_exec" + assert function_call.arguments == "print('hello')" + assert function_call.informational_only is True + + +def test_parse_response_from_openai_with_tool_search_call_is_informational_only() -> None: + """Hosted tool-search calls are transcript items and must not be invoked locally.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + + mock_tool_search_item = MagicMock() + mock_tool_search_item.type = "tool_search_call" + mock_tool_search_item.id = "ts_456" + mock_tool_search_item.call_id = "call_123" + mock_tool_search_item.arguments = {"query": "weather tools"} + mock_tool_search_item.status = "completed" + mock_tool_search_item.execution = "server" + mock_tool_search_item.created_by = None + + mock_response.output = [mock_tool_search_item] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + assert len(response.messages[0].contents) == 1 + function_call = response.messages[0].contents[0] + assert function_call.type == "function_call" + assert function_call.call_id == "call_123" + assert function_call.name == "tool_search" + assert function_call.arguments == {"query": "weather tools"} + assert function_call.informational_only is True def test_parse_response_from_openai_with_web_search_call() -> None: @@ -2034,6 +2102,67 @@ def test_parse_chunk_from_openai_with_web_search_call_added() -> None: assert content.arguments == {"type": "search", "query": "weather in Seattle"} +def test_parse_chunk_from_openai_function_call_is_actionable() -> None: + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + + added_event = MagicMock() + added_event.type = "response.output_item.added" + added_event.output_index = 0 + added_item = MagicMock() + added_item.type = "function_call" + added_item.call_id = "call_123" + added_item.name = "get_weather" + added_event.item = added_item + + delta_event = MagicMock() + delta_event.type = "response.function_call_arguments.delta" + delta_event.output_index = 0 + delta_event.delta = '{"location": "Seattle"}' + delta_event.item_id = "fc_456" + + client._parse_chunk_from_openai( + added_event, + options=chat_options, + function_call_ids=function_call_ids, + ) + update = client._parse_chunk_from_openai( + delta_event, + options=chat_options, + function_call_ids=function_call_ids, + ) + + assert len(update.contents) == 1 + assert update.contents[0].type == "function_call" + assert update.contents[0].informational_only is False + + +def test_parse_chunk_from_openai_custom_tool_call_done_is_informational_only() -> None: + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "custom_tool_call" + mock_item.id = "ctc_456" + mock_item.call_id = "call_123" + mock_item.name = "code_exec" + mock_item.input = "print('hello')" + mock_item.namespace = None + mock_event.item = mock_item + + update = client._parse_chunk_from_openai(mock_event, options=chat_options, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + assert update.contents[0].type == "function_call" + assert update.contents[0].name == "code_exec" + assert update.contents[0].informational_only is True + + def test_parse_chunk_from_openai_with_file_search_call_done() -> None: """Test that response.output_item.done for file_search_call emits search tool result content.""" client = OpenAIChatClient(model="test-model", api_key="test-key") From ad325aee36389373383357e47ce195673cc45794 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 19:39:18 +0200 Subject: [PATCH 2/3] Python: Address informational-only review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_tools.py | 6 +--- .../core/test_function_invocation_logic.py | 30 ++++++++++++++++++ .../agent_framework_gemini/_chat_client.py | 18 +++++++++-- .../gemini/tests/test_gemini_client.py | 31 ++++++++++++++++++- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a3d9bf1400..ae4c1ea920 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1659,11 +1659,7 @@ async def _try_execute_function_calls( """ from ._types import Content - function_calls = [ - function_call - for function_call in function_calls - if function_call.type != "function_call" or _is_actionable_function_call(function_call) - ] + function_calls = [function_call for function_call in function_calls if _is_actionable_function_call(function_call)] if not function_calls: return ([], False) 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 609d12eb6b..6426fade63 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -796,6 +796,36 @@ def func_no_approval(arg1: str) -> str: assert response.messages[0].contents == [informational_call] +async def test_only_actionable_function_calls_are_invoked(chat_client_base: SupportsChatGetResponse): + exec_counter = 0 + + @tool(name="no_approval_func", approval_mode="never_require") + def func_no_approval(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + informational_call = Content.from_function_call( + call_id="1", + name="no_approval_func", + arguments='{"arg1": "value1"}', + informational_only=True, + ) + text_content = Content.from_text("Provider transcript text") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=[informational_call, text_content])) + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], + options={"tool_choice": "auto", "tools": [func_no_approval]}, + ) + + assert exec_counter == 0 + assert len(response.messages) == 1 + assert response.messages[0].contents == [informational_call, text_content] + + async def test_informational_only_function_call_does_not_request_approval(chat_client_base: SupportsChatGetResponse): @tool(name="approval_func", approval_mode="always_require") def func_with_approval(arg1: str) -> str: diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 69310cdd99..a627af63f9 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -698,7 +698,14 @@ def _convert_message_contents( case "function_result": raw_part = content.raw_representation if isinstance(raw_part, types.Part) and raw_part.tool_response is not None: - parts.append(raw_part) + tool_response = raw_part.tool_response.model_copy( + update={ + "id": content.call_id or self._generate_tool_call_id(), + "response": content.result, + }, + deep=True, + ) + parts.append(raw_part.model_copy(update={"tool_response": tool_response}, deep=True)) else: logger.debug("Skipping unsupported content type for Gemini: %s", content.type) case "data" | "uri": @@ -771,7 +778,14 @@ def _convert_function_result( raw_part = content.raw_representation if isinstance(raw_part, types.Part) and raw_part.tool_response is not None: - return raw_part + tool_response = raw_part.tool_response.model_copy( + update={ + "id": content.call_id or self._generate_tool_call_id(), + "response": content.result, + }, + deep=True, + ) + return raw_part.model_copy(update={"tool_response": tool_response}, deep=True) name = call_id_to_name.get(content.call_id or "") if not name: diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 1b66721c5d..d3ad53c5ab 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -805,7 +805,36 @@ def test_server_side_tool_response_part_preserves_raw_part_on_replay() -> None: assert parts[0].function_response is None assert parts[0].tool_response.id == "search-1" assert parts[0].tool_response.tool_type == types.ToolType.FILE_SEARCH - assert parts[0].tool_response.response == {"results": ["doc"]} + assert parts[0].tool_response.response == '{"results": ["doc"]}' + + +def test_server_side_tool_response_part_uses_content_values_on_replay() -> None: + """Server-side Gemini tool responses replay the framework call ID and result.""" + client, _ = _make_gemini_client() + raw_part = types.Part( + tool_response=types.ToolResponse( + id=None, + tool_type=types.ToolType.FILE_SEARCH, + response={"results": ["raw"]}, + ) + ) + content = Content.from_function_result( + call_id="generated-search-id", + result={"results": ["content"]}, + raw_representation=raw_part, + ) + + parts = client._convert_message_contents([content], {}) + + assert len(parts) == 1 + assert parts[0].tool_response is not None + assert parts[0].function_response is None + assert parts[0].tool_response.id == "generated-search-id" + assert parts[0].tool_response.tool_type == types.ToolType.FILE_SEARCH + assert parts[0].tool_response.response == '{"results": ["content"]}' + assert raw_part.tool_response is not None + assert raw_part.tool_response.id is None + assert raw_part.tool_response.response == {"results": ["raw"]} # multimodal (data/uri) parts From 057d10351c46b3441f23cec0ee92ed805c605de1 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 8 Jul 2026 19:45:30 +0200 Subject: [PATCH 3/3] Python: Preserve approval responses in tool invocation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_tools.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index ae4c1ea920..b18edb65db 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1659,7 +1659,11 @@ async def _try_execute_function_calls( """ from ._types import Content - function_calls = [function_call for function_call in function_calls if _is_actionable_function_call(function_call)] + function_calls = [ + function_call + for function_call in function_calls + if function_call.type == "function_approval_response" or _is_actionable_function_call(function_call) + ] if not function_calls: return ([], False)