From d1736324fbbb95955b996f137d32304236be3594 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 5 Jun 2026 20:51:21 +0100 Subject: [PATCH 01/14] Python: Add function_choice_behavior support to Azure AI and OpenAI Assistant agents Add function_choice_behavior parameter to invoke() and invoke_stream() methods in Azure AI and OpenAI Assistant agent thread actions, aligning them with the existing Responses agent and Chat Completion agent APIs. Changes: - Add function_choice_behavior parameter to invoke/invoke_stream in both AzureAIAgentThreadActions and AssistantThreadActions - Support tools parameter override for SDK-level tools (CodeInterpreter, FileSearch, etc.) - Filter kernel functions based on FunctionChoiceBehavior configuration - Validate that only Auto type with auto_invoke enabled is supported - Fix _get_tools in OpenAI path to use passed kernel parameter - Add comprehensive unit tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai/agent_thread_actions.py | 87 +++++++- .../open_ai/assistant_thread_actions.py | 83 +++++++- .../test_agent_thread_actions.py | 165 ++++++++++++++++ .../test_assistant_thread_actions.py | 185 ++++++++++++++++++ 4 files changed, 501 insertions(+), 19 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 62fb798bb11b..c208897a4a75 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -67,6 +67,8 @@ from semantic_kernel.agents.open_ai.assistant_content_generation import merge_streaming_function_results from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior +from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.function_call_content import FunctionCallContent @@ -124,6 +126,7 @@ async def invoke( parallel_tool_calls: bool | None = None, metadata: dict[str, str] | None = None, polling_options: RunPollingOptions | None = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]: """Invoke the message in the thread. @@ -139,7 +142,9 @@ async def invoke( additional_messages: The additional messages to add to the thread. Only supports messages with role = User or Assistant. https://platform.openai.com/docs/api-reference/runs/createRun#runs-createrun-additional_messages - tools: The tools. + tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch, AzureAISearch). When provided, + overrides the tools from the agent definition. Does not affect kernel function availability; + use function_choice_behavior for that. temperature: The temperature. top_p: The top p. max_prompt_tokens: The max prompt tokens. @@ -150,6 +155,9 @@ async def invoke( metadata: The metadata. polling_options: The polling options defined at the run-level. These will override the agent-level polling options. + function_choice_behavior: Controls which kernel functions are allowed to execute during this run. + Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific + functions. Only Auto is supported; other types will raise an error. kwargs: Additional keyword arguments. Returns: @@ -158,7 +166,10 @@ async def invoke( arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs) kernel = kernel or agent.kernel - tools = cls._get_tools(agent=agent, kernel=kernel) # type: ignore + cls._validate_function_choice_behavior(function_choice_behavior) + + tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, + function_choice_behavior=function_choice_behavior) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -232,7 +243,7 @@ async def invoke( chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"] _ = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments + kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, function_choice_behavior=function_choice_behavior ) tool_outputs = cls._format_tool_outputs(fccs, chat_history) @@ -467,6 +478,7 @@ async def invoke_stream( temperature: float | None = None, top_p: float | None = None, truncation_strategy: TruncationObject | None = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> AsyncIterable["StreamingChatMessageContent"]: """Invoke the agent stream and yield ChatMessageContent continuously. @@ -489,10 +501,15 @@ async def invoke_stream( formed from the streamed chunks. parallel_tool_calls: Whether to configure parallel tool calls. response_format: The response format. - tools: The tools. + tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch, AzureAISearch). When provided, + overrides the tools from the agent definition. Does not affect kernel function availability; + use function_choice_behavior for that. temperature: The temperature. top_p: The top p. truncation_strategy: The truncation strategy. + function_choice_behavior: Controls which kernel functions are allowed to execute during this run. + Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific + functions. Only Auto is supported; other types will raise an error. kwargs: Additional keyword arguments. Returns: @@ -502,7 +519,10 @@ async def invoke_stream( kernel = kernel or agent.kernel arguments = agent._merge_arguments(arguments) - tools = cls._get_tools(agent=agent, kernel=kernel) # type: ignore + cls._validate_function_choice_behavior(function_choice_behavior) + + tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, + function_choice_behavior=function_choice_behavior) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -549,6 +569,7 @@ async def invoke_stream( arguments=arguments, function_steps=function_steps, active_messages=active_messages, + function_choice_behavior=function_choice_behavior, ): if content: yield content @@ -564,6 +585,7 @@ async def _process_stream_events( function_steps: dict[str, FunctionCallContent], active_messages: dict[str, RunStep], output_messages: "list[ChatMessageContent] | None" = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, ) -> AsyncIterable["StreamingChatMessageContent"]: """Process events from the main stream and delegate tool output handling as needed.""" thread_msg_id = None @@ -671,6 +693,7 @@ async def _process_stream_events( run=run, function_steps=function_steps, arguments=arguments, + function_choice_behavior=function_choice_behavior, ) if action_result is None: raise RuntimeError( @@ -959,11 +982,51 @@ def _deduplicate_tools(existing_tools: list[dict], new_tools: list[dict]) -> lis } return [tool for tool in new_tools if tool.get("function", {}).get("name") not in existing_names] + @staticmethod + def _validate_function_choice_behavior( + function_choice_behavior: FunctionChoiceBehavior | None, + ) -> None: + """Validate the function choice behavior is compatible with agent invocations.""" + if function_choice_behavior is None: + return + if function_choice_behavior.type_ != FunctionChoiceType.AUTO: + raise AgentInvokeException( + f"FunctionChoiceBehavior with type '{function_choice_behavior.type_}' is not supported for agent " + "invocations. Use FunctionChoiceBehavior.Auto(filters=...) to control which kernel functions " + "are available." + ) + if not function_choice_behavior.auto_invoke_kernel_functions: + raise AgentInvokeException( + "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " + "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." + ) + @classmethod - def _get_tools(cls: type[_T], agent: "AzureAIAgent", kernel: "Kernel") -> list[dict[str, Any] | ToolDefinition]: - """Get the tools for the agent.""" - tools: list[Any] = list(agent.definition.tools) - funcs = kernel.get_full_list_of_function_metadata() + def _get_tools( + cls: type[_T], + agent: "AzureAIAgent", + kernel: "Kernel", + tools_override: list[ToolDefinition] | None = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, + ) -> list[dict[str, Any] | ToolDefinition]: + """Get the tools for the agent. + + Args: + agent: The agent instance. + kernel: The kernel to use for function metadata. + tools_override: When provided, overrides agent.definition.tools (SDK-level tools only). + function_choice_behavior: When provided, filters which kernel functions are included. + """ + tools: list[Any] = list(tools_override) if tools_override is not None else list(agent.definition.tools) + + # Determine kernel function metadata based on function_choice_behavior + if function_choice_behavior is not None and not function_choice_behavior.enable_kernel_functions: + funcs = [] + elif function_choice_behavior is not None and function_choice_behavior.filters: + funcs = kernel.get_list_of_function_metadata(function_choice_behavior.filters) + else: + funcs = kernel.get_full_list_of_function_metadata() + cls._validate_function_tools_registered(tools, funcs) dict_defs = [kernel_function_metadata_to_function_call_format(f) for f in funcs] deduped_defs = cls._deduplicate_tools(tools, dict_defs) @@ -1071,6 +1134,7 @@ async def _invoke_function_calls( fccs: list["FunctionCallContent"], chat_history: "ChatHistory", arguments: KernelArguments, + function_choice_behavior: FunctionChoiceBehavior | None = None, ) -> list["AutoFunctionInvocationContext | None"]: """Invoke the function calls.""" return await asyncio.gather( @@ -1079,6 +1143,7 @@ async def _invoke_function_calls( function_call=function_call, chat_history=chat_history, arguments=arguments, + function_behavior=function_choice_behavior, ) for function_call in fccs ], @@ -1111,6 +1176,7 @@ async def _handle_streaming_requires_action( run: ThreadRun, function_steps: dict[str, "FunctionCallContent"], arguments: KernelArguments, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> FunctionActionResult | None: """Handle the requires action event for a streaming run.""" @@ -1121,7 +1187,8 @@ async def _handle_streaming_requires_action( chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"] results = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments + kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, + function_choice_behavior=function_choice_behavior, ) function_result_streaming_content = merge_streaming_function_results( diff --git a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py index 3a6679df643f..1d2146232a04 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py +++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py @@ -33,6 +33,8 @@ ) from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior +from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format from semantic_kernel.contents.file_reference_content import FileReferenceContent from semantic_kernel.contents.function_call_content import FunctionCallContent @@ -154,6 +156,7 @@ async def invoke( top_p: float | None = None, truncation_strategy: "TruncationStrategy | None" = None, polling_options: RunPollingOptions | None = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]: """Invoke the assistant. @@ -173,12 +176,17 @@ async def invoke( parallel_tool_calls: The parallel tool calls. reasoning_effort: The reasoning effort. response_format: The response format. - tools: The tools. + tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch). When provided, + overrides the tools from the agent definition. Does not affect kernel function availability; + use function_choice_behavior for that. temperature: The temperature. top_p: The top p. truncation_strategy: The truncation strategy. polling_options: The polling options defined at the run-level. These will override the agent-level polling options. + function_choice_behavior: Controls which kernel functions are allowed to execute during this run. + Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific + functions. Only Auto is supported; other types will raise an error. kwargs: Additional keyword arguments. Returns: @@ -187,7 +195,10 @@ async def invoke( arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs) kernel = kernel or agent.kernel - tools = cls._get_tools(agent=agent, kernel=kernel) # type: ignore + cls._validate_function_choice_behavior(function_choice_behavior) + + tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, + function_choice_behavior=function_choice_behavior) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -260,7 +271,8 @@ async def invoke( chat_history = ChatHistory() _ = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments + kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, + function_choice_behavior=function_choice_behavior, ) tool_outputs = cls._format_tool_outputs(fccs, chat_history) @@ -374,6 +386,7 @@ async def invoke_stream( temperature: float | None = None, top_p: float | None = None, truncation_strategy: "TruncationStrategy | None" = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> AsyncIterable["StreamingChatMessageContent"]: """Invoke the assistant. @@ -396,10 +409,15 @@ async def invoke_stream( parallel_tool_calls: The parallel tool calls. reasoning_effort: The reasoning effort. response_format: The response format. - tools: The tools. + tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch). When provided, + overrides the tools from the agent definition. Does not affect kernel function availability; + use function_choice_behavior for that. temperature: The temperature. top_p: The top p. truncation_strategy: The truncation strategy. + function_choice_behavior: Controls which kernel functions are allowed to execute during this run. + Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific + functions. Only Auto is supported; other types will raise an error. kwargs: Additional keyword arguments. Returns: @@ -408,7 +426,10 @@ async def invoke_stream( arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs) kernel = kernel or agent.kernel - tools = cls._get_tools(agent=agent, kernel=kernel) # type: ignore + cls._validate_function_choice_behavior(function_choice_behavior) + + tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, + function_choice_behavior=function_choice_behavior) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -496,6 +517,7 @@ async def invoke_stream( run, function_steps, arguments, + function_choice_behavior=function_choice_behavior, ) if action_result is None: raise AgentInvokeException( @@ -553,6 +575,7 @@ async def _handle_streaming_requires_action( run: "Run", function_steps: dict[str, "FunctionCallContent"], arguments: KernelArguments, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> FunctionActionResult | None: """Handle the requires action event for a streaming run.""" @@ -563,7 +586,8 @@ async def _handle_streaming_requires_action( chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"] results = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments + kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, + function_choice_behavior=function_choice_behavior, ) function_result_streaming_content = merge_streaming_function_results( @@ -658,6 +682,7 @@ async def _invoke_function_calls( fccs: list["FunctionCallContent"], chat_history: "ChatHistory", arguments: KernelArguments, + function_choice_behavior: FunctionChoiceBehavior | None = None, ) -> list["AutoFunctionInvocationContext | None"]: """Invoke the function calls.""" return await asyncio.gather( @@ -666,6 +691,7 @@ async def _invoke_function_calls( function_call=function_call, chat_history=chat_history, arguments=arguments, + function_behavior=function_choice_behavior, ) for function_call in fccs ], @@ -837,22 +863,61 @@ def _get_tool_definition(cls: type[_T], tools: list[Any]) -> Iterable["Additiona if tool_definition := cls.tool_metadata.get(tool): yield from tool_definition + @staticmethod + def _validate_function_choice_behavior( + function_choice_behavior: FunctionChoiceBehavior | None, + ) -> None: + """Validate the function choice behavior is compatible with agent invocations.""" + if function_choice_behavior is None: + return + if function_choice_behavior.type_ != FunctionChoiceType.AUTO: + raise AgentInvokeException( + f"FunctionChoiceBehavior with type '{function_choice_behavior.type_}' is not supported for agent " + "invocations. Use FunctionChoiceBehavior.Auto(filters=...) to control which kernel functions " + "are available." + ) + if not function_choice_behavior.auto_invoke_kernel_functions: + raise AgentInvokeException( + "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " + "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." + ) + @classmethod - def _get_tools(cls: type[_T], agent: "OpenAIAssistantAgent", kernel: "Kernel") -> list[dict[str, str]]: + def _get_tools( + cls: type[_T], + agent: "OpenAIAssistantAgent", + kernel: "Kernel", + tools_override: "list[AssistantToolParam] | None" = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, + ) -> list[dict[str, str]]: """Get the list of tools for the assistant. + Args: + agent: The assistant agent. + kernel: The kernel to use for function metadata. + tools_override: When provided, overrides agent.definition.tools (SDK-level tools only). + function_choice_behavior: When provided, filters which kernel functions are included. + Returns: The list of tools. """ tools: list[Any] = [] - for tool in agent.definition.tools: + source_tools = tools_override if tools_override is not None else agent.definition.tools + for tool in source_tools: if isinstance(tool, CodeInterpreterTool): tools.append({"type": "code_interpreter"}) elif isinstance(tool, FileSearchTool): tools.append({"type": "file_search"}) - funcs = agent.kernel.get_full_list_of_function_metadata() + # Determine kernel function metadata based on function_choice_behavior + if function_choice_behavior is not None and not function_choice_behavior.enable_kernel_functions: + funcs = [] + elif function_choice_behavior is not None and function_choice_behavior.filters: + funcs = kernel.get_list_of_function_metadata(function_choice_behavior.filters) + else: + funcs = kernel.get_full_list_of_function_metadata() + tools.extend([kernel_function_metadata_to_function_call_format(f) for f in funcs]) return tools diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index 000491d09021..cea58f9b6135 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -29,6 +29,7 @@ from semantic_kernel.contents import FunctionCallContent, FunctionResultContent, TextContent from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel @@ -354,3 +355,167 @@ async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_de collected_messages.append(content) assert isinstance(content, ChatMessageContent) assert content.metadata.get("message_id") == "msg_1" + + +# region Security tests for tools override and function_choice_behavior + + +import pytest + +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior +from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException + + +async def test_validate_function_choice_behavior_rejects_required(): + """Required FCB is not supported for agent invocations.""" + with pytest.raises(AgentInvokeException, match="not supported"): + AgentThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Required() + ) + + +async def test_validate_function_choice_behavior_accepts_auto(): + """Auto FCB should be accepted without error.""" + AgentThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Auto() + ) + + +async def test_validate_function_choice_behavior_rejects_none_invoke(): + """NoneInvoke FCB is not supported for agent invocations.""" + with pytest.raises(AgentInvokeException, match="not supported"): + AgentThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.NoneInvoke() + ) + + +async def test_validate_function_choice_behavior_accepts_none(): + """None (no FCB) should be accepted.""" + AgentThreadActions._validate_function_choice_behavior(None) + + +async def test_validate_function_choice_behavior_rejects_auto_invoke_false(): + """Auto with auto_invoke=False is not supported for agent invocations.""" + with pytest.raises(AgentInvokeException, match="auto_invoke"): + AgentThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Auto(auto_invoke=False) + ) + + +async def test_get_tools_with_tools_override(ai_project_client, ai_agent_definition): + """When tools_override is provided, it should replace agent.definition.tools.""" + from azure.ai.agents.models import CodeInterpreterToolDefinition + + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + kernel = MagicMock(spec=Kernel) + kernel.get_full_list_of_function_metadata.return_value = [] + + override_tool = CodeInterpreterToolDefinition() + tools = AgentThreadActions._get_tools( + agent=agent, kernel=kernel, tools_override=[override_tool] + ) + # Should contain the override tool, not agent.definition.tools + assert any( + (isinstance(t, CodeInterpreterToolDefinition) or (isinstance(t, dict) and t.get("type") == "code_interpreter")) + for t in tools + ) + + +async def test_get_tools_with_fcb_filters(ai_project_client, ai_agent_definition): + """When function_choice_behavior has filters, only matching functions should be included.""" + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + kernel = MagicMock(spec=Kernel) + + # Simulate filtered metadata + mock_metadata = MagicMock() + mock_metadata.fully_qualified_name = "Plugin-AllowedFunc" + mock_metadata.name = "AllowedFunc" + mock_metadata.plugin_name = "Plugin" + mock_metadata.description = "An allowed function" + mock_metadata.parameters = [] + mock_metadata.is_prompt = False + mock_metadata.return_parameter = MagicMock() + mock_metadata.return_parameter.description = "" + mock_metadata.return_parameter.type_ = "str" + mock_metadata.additional_properties = {} + + kernel.get_list_of_function_metadata.return_value = [mock_metadata] + kernel.get_full_list_of_function_metadata.return_value = [] + + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-AllowedFunc"]} + ) + tools = AgentThreadActions._get_tools( + agent=agent, kernel=kernel, function_choice_behavior=fcb + ) + # Should have called get_list_of_function_metadata with the filters + kernel.get_list_of_function_metadata.assert_called_once_with(fcb.filters) + + +async def test_get_tools_with_fcb_disable_kernel_functions(ai_project_client, ai_agent_definition): + """When enable_kernel_functions=False, no kernel functions should be included.""" + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + kernel = MagicMock(spec=Kernel) + + fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) + tools = AgentThreadActions._get_tools( + agent=agent, kernel=kernel, function_choice_behavior=fcb + ) + # Should NOT have called any function metadata methods + kernel.get_full_list_of_function_metadata.assert_not_called() + kernel.get_list_of_function_metadata.assert_not_called() + + +async def test_invoke_function_calls_passes_function_behavior(): + """_invoke_function_calls should pass function_behavior to kernel.invoke_function_call.""" + mock_kernel = AsyncMock(spec=Kernel) + mock_kernel.invoke_function_call.return_value = None + + fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1") + from semantic_kernel.contents.chat_history import ChatHistory + + chat_history = ChatHistory() + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-Func"]} + ) + + await AgentThreadActions._invoke_function_calls( + kernel=mock_kernel, + fccs=[fcc], + chat_history=chat_history, + arguments=KernelArguments(), + function_choice_behavior=fcb, + ) + + mock_kernel.invoke_function_call.assert_awaited_once() + call_kwargs = mock_kernel.invoke_function_call.call_args + assert call_kwargs.kwargs.get("function_behavior") is fcb + + +async def test_invoke_function_calls_passes_disabled_kernel_functions(): + """_invoke_function_calls should pass enable_kernel_functions=False FCB to kernel.""" + mock_kernel = AsyncMock(spec=Kernel) + mock_kernel.invoke_function_call.return_value = None + + fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1") + from semantic_kernel.contents.chat_history import ChatHistory + + chat_history = ChatHistory() + fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) + + await AgentThreadActions._invoke_function_calls( + kernel=mock_kernel, + fccs=[fcc], + chat_history=chat_history, + arguments=KernelArguments(), + function_choice_behavior=fcb, + ) + + mock_kernel.invoke_function_call.assert_awaited_once() + call_kwargs = mock_kernel.invoke_function_call.call_args + passed_behavior = call_kwargs.kwargs.get("function_behavior") + assert passed_behavior is fcb + assert not passed_behavior.enable_kernel_functions + + +# endregion diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index 1bb688bb42c0..c3dd5d865529 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -853,3 +853,188 @@ async def test_handle_streaming_requires_action_returns_none(): dummy_args, ) assert result is None + + +# region Security tests for tools override and function_choice_behavior + + +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + +async def test_validate_function_choice_behavior_rejects_required(): + """Required FCB is not supported for agent invocations.""" + with pytest.raises(AgentInvokeException, match="not supported"): + AssistantThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Required() + ) + + +async def test_validate_function_choice_behavior_accepts_auto(): + """Auto FCB should be accepted without error.""" + AssistantThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Auto() + ) + + +async def test_validate_function_choice_behavior_rejects_none_invoke(): + """NoneInvoke FCB is not supported for agent invocations.""" + with pytest.raises(AgentInvokeException, match="not supported"): + AssistantThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.NoneInvoke() + ) + + +async def test_validate_function_choice_behavior_accepts_none(): + """None (no FCB) should be accepted.""" + AssistantThreadActions._validate_function_choice_behavior(None) + + +async def test_validate_function_choice_behavior_rejects_auto_invoke_false(): + """Auto with auto_invoke=False is not supported for agent invocations.""" + with pytest.raises(AgentInvokeException, match="auto_invoke"): + AssistantThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Auto(auto_invoke=False) + ) + + +async def test_get_tools_with_tools_override(): + """When tools_override is provided, it should replace agent.definition.tools.""" + agent = MagicMock(spec=OpenAIAssistantAgent) + agent.definition = MagicMock() + agent.definition.tools = [CodeInterpreterTool(type="code_interpreter")] + agent.kernel = MagicMock(spec=Kernel) + + kernel = MagicMock(spec=Kernel) + kernel.get_full_list_of_function_metadata.return_value = [] + + # Override with file_search only + override_tools = [FileSearchTool(type="file_search")] + tools = AssistantThreadActions._get_tools( + agent=agent, kernel=kernel, tools_override=override_tools + ) + # Should contain file_search from override, not code_interpreter from agent + tool_types = [t.get("type") if isinstance(t, dict) else None for t in tools] + assert "file_search" in tool_types + # Agent's code_interpreter should NOT be in the result + assert "code_interpreter" not in tool_types + + +async def test_get_tools_with_fcb_filters(): + """When function_choice_behavior has filters, only matching functions should be included.""" + agent = MagicMock(spec=OpenAIAssistantAgent) + agent.definition = MagicMock() + agent.definition.tools = [] + agent.kernel = MagicMock(spec=Kernel) + + kernel = MagicMock(spec=Kernel) + + mock_metadata = MagicMock() + mock_metadata.fully_qualified_name = "Plugin-AllowedFunc" + mock_metadata.name = "AllowedFunc" + mock_metadata.plugin_name = "Plugin" + mock_metadata.description = "An allowed function" + mock_metadata.parameters = [] + mock_metadata.is_prompt = False + mock_metadata.return_parameter = MagicMock() + mock_metadata.return_parameter.description = "" + mock_metadata.return_parameter.type_ = "str" + mock_metadata.additional_properties = {} + + kernel.get_list_of_function_metadata.return_value = [mock_metadata] + + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-AllowedFunc"]} + ) + tools = AssistantThreadActions._get_tools( + agent=agent, kernel=kernel, function_choice_behavior=fcb + ) + kernel.get_list_of_function_metadata.assert_called_once_with(fcb.filters) + + +async def test_get_tools_with_fcb_disable_kernel_functions(): + """When enable_kernel_functions=False, no kernel functions should be included.""" + agent = MagicMock(spec=OpenAIAssistantAgent) + agent.definition = MagicMock() + agent.definition.tools = [] + agent.kernel = MagicMock(spec=Kernel) + + kernel = MagicMock(spec=Kernel) + + fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) + tools = AssistantThreadActions._get_tools( + agent=agent, kernel=kernel, function_choice_behavior=fcb + ) + kernel.get_full_list_of_function_metadata.assert_not_called() + kernel.get_list_of_function_metadata.assert_not_called() + + +async def test_invoke_function_calls_passes_function_behavior(): + """_invoke_function_calls should pass function_behavior to kernel.invoke_function_call.""" + mock_kernel = AsyncMock(spec=Kernel) + mock_kernel.invoke_function_call.return_value = None + + fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1") + from semantic_kernel.contents.chat_history import ChatHistory + + chat_history = ChatHistory() + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-Func"]} + ) + + await AssistantThreadActions._invoke_function_calls( + kernel=mock_kernel, + fccs=[fcc], + chat_history=chat_history, + arguments=KernelArguments(), + function_choice_behavior=fcb, + ) + + mock_kernel.invoke_function_call.assert_awaited_once() + call_kwargs = mock_kernel.invoke_function_call.call_args + assert call_kwargs.kwargs.get("function_behavior") is fcb + + +async def test_invoke_function_calls_passes_disabled_kernel_functions(): + """_invoke_function_calls should pass enable_kernel_functions=False FCB to kernel.""" + mock_kernel = AsyncMock(spec=Kernel) + mock_kernel.invoke_function_call.return_value = None + + fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1") + from semantic_kernel.contents.chat_history import ChatHistory + + chat_history = ChatHistory() + fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) + + await AssistantThreadActions._invoke_function_calls( + kernel=mock_kernel, + fccs=[fcc], + chat_history=chat_history, + arguments=KernelArguments(), + function_choice_behavior=fcb, + ) + + mock_kernel.invoke_function_call.assert_awaited_once() + call_kwargs = mock_kernel.invoke_function_call.call_args + passed_behavior = call_kwargs.kwargs.get("function_behavior") + assert passed_behavior is fcb + assert not passed_behavior.enable_kernel_functions + + +async def test_get_tools_uses_passed_kernel_not_agent_kernel(): + """_get_tools should use the passed kernel parameter, not agent.kernel.""" + agent = MagicMock(spec=OpenAIAssistantAgent) + agent.definition = MagicMock() + agent.definition.tools = [] + agent.kernel = MagicMock(spec=Kernel) + agent.kernel.get_full_list_of_function_metadata.return_value = ["should_not_be_used"] + + kernel = MagicMock(spec=Kernel) + kernel.get_full_list_of_function_metadata.return_value = [] + + AssistantThreadActions._get_tools(agent=agent, kernel=kernel) + # Should call the passed kernel, not agent.kernel + kernel.get_full_list_of_function_metadata.assert_called_once() + agent.kernel.get_full_list_of_function_metadata.assert_not_called() + + +# endregion From 47f44477815b18bc25aab2e4a71ef37ed23227a5 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 5 Jun 2026 21:07:23 +0100 Subject: [PATCH 02/14] Python: Expose function_choice_behavior on public agent APIs Add function_choice_behavior parameter to get_response(), invoke(), and invoke_stream() on AzureAIAgent and OpenAIAssistantAgent, forwarding it to the thread actions layer. Add unit tests to verify the parameter is passed through correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai/azure_ai_agent.py | 13 +++ .../agents/open_ai/openai_assistant_agent.py | 13 +++ .../azure_ai_agent/test_azure_ai_agent.py | 83 ++++++++++++++++++ .../test_openai_assistant_agent.py | 87 +++++++++++++++++++ 4 files changed, 196 insertions(+) diff --git a/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py b/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py index 44af0f22fee8..74b64ed534be 100644 --- a/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py +++ b/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py @@ -40,6 +40,7 @@ from semantic_kernel.agents.azure_ai.azure_ai_channel import AzureAIChannel from semantic_kernel.agents.channels.agent_channel import AgentChannel from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole @@ -647,6 +648,7 @@ async def get_response( parallel_tool_calls: bool | None = None, metadata: dict[str, str] | None = None, polling_options: RunPollingOptions | None = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> AgentResponseItem[ChatMessageContent]: """Get a response from the agent on a thread. @@ -671,6 +673,8 @@ async def get_response( parallel_tool_calls: Whether to allow parallel tool calls. metadata: Metadata for the agent. polling_options: The polling options for the agent. + function_choice_behavior: The function choice behavior to control which kernel + functions are available. Only Auto is supported; other types will raise an error. **kwargs: Additional keyword arguments. Returns: @@ -716,6 +720,7 @@ async def get_response( thread_id=thread.id, kernel=kernel, arguments=arguments, + function_choice_behavior=function_choice_behavior, **run_level_params, # type: ignore ): if is_visible and response.metadata.get("code") is not True: @@ -752,6 +757,7 @@ async def invoke( parallel_tool_calls: bool | None = None, metadata: dict[str, str] | None = None, polling_options: RunPollingOptions | None = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]: """Invoke the agent on the specified thread. @@ -777,6 +783,8 @@ async def invoke( parallel_tool_calls: Whether to allow parallel tool calls. polling_options: The polling options for the agent. metadata: Metadata for the agent. + function_choice_behavior: The function choice behavior to control which kernel + functions are available. Only Auto is supported; other types will raise an error. **kwargs: Additional keyword arguments. Yields: @@ -821,6 +829,7 @@ async def invoke( thread_id=thread.id, kernel=kernel, arguments=arguments, + function_choice_behavior=function_choice_behavior, **run_level_params, # type: ignore ): message.metadata["thread_id"] = thread.id @@ -856,6 +865,7 @@ async def invoke_stream( response_format: AgentsApiResponseFormatOption | None = None, parallel_tool_calls: bool | None = None, metadata: dict[str, str] | None = None, + function_choice_behavior: FunctionChoiceBehavior | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseItem["StreamingChatMessageContent"]]: """Invoke the agent on the specified thread with a stream of messages. @@ -881,6 +891,8 @@ async def invoke_stream( response_format: Response format for the agent. parallel_tool_calls: Whether to allow parallel tool calls. metadata: Metadata for the agent. + function_choice_behavior: The function choice behavior to control which kernel + functions are available. Only Auto is supported; other types will raise an error. **kwargs: Additional keyword arguments. Yields: @@ -928,6 +940,7 @@ async def invoke_stream( output_messages=collected_messages, kernel=kernel, arguments=arguments, + function_choice_behavior=function_choice_behavior, **run_level_params, # type: ignore ): # Before yielding the current streamed message, emit any new full messages first diff --git a/python/semantic_kernel/agents/open_ai/openai_assistant_agent.py b/python/semantic_kernel/agents/open_ai/openai_assistant_agent.py index a1daaa75f5be..123f19aaf448 100644 --- a/python/semantic_kernel/agents/open_ai/openai_assistant_agent.py +++ b/python/semantic_kernel/agents/open_ai/openai_assistant_agent.py @@ -35,6 +35,7 @@ from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel from semantic_kernel.agents.open_ai.assistant_thread_actions import AssistantThreadActions from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.open_ai.settings.open_ai_settings import OpenAISettings from semantic_kernel.connectors.utils.structured_output_schema import generate_structured_output_response_format_schema from semantic_kernel.contents.chat_message_content import ChatMessageContent @@ -758,6 +759,7 @@ async def get_response( top_p: float | None = None, truncation_strategy: "TruncationStrategy | None" = None, polling_options: RunPollingOptions | None = None, + function_choice_behavior: "FunctionChoiceBehavior | None" = None, **kwargs: Any, ) -> AgentResponseItem[ChatMessageContent]: """Get a response from the agent on a thread. @@ -783,6 +785,8 @@ async def get_response( top_p: The top p. truncation_strategy: The truncation strategy. polling_options: The polling options at the run-level. + function_choice_behavior: The function choice behavior to control which kernel + functions are available. Only Auto is supported; other types will raise an error. kwargs: Additional keyword arguments. Returns: @@ -829,6 +833,7 @@ async def get_response( thread_id=thread.id, kernel=kernel, arguments=arguments, + function_choice_behavior=function_choice_behavior, **run_level_params, # type: ignore ): if is_visible and response.metadata.get("code") is not True: @@ -866,6 +871,7 @@ async def invoke( top_p: float | None = None, truncation_strategy: "TruncationStrategy | None" = None, polling_options: RunPollingOptions | None = None, + function_choice_behavior: "FunctionChoiceBehavior | None" = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]: """Invoke the agent. @@ -892,6 +898,8 @@ async def invoke( top_p: The top p. truncation_strategy: The truncation strategy. polling_options: The polling options at the run-level. + function_choice_behavior: The function choice behavior to control which kernel + functions are available. Only Auto is supported; other types will raise an error. kwargs: Additional keyword arguments. Yields: @@ -937,6 +945,7 @@ async def invoke( thread_id=thread.id, kernel=kernel, arguments=arguments, + function_choice_behavior=function_choice_behavior, **run_level_params, # type: ignore ): message.metadata["thread_id"] = thread.id @@ -973,6 +982,7 @@ async def invoke_stream( temperature: float | None = None, top_p: float | None = None, truncation_strategy: "TruncationStrategy | None" = None, + function_choice_behavior: "FunctionChoiceBehavior | None" = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]: """Invoke the agent. @@ -999,6 +1009,8 @@ async def invoke_stream( temperature: The temperature. top_p: The top p. truncation_strategy: The truncation strategy. + function_choice_behavior: The function choice behavior to control which kernel + functions are available. Only Auto is supported; other types will raise an error. kwargs: Additional keyword arguments. Yields: @@ -1047,6 +1059,7 @@ async def invoke_stream( output_messages=collected_messages, kernel=kernel, arguments=arguments, + function_choice_behavior=function_choice_behavior, **run_level_params, # type: ignore ): # Before yielding the current streamed message, emit any new full messages first diff --git a/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py b/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py index b5dc1178b6a1..fa2a4f3f2c04 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py +++ b/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py @@ -398,3 +398,86 @@ def test_create_client_raises_if_no_endpoint(): assert "Azure AI endpoint" in str(e) else: assert False, "Expected AgentInitializationException to be raised" + + +async def test_azure_ai_agent_get_response_passes_function_choice_behavior(ai_project_client, ai_agent_definition): + from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + thread = AsyncMock(spec=AzureAIAgentThread) + fcb = FunctionChoiceBehavior.Auto() + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke", + side_effect=fake_invoke, + ): + await agent.get_response(messages="message", thread=thread, function_choice_behavior=fcb) + + assert captured_kwargs.get("function_choice_behavior") is fcb + + +async def test_azure_ai_agent_invoke_passes_function_choice_behavior(ai_project_client, ai_agent_definition): + from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + thread = AsyncMock(spec=AzureAIAgentThread) + fcb = FunctionChoiceBehavior.Auto() + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke", + side_effect=fake_invoke, + ): + async for _ in agent.invoke(messages="message", thread=thread, function_choice_behavior=fcb): + pass + + assert captured_kwargs.get("function_choice_behavior") is fcb + + +async def test_azure_ai_agent_invoke_stream_passes_function_choice_behavior(ai_project_client, ai_agent_definition): + from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + thread = AsyncMock(spec=AzureAIAgentThread) + fcb = FunctionChoiceBehavior.Auto() + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream", + side_effect=fake_invoke, + ): + async for _ in agent.invoke_stream(messages="message", thread=thread, function_choice_behavior=fcb): + pass + + assert captured_kwargs.get("function_choice_behavior") is fcb + + +async def test_azure_ai_agent_get_response_no_fcb_passes_none(ai_project_client, ai_agent_definition): + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + thread = AsyncMock(spec=AzureAIAgentThread) + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke", + side_effect=fake_invoke, + ): + await agent.get_response(messages="message", thread=thread) + + assert captured_kwargs.get("function_choice_behavior") is None diff --git a/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py b/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py index 6423ebf39b74..8b5e71aab575 100644 --- a/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py +++ b/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py @@ -497,3 +497,90 @@ async def test_openai_assistant_agent_from_yaml_invalid_type(): """ with pytest.raises(AgentInitializationException, match="not registered"): await AgentRegistry.create_from_yaml(spec) + + +async def test_openai_assistant_agent_get_response_passes_function_choice_behavior( + openai_client, assistant_definition +): + from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) + thread = AsyncMock(spec=AssistantAgentThread) + fcb = FunctionChoiceBehavior.Auto() + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke", + side_effect=fake_invoke, + ): + await agent.get_response(messages="message", thread=thread, function_choice_behavior=fcb) + + assert captured_kwargs.get("function_choice_behavior") is fcb + + +async def test_openai_assistant_agent_invoke_passes_function_choice_behavior(openai_client, assistant_definition): + from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) + thread = AsyncMock(spec=AssistantAgentThread) + fcb = FunctionChoiceBehavior.Auto() + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke", + side_effect=fake_invoke, + ): + async for _ in agent.invoke(messages="message", thread=thread, function_choice_behavior=fcb): + pass + + assert captured_kwargs.get("function_choice_behavior") is fcb + + +async def test_openai_assistant_agent_invoke_stream_passes_function_choice_behavior( + openai_client, assistant_definition +): + from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) + thread = AsyncMock(spec=AssistantAgentThread) + fcb = FunctionChoiceBehavior.Auto() + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke_stream", + side_effect=fake_invoke, + ): + async for _ in agent.invoke_stream(messages="message", thread=thread, function_choice_behavior=fcb): + pass + + assert captured_kwargs.get("function_choice_behavior") is fcb + + +async def test_openai_assistant_agent_get_response_no_fcb_passes_none(openai_client, assistant_definition): + agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) + thread = AsyncMock(spec=AssistantAgentThread) + captured_kwargs = {} + + async def fake_invoke(*args, **kwargs): + captured_kwargs.update(kwargs) + yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content") + + with patch( + "semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke", + side_effect=fake_invoke, + ): + await agent.get_response(messages="message", thread=thread) + + assert captured_kwargs.get("function_choice_behavior") is None From 69a7598f93a888e899c6104571f201c5cfb905f1 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 5 Jun 2026 21:17:39 +0100 Subject: [PATCH 03/14] Move FunctionChoiceBehavior imports to top of test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai_agent/test_agent_thread_actions.py | 10 ++++------ .../unit/agents/azure_ai_agent/test_azure_ai_agent.py | 7 +------ .../openai_assistant/test_assistant_thread_actions.py | 4 +--- .../openai_assistant/test_openai_assistant_agent.py | 7 +------ 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index cea58f9b6135..b7260a7e5d02 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -3,6 +3,8 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from azure.ai.agents.models import ( MessageTextContent, MessageTextDetails, @@ -26,9 +28,11 @@ from semantic_kernel.agents.azure_ai.agent_thread_actions import AgentThreadActions from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.contents import FunctionCallContent, FunctionResultContent, TextContent from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel @@ -360,12 +364,6 @@ async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_de # region Security tests for tools override and function_choice_behavior -import pytest - -from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior -from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException - - async def test_validate_function_choice_behavior_rejects_required(): """Required FCB is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="not supported"): diff --git a/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py b/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py index fa2a4f3f2c04..dff8f210f063 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py +++ b/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py @@ -9,6 +9,7 @@ from semantic_kernel.agents.agent import AgentResponseItem from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent, AzureAIAgentThread from semantic_kernel.agents.channels.agent_channel import AgentChannel +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.function_call_content import FunctionCallContent @@ -401,8 +402,6 @@ def test_create_client_raises_if_no_endpoint(): async def test_azure_ai_agent_get_response_passes_function_choice_behavior(ai_project_client, ai_agent_definition): - from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior - agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) thread = AsyncMock(spec=AzureAIAgentThread) fcb = FunctionChoiceBehavior.Auto() @@ -422,8 +421,6 @@ async def fake_invoke(*args, **kwargs): async def test_azure_ai_agent_invoke_passes_function_choice_behavior(ai_project_client, ai_agent_definition): - from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior - agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) thread = AsyncMock(spec=AzureAIAgentThread) fcb = FunctionChoiceBehavior.Auto() @@ -444,8 +441,6 @@ async def fake_invoke(*args, **kwargs): async def test_azure_ai_agent_invoke_stream_passes_function_choice_behavior(ai_project_client, ai_agent_definition): - from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior - agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) thread = AsyncMock(spec=AzureAIAgentThread) fcb = FunctionChoiceBehavior.Auto() diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index c3dd5d865529..17572dd21907 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -55,6 +55,7 @@ from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult from semantic_kernel.agents.open_ai.openai_assistant_agent import OpenAIAssistantAgent from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.file_reference_content import FileReferenceContent from semantic_kernel.contents.function_call_content import FunctionCallContent @@ -858,9 +859,6 @@ async def test_handle_streaming_requires_action_returns_none(): # region Security tests for tools override and function_choice_behavior -from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior - - async def test_validate_function_choice_behavior_rejects_required(): """Required FCB is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="not supported"): diff --git a/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py b/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py index 8b5e71aab575..88262c22b925 100644 --- a/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py +++ b/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py @@ -10,6 +10,7 @@ from semantic_kernel.agents import AgentRegistry, AgentResponseItem, OpenAIAssistantAgent from semantic_kernel.agents.open_ai.openai_assistant_agent import AssistantAgentThread from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.function_call_content import FunctionCallContent @@ -502,8 +503,6 @@ async def test_openai_assistant_agent_from_yaml_invalid_type(): async def test_openai_assistant_agent_get_response_passes_function_choice_behavior( openai_client, assistant_definition ): - from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior - agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) thread = AsyncMock(spec=AssistantAgentThread) fcb = FunctionChoiceBehavior.Auto() @@ -523,8 +522,6 @@ async def fake_invoke(*args, **kwargs): async def test_openai_assistant_agent_invoke_passes_function_choice_behavior(openai_client, assistant_definition): - from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior - agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) thread = AsyncMock(spec=AssistantAgentThread) fcb = FunctionChoiceBehavior.Auto() @@ -547,8 +544,6 @@ async def fake_invoke(*args, **kwargs): async def test_openai_assistant_agent_invoke_stream_passes_function_choice_behavior( openai_client, assistant_definition ): - from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior - agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) thread = AsyncMock(spec=AssistantAgentThread) fcb = FunctionChoiceBehavior.Auto() From 8988510d29eae8a7d325942a21ef5298d8fe6c9d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 5 Jun 2026 21:25:50 +0100 Subject: [PATCH 04/14] Fix lint errors: line length and unused variable assignments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../semantic_kernel/agents/azure_ai/agent_thread_actions.py | 6 +++++- .../unit/agents/azure_ai_agent/test_agent_thread_actions.py | 4 ++-- .../openai_assistant/test_assistant_thread_actions.py | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index c208897a4a75..54b75e928fa1 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -243,7 +243,11 @@ async def invoke( chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"] _ = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, function_choice_behavior=function_choice_behavior + kernel=kernel, + fccs=fccs, + chat_history=chat_history, + arguments=arguments, + function_choice_behavior=function_choice_behavior, ) tool_outputs = cls._format_tool_outputs(fccs, chat_history) diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index b7260a7e5d02..a83bcdef0c4e 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -443,7 +443,7 @@ async def test_get_tools_with_fcb_filters(ai_project_client, ai_agent_definition fcb = FunctionChoiceBehavior.Auto( filters={"included_functions": ["Plugin-AllowedFunc"]} ) - tools = AgentThreadActions._get_tools( + AgentThreadActions._get_tools( agent=agent, kernel=kernel, function_choice_behavior=fcb ) # Should have called get_list_of_function_metadata with the filters @@ -456,7 +456,7 @@ async def test_get_tools_with_fcb_disable_kernel_functions(ai_project_client, ai kernel = MagicMock(spec=Kernel) fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) - tools = AgentThreadActions._get_tools( + AgentThreadActions._get_tools( agent=agent, kernel=kernel, function_choice_behavior=fcb ) # Should NOT have called any function metadata methods diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index 17572dd21907..c9b3436deac0 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -943,7 +943,7 @@ async def test_get_tools_with_fcb_filters(): fcb = FunctionChoiceBehavior.Auto( filters={"included_functions": ["Plugin-AllowedFunc"]} ) - tools = AssistantThreadActions._get_tools( + AssistantThreadActions._get_tools( agent=agent, kernel=kernel, function_choice_behavior=fcb ) kernel.get_list_of_function_metadata.assert_called_once_with(fcb.filters) @@ -959,7 +959,7 @@ async def test_get_tools_with_fcb_disable_kernel_functions(): kernel = MagicMock(spec=Kernel) fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) - tools = AssistantThreadActions._get_tools( + AssistantThreadActions._get_tools( agent=agent, kernel=kernel, function_choice_behavior=fcb ) kernel.get_full_list_of_function_metadata.assert_not_called() From 14bcb3b36fad85f6f2cce4e5754328563850791c Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 10:51:37 +0100 Subject: [PATCH 05/14] Fix validation to use full kernel list regardless of FCB filtering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai/agent_thread_actions.py | 13 ++++++++----- .../azure_ai_agent/test_agent_thread_actions.py | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 54b75e928fa1..31b5bea7754c 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -1023,15 +1023,18 @@ def _get_tools( """ tools: list[Any] = list(tools_override) if tools_override is not None else list(agent.definition.tools) - # Determine kernel function metadata based on function_choice_behavior + # Always validate against the full kernel function list to catch truly + # unregistered functions, regardless of FCB filtering. + all_funcs = kernel.get_full_list_of_function_metadata() + cls._validate_function_tools_registered(tools, all_funcs) + + # Determine which kernel functions to advertise based on function_choice_behavior if function_choice_behavior is not None and not function_choice_behavior.enable_kernel_functions: - funcs = [] + funcs: list[KernelFunctionMetadata] = [] elif function_choice_behavior is not None and function_choice_behavior.filters: funcs = kernel.get_list_of_function_metadata(function_choice_behavior.filters) else: - funcs = kernel.get_full_list_of_function_metadata() - - cls._validate_function_tools_registered(tools, funcs) + funcs = all_funcs dict_defs = [kernel_function_metadata_to_function_call_format(f) for f in funcs] deduped_defs = cls._deduplicate_tools(tools, dict_defs) tools.extend(deduped_defs) diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index a83bcdef0c4e..e7d4903ade5a 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -459,8 +459,8 @@ async def test_get_tools_with_fcb_disable_kernel_functions(ai_project_client, ai AgentThreadActions._get_tools( agent=agent, kernel=kernel, function_choice_behavior=fcb ) - # Should NOT have called any function metadata methods - kernel.get_full_list_of_function_metadata.assert_not_called() + # Full list is called for validation, but filtered list should not be called + kernel.get_full_list_of_function_metadata.assert_called_once() kernel.get_list_of_function_metadata.assert_not_called() From 2fc09feed79125e46425854c22956baf97dbd59d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 11:15:58 +0100 Subject: [PATCH 06/14] Validate FunctionChoiceBehavior filters in agent thread actions Add validation for empty and unknown filter keys in _validate_function_choice_behavior for both Azure AI and OpenAI assistant thread actions. Without this, an empty filters dict or mistyped keys would silently fall through and expose all kernel functions to the model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai/agent_thread_actions.py | 14 +++++++++++ .../open_ai/assistant_thread_actions.py | 14 +++++++++++ .../test_agent_thread_actions.py | 24 +++++++++++++++++++ .../test_assistant_thread_actions.py | 24 +++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 31b5bea7754c..5236929903fd 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -1004,6 +1004,20 @@ def _validate_function_choice_behavior( "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." ) + valid_filter_keys = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} + if function_choice_behavior.filters is not None: + if not function_choice_behavior.filters: + raise AgentInvokeException( + "FunctionChoiceBehavior filters must not be empty. Provide at least one filter key " + f"from {sorted(valid_filter_keys)}, or omit filters entirely to include all " + "kernel functions." + ) + unknown_keys = set(function_choice_behavior.filters.keys()) - valid_filter_keys + if unknown_keys: + raise AgentInvokeException( + f"Unknown filter key(s): {sorted(unknown_keys)}. " + f"Valid filter keys are: {sorted(valid_filter_keys)}." + ) @classmethod def _get_tools( diff --git a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py index 1d2146232a04..bae0f38bf929 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py +++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py @@ -881,6 +881,20 @@ def _validate_function_choice_behavior( "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." ) + valid_filter_keys = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} + if function_choice_behavior.filters is not None: + if not function_choice_behavior.filters: + raise AgentInvokeException( + "FunctionChoiceBehavior filters must not be empty. Provide at least one filter key " + f"from {sorted(valid_filter_keys)}, or omit filters entirely to include all " + "kernel functions." + ) + unknown_keys = set(function_choice_behavior.filters.keys()) - valid_filter_keys + if unknown_keys: + raise AgentInvokeException( + f"Unknown filter key(s): {sorted(unknown_keys)}. " + f"Valid filter keys are: {sorted(valid_filter_keys)}." + ) @classmethod def _get_tools( diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index e7d4903ade5a..ece4613fef5e 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -400,6 +400,30 @@ async def test_validate_function_choice_behavior_rejects_auto_invoke_false(): ) +async def test_validate_function_choice_behavior_rejects_empty_filters(): + """Empty filters dict should be rejected.""" + fcb = FunctionChoiceBehavior.Auto() + fcb.filters = {} + with pytest.raises(AgentInvokeException, match="must not be empty"): + AgentThreadActions._validate_function_choice_behavior(fcb) + + +async def test_validate_function_choice_behavior_rejects_unknown_filter_keys(): + """Unknown filter keys should be rejected.""" + fcb = FunctionChoiceBehavior.Auto() + # Bypass Pydantic validation to simulate a mistyped key reaching the validator + object.__setattr__(fcb, "filters", {"include_functions": ["foo"]}) + with pytest.raises(AgentInvokeException, match="Unknown filter key"): + AgentThreadActions._validate_function_choice_behavior(fcb) + + +async def test_validate_function_choice_behavior_accepts_valid_filters(): + """Valid filter keys should be accepted.""" + AgentThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Auto(filters={"included_functions": ["plugin-func"]}) + ) + + async def test_get_tools_with_tools_override(ai_project_client, ai_agent_definition): """When tools_override is provided, it should replace agent.definition.tools.""" from azure.ai.agents.models import CodeInterpreterToolDefinition diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index c9b3436deac0..ac2c6e523e4c 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -895,6 +895,30 @@ async def test_validate_function_choice_behavior_rejects_auto_invoke_false(): ) +async def test_validate_function_choice_behavior_rejects_empty_filters(): + """Empty filters dict should be rejected.""" + fcb = FunctionChoiceBehavior.Auto() + fcb.filters = {} + with pytest.raises(AgentInvokeException, match="must not be empty"): + AssistantThreadActions._validate_function_choice_behavior(fcb) + + +async def test_validate_function_choice_behavior_rejects_unknown_filter_keys(): + """Unknown filter keys should be rejected.""" + fcb = FunctionChoiceBehavior.Auto() + # Bypass Pydantic validation to simulate a mistyped key reaching the validator + object.__setattr__(fcb, "filters", {"include_functions": ["foo"]}) + with pytest.raises(AgentInvokeException, match="Unknown filter key"): + AssistantThreadActions._validate_function_choice_behavior(fcb) + + +async def test_validate_function_choice_behavior_accepts_valid_filters(): + """Valid filter keys should be accepted.""" + AssistantThreadActions._validate_function_choice_behavior( + FunctionChoiceBehavior.Auto(filters={"included_functions": ["plugin-func"]}) + ) + + async def test_get_tools_with_tools_override(): """When tools_override is provided, it should replace agent.definition.tools.""" agent = MagicMock(spec=OpenAIAssistantAgent) From 1a133fd79c8b6fd8071ded4ed5ed806a515aa6ff Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 11:35:31 +0100 Subject: [PATCH 07/14] Add real-kernel and public-path enforcement tests for FCB filtering Add tests that use a real Kernel to verify invoke_function_call actually blocks disallowed functions and allows permitted ones, rather than only asserting that the kwarg is forwarded. Add public-path tests that call AgentThreadActions.invoke() and invoke_stream() with non-Auto FCB to ensure _validate_function_choice_behavior is exercised through the actual invocation path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test_agent_thread_actions.py | 129 ++++++++++++++++++ .../test_assistant_thread_actions.py | 126 +++++++++++++++++ 2 files changed, 255 insertions(+) diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index ece4613fef5e..51acb75e3875 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -34,6 +34,8 @@ from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException from semantic_kernel.functions.kernel_arguments import KernelArguments +from semantic_kernel.functions.kernel_function_decorator import kernel_function +from semantic_kernel.functions.kernel_plugin import KernelPlugin from semantic_kernel.kernel import Kernel @@ -540,4 +542,131 @@ async def test_invoke_function_calls_passes_disabled_kernel_functions(): assert not passed_behavior.enable_kernel_functions +async def test_invoke_function_calls_blocks_disallowed_function(): + """A real Kernel should block a function call not in the FCB allowlist. + + This verifies that the enforcement in kernel.invoke_function_call actually + rejects a disallowed function name when filters are provided, rather than + only asserting that the kwarg is forwarded. + """ + from semantic_kernel.contents.chat_history import ChatHistory + from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod + + @kernel_function + def allowed_func() -> str: + return "allowed" + + @kernel_function + def disallowed_func() -> str: + return "disallowed" + + kernel = Kernel() + kernel.add_plugin( + KernelPlugin( + name="Plugin", + functions=[ + KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"), + KernelFunctionFromMethod(method=disallowed_func, plugin_name="Plugin"), + ], + ) + ) + + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-allowed_func"]} + ) + + # Call a function NOT in the allowlist + fcc = FunctionCallContent(name="Plugin-disallowed_func", plugin_name="Plugin", function_name="disallowed_func", arguments={}, id="call1") + chat_history = ChatHistory() + + result = await kernel.invoke_function_call( + function_call=fcc, + chat_history=chat_history, + function_behavior=fcb, + ) + # invoke_function_call catches the FunctionExecutionException and returns None, + # adding an error message to chat_history instead of raising. + assert result is None + assert len(chat_history.messages) == 1 + result_item = chat_history.messages[0].items[0] + assert "not part of the provided tools" in str(result_item.result) + + +async def test_invoke_function_calls_allows_permitted_function(): + """A real Kernel should allow a function call that IS in the FCB allowlist.""" + from semantic_kernel.contents.chat_history import ChatHistory + from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod + + @kernel_function + def allowed_func() -> str: + return "ok" + + @kernel_function + def other_func() -> str: + return "other" + + kernel = Kernel() + kernel.add_plugin( + KernelPlugin( + name="Plugin", + functions=[ + KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"), + KernelFunctionFromMethod(method=other_func, plugin_name="Plugin"), + ], + ) + ) + + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-allowed_func"]} + ) + + fcc = FunctionCallContent(name="Plugin-allowed_func", plugin_name="Plugin", function_name="allowed_func", arguments={}, id="call1") + chat_history = ChatHistory() + + await kernel.invoke_function_call( + function_call=fcc, + chat_history=chat_history, + function_behavior=fcb, + ) + # Should succeed — the function result should be in chat_history + assert len(chat_history.messages) == 1 + result_item = chat_history.messages[0].items[0] + assert "ok" in str(result_item.result) + +async def test_invoke_raises_for_non_auto_fcb(ai_project_client, ai_agent_definition): + """Calling AgentThreadActions.invoke() with a non-Auto FCB should raise before any API call.""" + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + agent.client.agents = AsyncMock() + + with pytest.raises(AgentInvokeException, match="not supported"): + async for _ in AgentThreadActions.invoke( + agent=agent, + thread_id="thread123", + kernel=Kernel(), + function_choice_behavior=FunctionChoiceBehavior.Required(), + ): + pass + + # No API calls should have been made + agent.client.agents.runs.create.assert_not_awaited() + + +async def test_invoke_stream_raises_for_non_auto_fcb(ai_project_client, ai_agent_definition): + """Calling AgentThreadActions.invoke_stream() with a non-Auto FCB should raise before any API call.""" + agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) + agent.client.agents = AsyncMock() + + with pytest.raises(AgentInvokeException, match="not supported"): + async for _ in AgentThreadActions.invoke_stream( + agent=agent, + thread_id="thread123", + kernel=Kernel(), + function_choice_behavior=FunctionChoiceBehavior.NoneInvoke(), + ): + pass + + # No API calls should have been made + agent.client.agents.create_stream.assert_not_called() + + # endregion diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index ac2c6e523e4c..41cbb1707ed3 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -1058,5 +1058,131 @@ async def test_get_tools_uses_passed_kernel_not_agent_kernel(): kernel.get_full_list_of_function_metadata.assert_called_once() agent.kernel.get_full_list_of_function_metadata.assert_not_called() +async def test_invoke_function_calls_blocks_disallowed_function(): + """A real Kernel should block a function call not in the FCB allowlist. + + This verifies that the enforcement in kernel.invoke_function_call actually + rejects a disallowed function name when filters are provided, rather than + only asserting that the kwarg is forwarded. + """ + from semantic_kernel.contents.chat_history import ChatHistory + from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod + + @kernel_function + def allowed_func() -> str: + return "allowed" + + @kernel_function + def disallowed_func() -> str: + return "disallowed" + + kernel = Kernel() + kernel.add_plugin( + KernelPlugin( + name="Plugin", + functions=[ + KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"), + KernelFunctionFromMethod(method=disallowed_func, plugin_name="Plugin"), + ], + ) + ) + + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-allowed_func"]} + ) + + # Call a function NOT in the allowlist + fcc = FunctionCallContent(name="Plugin-disallowed_func", plugin_name="Plugin", function_name="disallowed_func", arguments={}, id="call1") + chat_history = ChatHistory() + + result = await kernel.invoke_function_call( + function_call=fcc, + chat_history=chat_history, + function_behavior=fcb, + ) + # invoke_function_call catches the FunctionExecutionException and returns None, + # adding an error message to chat_history instead of raising. + assert result is None + assert len(chat_history.messages) == 1 + result_item = chat_history.messages[0].items[0] + assert "not part of the provided tools" in str(result_item.result) + + +async def test_invoke_function_calls_allows_permitted_function(): + """A real Kernel should allow a function call that IS in the FCB allowlist.""" + from semantic_kernel.contents.chat_history import ChatHistory + from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod + + @kernel_function + def allowed_func() -> str: + return "ok" + + @kernel_function + def other_func() -> str: + return "other" + + kernel = Kernel() + kernel.add_plugin( + KernelPlugin( + name="Plugin", + functions=[ + KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"), + KernelFunctionFromMethod(method=other_func, plugin_name="Plugin"), + ], + ) + ) + + fcb = FunctionChoiceBehavior.Auto( + filters={"included_functions": ["Plugin-allowed_func"]} + ) + + fcc = FunctionCallContent(name="Plugin-allowed_func", plugin_name="Plugin", function_name="allowed_func", arguments={}, id="call1") + chat_history = ChatHistory() + + await kernel.invoke_function_call( + function_call=fcc, + chat_history=chat_history, + function_behavior=fcb, + ) + # Should succeed — the function result should be in chat_history + assert len(chat_history.messages) == 1 + result_item = chat_history.messages[0].items[0] + assert "ok" in str(result_item.result) + +async def test_invoke_raises_for_non_auto_fcb(): + """Calling AssistantThreadActions.invoke() with a non-Auto FCB should raise before any API call.""" + agent = MagicMock(spec=OpenAIAssistantAgent) + agent.definition = MagicMock() + agent.definition.tools = [] + agent.kernel = Kernel() + agent.format_instructions = AsyncMock(return_value="") + + with pytest.raises(AgentInvokeException, match="not supported"): + async for _ in AssistantThreadActions.invoke( + agent=agent, + thread_id="thread123", + kernel=Kernel(), + function_choice_behavior=FunctionChoiceBehavior.Required(), + ): + pass + + +async def test_invoke_stream_raises_for_non_auto_fcb(): + """Calling AssistantThreadActions.invoke_stream() with a non-Auto FCB should raise before any API call.""" + agent = MagicMock(spec=OpenAIAssistantAgent) + agent.definition = MagicMock() + agent.definition.tools = [] + agent.kernel = Kernel() + agent.format_instructions = AsyncMock(return_value="") + + with pytest.raises(AgentInvokeException, match="not supported"): + async for _ in AssistantThreadActions.invoke_stream( + agent=agent, + thread_id="thread123", + kernel=Kernel(), + function_choice_behavior=FunctionChoiceBehavior.NoneInvoke(), + ): + pass + # endregion From 80c2eb7fa8f6f62a5ce783c24b88c725c4e5b3f1 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 11:41:26 +0100 Subject: [PATCH 08/14] Fix import sorting in agent thread actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/semantic_kernel/agents/azure_ai/agent_thread_actions.py | 2 +- .../semantic_kernel/agents/open_ai/assistant_thread_actions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 5236929903fd..8a08f64cafd6 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -67,9 +67,9 @@ from semantic_kernel.agents.open_ai.assistant_content_generation import merge_streaming_function_results from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType -from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.function_call_content import FunctionCallContent from semantic_kernel.contents.utils.author_role import AuthorRole diff --git a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py index bae0f38bf929..0babf67fa21c 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py +++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py @@ -33,9 +33,9 @@ ) from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions +from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType -from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format from semantic_kernel.contents.file_reference_content import FileReferenceContent from semantic_kernel.contents.function_call_content import FunctionCallContent from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent From 1a955993755979c1bff8080d66bc692de3960c65 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 11:57:35 +0100 Subject: [PATCH 09/14] Fix E501 line too long in test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai_agent/test_agent_thread_actions.py | 10 ++++++++-- .../openai_assistant/test_assistant_thread_actions.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index 51acb75e3875..16d09b87c4f3 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -576,7 +576,10 @@ def disallowed_func() -> str: ) # Call a function NOT in the allowlist - fcc = FunctionCallContent(name="Plugin-disallowed_func", plugin_name="Plugin", function_name="disallowed_func", arguments={}, id="call1") + fcc = FunctionCallContent( + name="Plugin-disallowed_func", plugin_name="Plugin", + function_name="disallowed_func", arguments={}, id="call1", + ) chat_history = ChatHistory() result = await kernel.invoke_function_call( @@ -620,7 +623,10 @@ def other_func() -> str: filters={"included_functions": ["Plugin-allowed_func"]} ) - fcc = FunctionCallContent(name="Plugin-allowed_func", plugin_name="Plugin", function_name="allowed_func", arguments={}, id="call1") + fcc = FunctionCallContent( + name="Plugin-allowed_func", plugin_name="Plugin", + function_name="allowed_func", arguments={}, id="call1", + ) chat_history = ChatHistory() await kernel.invoke_function_call( diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index 41cbb1707ed3..5d9742027cae 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -1092,7 +1092,10 @@ def disallowed_func() -> str: ) # Call a function NOT in the allowlist - fcc = FunctionCallContent(name="Plugin-disallowed_func", plugin_name="Plugin", function_name="disallowed_func", arguments={}, id="call1") + fcc = FunctionCallContent( + name="Plugin-disallowed_func", plugin_name="Plugin", + function_name="disallowed_func", arguments={}, id="call1", + ) chat_history = ChatHistory() result = await kernel.invoke_function_call( @@ -1136,7 +1139,10 @@ def other_func() -> str: filters={"included_functions": ["Plugin-allowed_func"]} ) - fcc = FunctionCallContent(name="Plugin-allowed_func", plugin_name="Plugin", function_name="allowed_func", arguments={}, id="call1") + fcc = FunctionCallContent( + name="Plugin-allowed_func", plugin_name="Plugin", + function_name="allowed_func", arguments={}, id="call1", + ) chat_history = ChatHistory() await kernel.invoke_function_call( From fa1ef39c55f87831883a79b9eb4ebc9aec7eb685 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 12:15:14 +0100 Subject: [PATCH 10/14] Fix ruff lint errors: import sorting and blank lines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/semantic_kernel/agents/azure_ai/azure_ai_agent.py | 2 +- .../unit/agents/azure_ai_agent/test_agent_thread_actions.py | 2 +- .../agents/openai_assistant/test_assistant_thread_actions.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py b/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py index 74b64ed534be..d181ae5738b4 100644 --- a/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py +++ b/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py @@ -40,8 +40,8 @@ from semantic_kernel.agents.azure_ai.azure_ai_channel import AzureAIChannel from semantic_kernel.agents.channels.agent_channel import AgentChannel from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions -from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.exceptions.agent_exceptions import ( diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index 16d09b87c4f3..0980c3535b2f 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest - from azure.ai.agents.models import ( MessageTextContent, MessageTextDetails, @@ -639,6 +638,7 @@ def other_func() -> str: result_item = chat_history.messages[0].items[0] assert "ok" in str(result_item.result) + async def test_invoke_raises_for_non_auto_fcb(ai_project_client, ai_agent_definition): """Calling AgentThreadActions.invoke() with a non-Auto FCB should raise before any API call.""" agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition) diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index 5d9742027cae..9b8b16b52821 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -1058,6 +1058,7 @@ async def test_get_tools_uses_passed_kernel_not_agent_kernel(): kernel.get_full_list_of_function_metadata.assert_called_once() agent.kernel.get_full_list_of_function_metadata.assert_not_called() + async def test_invoke_function_calls_blocks_disallowed_function(): """A real Kernel should block a function call not in the FCB allowlist. @@ -1155,6 +1156,7 @@ def other_func() -> str: result_item = chat_history.messages[0].items[0] assert "ok" in str(result_item.result) + async def test_invoke_raises_for_non_auto_fcb(): """Calling AssistantThreadActions.invoke() with a non-Auto FCB should raise before any API call.""" agent = MagicMock(spec=OpenAIAssistantAgent) From ad8f8860c0c6333d0aec1d3c9fdcfd51bad0fbf7 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 12:20:46 +0100 Subject: [PATCH 11/14] Apply ruff format to all changed files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai/agent_thread_actions.py | 15 +++-- .../open_ai/assistant_thread_actions.py | 20 +++++-- .../test_agent_thread_actions.py | 58 +++++++------------ .../test_assistant_thread_actions.py | 58 +++++++------------ .../test_openai_assistant_agent.py | 4 +- 5 files changed, 67 insertions(+), 88 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 8a08f64cafd6..71bb5e5f38cf 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -168,8 +168,9 @@ async def invoke( cls._validate_function_choice_behavior(function_choice_behavior) - tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, - function_choice_behavior=function_choice_behavior) # type: ignore + tools = cls._get_tools( + agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior + ) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -525,8 +526,9 @@ async def invoke_stream( cls._validate_function_choice_behavior(function_choice_behavior) - tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, - function_choice_behavior=function_choice_behavior) # type: ignore + tools = cls._get_tools( + agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior + ) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -1208,7 +1210,10 @@ async def _handle_streaming_requires_action( chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"] results = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, + kernel=kernel, + fccs=fccs, + chat_history=chat_history, + arguments=arguments, function_choice_behavior=function_choice_behavior, ) diff --git a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py index 0babf67fa21c..066a8b1eab2e 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py +++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py @@ -197,8 +197,9 @@ async def invoke( cls._validate_function_choice_behavior(function_choice_behavior) - tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, - function_choice_behavior=function_choice_behavior) # type: ignore + tools = cls._get_tools( + agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior + ) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -271,7 +272,10 @@ async def invoke( chat_history = ChatHistory() _ = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, + kernel=kernel, + fccs=fccs, + chat_history=chat_history, + arguments=arguments, function_choice_behavior=function_choice_behavior, ) @@ -428,8 +432,9 @@ async def invoke_stream( cls._validate_function_choice_behavior(function_choice_behavior) - tools = cls._get_tools(agent=agent, kernel=kernel, tools_override=tools, - function_choice_behavior=function_choice_behavior) # type: ignore + tools = cls._get_tools( + agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior + ) # type: ignore base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments) @@ -586,7 +591,10 @@ async def _handle_streaming_requires_action( chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"] results = await cls._invoke_function_calls( - kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments, + kernel=kernel, + fccs=fccs, + chat_history=chat_history, + arguments=arguments, function_choice_behavior=function_choice_behavior, ) diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index 0980c3535b2f..0916183e14ce 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -368,24 +368,18 @@ async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_de async def test_validate_function_choice_behavior_rejects_required(): """Required FCB is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="not supported"): - AgentThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.Required() - ) + AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Required()) async def test_validate_function_choice_behavior_accepts_auto(): """Auto FCB should be accepted without error.""" - AgentThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.Auto() - ) + AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto()) async def test_validate_function_choice_behavior_rejects_none_invoke(): """NoneInvoke FCB is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="not supported"): - AgentThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.NoneInvoke() - ) + AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.NoneInvoke()) async def test_validate_function_choice_behavior_accepts_none(): @@ -396,9 +390,7 @@ async def test_validate_function_choice_behavior_accepts_none(): async def test_validate_function_choice_behavior_rejects_auto_invoke_false(): """Auto with auto_invoke=False is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="auto_invoke"): - AgentThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.Auto(auto_invoke=False) - ) + AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto(auto_invoke=False)) async def test_validate_function_choice_behavior_rejects_empty_filters(): @@ -434,9 +426,7 @@ async def test_get_tools_with_tools_override(ai_project_client, ai_agent_definit kernel.get_full_list_of_function_metadata.return_value = [] override_tool = CodeInterpreterToolDefinition() - tools = AgentThreadActions._get_tools( - agent=agent, kernel=kernel, tools_override=[override_tool] - ) + tools = AgentThreadActions._get_tools(agent=agent, kernel=kernel, tools_override=[override_tool]) # Should contain the override tool, not agent.definition.tools assert any( (isinstance(t, CodeInterpreterToolDefinition) or (isinstance(t, dict) and t.get("type") == "code_interpreter")) @@ -465,12 +455,8 @@ async def test_get_tools_with_fcb_filters(ai_project_client, ai_agent_definition kernel.get_list_of_function_metadata.return_value = [mock_metadata] kernel.get_full_list_of_function_metadata.return_value = [] - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-AllowedFunc"]} - ) - AgentThreadActions._get_tools( - agent=agent, kernel=kernel, function_choice_behavior=fcb - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-AllowedFunc"]}) + AgentThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb) # Should have called get_list_of_function_metadata with the filters kernel.get_list_of_function_metadata.assert_called_once_with(fcb.filters) @@ -481,9 +467,7 @@ async def test_get_tools_with_fcb_disable_kernel_functions(ai_project_client, ai kernel = MagicMock(spec=Kernel) fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) - AgentThreadActions._get_tools( - agent=agent, kernel=kernel, function_choice_behavior=fcb - ) + AgentThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb) # Full list is called for validation, but filtered list should not be called kernel.get_full_list_of_function_metadata.assert_called_once() kernel.get_list_of_function_metadata.assert_not_called() @@ -498,9 +482,7 @@ async def test_invoke_function_calls_passes_function_behavior(): from semantic_kernel.contents.chat_history import ChatHistory chat_history = ChatHistory() - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-Func"]} - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-Func"]}) await AgentThreadActions._invoke_function_calls( kernel=mock_kernel, @@ -570,14 +552,15 @@ def disallowed_func() -> str: ) ) - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-allowed_func"]} - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]}) # Call a function NOT in the allowlist fcc = FunctionCallContent( - name="Plugin-disallowed_func", plugin_name="Plugin", - function_name="disallowed_func", arguments={}, id="call1", + name="Plugin-disallowed_func", + plugin_name="Plugin", + function_name="disallowed_func", + arguments={}, + id="call1", ) chat_history = ChatHistory() @@ -618,13 +601,14 @@ def other_func() -> str: ) ) - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-allowed_func"]} - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]}) fcc = FunctionCallContent( - name="Plugin-allowed_func", plugin_name="Plugin", - function_name="allowed_func", arguments={}, id="call1", + name="Plugin-allowed_func", + plugin_name="Plugin", + function_name="allowed_func", + arguments={}, + id="call1", ) chat_history = ChatHistory() diff --git a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py index 9b8b16b52821..f4758dd13a43 100644 --- a/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py +++ b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py @@ -862,24 +862,18 @@ async def test_handle_streaming_requires_action_returns_none(): async def test_validate_function_choice_behavior_rejects_required(): """Required FCB is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="not supported"): - AssistantThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.Required() - ) + AssistantThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Required()) async def test_validate_function_choice_behavior_accepts_auto(): """Auto FCB should be accepted without error.""" - AssistantThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.Auto() - ) + AssistantThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto()) async def test_validate_function_choice_behavior_rejects_none_invoke(): """NoneInvoke FCB is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="not supported"): - AssistantThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.NoneInvoke() - ) + AssistantThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.NoneInvoke()) async def test_validate_function_choice_behavior_accepts_none(): @@ -890,9 +884,7 @@ async def test_validate_function_choice_behavior_accepts_none(): async def test_validate_function_choice_behavior_rejects_auto_invoke_false(): """Auto with auto_invoke=False is not supported for agent invocations.""" with pytest.raises(AgentInvokeException, match="auto_invoke"): - AssistantThreadActions._validate_function_choice_behavior( - FunctionChoiceBehavior.Auto(auto_invoke=False) - ) + AssistantThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto(auto_invoke=False)) async def test_validate_function_choice_behavior_rejects_empty_filters(): @@ -931,9 +923,7 @@ async def test_get_tools_with_tools_override(): # Override with file_search only override_tools = [FileSearchTool(type="file_search")] - tools = AssistantThreadActions._get_tools( - agent=agent, kernel=kernel, tools_override=override_tools - ) + tools = AssistantThreadActions._get_tools(agent=agent, kernel=kernel, tools_override=override_tools) # Should contain file_search from override, not code_interpreter from agent tool_types = [t.get("type") if isinstance(t, dict) else None for t in tools] assert "file_search" in tool_types @@ -964,12 +954,8 @@ async def test_get_tools_with_fcb_filters(): kernel.get_list_of_function_metadata.return_value = [mock_metadata] - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-AllowedFunc"]} - ) - AssistantThreadActions._get_tools( - agent=agent, kernel=kernel, function_choice_behavior=fcb - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-AllowedFunc"]}) + AssistantThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb) kernel.get_list_of_function_metadata.assert_called_once_with(fcb.filters) @@ -983,9 +969,7 @@ async def test_get_tools_with_fcb_disable_kernel_functions(): kernel = MagicMock(spec=Kernel) fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False) - AssistantThreadActions._get_tools( - agent=agent, kernel=kernel, function_choice_behavior=fcb - ) + AssistantThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb) kernel.get_full_list_of_function_metadata.assert_not_called() kernel.get_list_of_function_metadata.assert_not_called() @@ -999,9 +983,7 @@ async def test_invoke_function_calls_passes_function_behavior(): from semantic_kernel.contents.chat_history import ChatHistory chat_history = ChatHistory() - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-Func"]} - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-Func"]}) await AssistantThreadActions._invoke_function_calls( kernel=mock_kernel, @@ -1088,14 +1070,15 @@ def disallowed_func() -> str: ) ) - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-allowed_func"]} - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]}) # Call a function NOT in the allowlist fcc = FunctionCallContent( - name="Plugin-disallowed_func", plugin_name="Plugin", - function_name="disallowed_func", arguments={}, id="call1", + name="Plugin-disallowed_func", + plugin_name="Plugin", + function_name="disallowed_func", + arguments={}, + id="call1", ) chat_history = ChatHistory() @@ -1136,13 +1119,14 @@ def other_func() -> str: ) ) - fcb = FunctionChoiceBehavior.Auto( - filters={"included_functions": ["Plugin-allowed_func"]} - ) + fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]}) fcc = FunctionCallContent( - name="Plugin-allowed_func", plugin_name="Plugin", - function_name="allowed_func", arguments={}, id="call1", + name="Plugin-allowed_func", + plugin_name="Plugin", + function_name="allowed_func", + arguments={}, + id="call1", ) chat_history = ChatHistory() diff --git a/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py b/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py index 88262c22b925..40c3e42145c8 100644 --- a/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py +++ b/python/tests/unit/agents/openai_assistant/test_openai_assistant_agent.py @@ -500,9 +500,7 @@ async def test_openai_assistant_agent_from_yaml_invalid_type(): await AgentRegistry.create_from_yaml(spec) -async def test_openai_assistant_agent_get_response_passes_function_choice_behavior( - openai_client, assistant_definition -): +async def test_openai_assistant_agent_get_response_passes_function_choice_behavior(openai_client, assistant_definition): agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition) thread = AsyncMock(spec=AssistantAgentThread) fcb = FunctionChoiceBehavior.Auto() From 2aaadc34ad5525e595d63f1eb585468f50ab5823 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 12:26:50 +0100 Subject: [PATCH 12/14] Fix mypy error: annotate valid_filter_keys as set[str] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/semantic_kernel/agents/azure_ai/agent_thread_actions.py | 2 +- .../semantic_kernel/agents/open_ai/assistant_thread_actions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 71bb5e5f38cf..412ee572b63c 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -1006,7 +1006,7 @@ def _validate_function_choice_behavior( "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." ) - valid_filter_keys = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} + valid_filter_keys: set[str] = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} if function_choice_behavior.filters is not None: if not function_choice_behavior.filters: raise AgentInvokeException( diff --git a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py index 066a8b1eab2e..413825406881 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py +++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py @@ -889,7 +889,7 @@ def _validate_function_choice_behavior( "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." ) - valid_filter_keys = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} + valid_filter_keys: set[str] = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} if function_choice_behavior.filters is not None: if not function_choice_behavior.filters: raise AgentInvokeException( From 508941888a327c9f95464e6cde8777fc22c20705 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 12:34:25 +0100 Subject: [PATCH 13/14] Fix E501 line too long in valid_filter_keys declaration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agents/azure_ai/agent_thread_actions.py | 7 ++++++- .../agents/open_ai/assistant_thread_actions.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 412ee572b63c..2aece75dd7f3 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -1006,7 +1006,12 @@ def _validate_function_choice_behavior( "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." ) - valid_filter_keys: set[str] = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} + valid_filter_keys: set[str] = { + "excluded_plugins", + "included_plugins", + "excluded_functions", + "included_functions", + } if function_choice_behavior.filters is not None: if not function_choice_behavior.filters: raise AgentInvokeException( diff --git a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py index 413825406881..81af5f69d3f2 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py +++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py @@ -889,7 +889,12 @@ def _validate_function_choice_behavior( "FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. " "The agent run loop manages tool invocation; disabling auto_invoke is not compatible." ) - valid_filter_keys: set[str] = {"excluded_plugins", "included_plugins", "excluded_functions", "included_functions"} + valid_filter_keys: set[str] = { + "excluded_plugins", + "included_plugins", + "excluded_functions", + "included_functions", + } if function_choice_behavior.filters is not None: if not function_choice_behavior.filters: raise AgentInvokeException( From 71a15891dc52686715b88c72b9987d9974433fe9 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 8 Jun 2026 12:48:39 +0100 Subject: [PATCH 14/14] Fix mypy set operator error with str cast in filter validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/semantic_kernel/agents/azure_ai/agent_thread_actions.py | 2 +- .../semantic_kernel/agents/open_ai/assistant_thread_actions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 2aece75dd7f3..7015c1ef4eef 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -1019,7 +1019,7 @@ def _validate_function_choice_behavior( f"from {sorted(valid_filter_keys)}, or omit filters entirely to include all " "kernel functions." ) - unknown_keys = set(function_choice_behavior.filters.keys()) - valid_filter_keys + unknown_keys = {str(k) for k in function_choice_behavior.filters} - valid_filter_keys if unknown_keys: raise AgentInvokeException( f"Unknown filter key(s): {sorted(unknown_keys)}. " diff --git a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py index 81af5f69d3f2..6941d8f45c6e 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py +++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py @@ -902,7 +902,7 @@ def _validate_function_choice_behavior( f"from {sorted(valid_filter_keys)}, or omit filters entirely to include all " "kernel functions." ) - unknown_keys = set(function_choice_behavior.filters.keys()) - valid_filter_keys + unknown_keys = {str(k) for k in function_choice_behavior.filters} - valid_filter_keys if unknown_keys: raise AgentInvokeException( f"Unknown filter key(s): {sorted(unknown_keys)}. "