Skip to content

Commit 61331d8

Browse files
Python: Add function_choice_behavior support to Azure AI and OpenAI Assistant agents (#14057)
### Description Adds `function_choice_behavior` parameter to Azure AI and OpenAI Assistant agents, aligning them with the existing Responses agent and Chat Completion agent APIs. ### Changes - Add `function_choice_behavior` parameter to `invoke`/`invoke_stream`/`get_response` on both `AzureAIAgent` and `OpenAIAssistantAgent` public APIs - Add `function_choice_behavior` parameter to internal `AzureAIAgentThreadActions` and `AssistantThreadActions` `invoke`/`invoke_stream` methods - 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 instead of `agent.kernel` - Add comprehensive unit tests for both thread actions and public agent APIs --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 417d62f commit 61331d8

8 files changed

Lines changed: 1025 additions & 20 deletions

File tree

python/semantic_kernel/agents/azure_ai/agent_thread_actions.py

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@
6868
from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult
6969
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
7070
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
71+
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
72+
from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType
7173
from semantic_kernel.contents.chat_message_content import ChatMessageContent
7274
from semantic_kernel.contents.function_call_content import FunctionCallContent
7375
from semantic_kernel.contents.utils.author_role import AuthorRole
@@ -124,6 +126,7 @@ async def invoke(
124126
parallel_tool_calls: bool | None = None,
125127
metadata: dict[str, str] | None = None,
126128
polling_options: RunPollingOptions | None = None,
129+
function_choice_behavior: FunctionChoiceBehavior | None = None,
127130
**kwargs: Any,
128131
) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]:
129132
"""Invoke the message in the thread.
@@ -139,7 +142,9 @@ async def invoke(
139142
additional_messages: The additional messages to add to the thread. Only supports messages with
140143
role = User or Assistant.
141144
https://platform.openai.com/docs/api-reference/runs/createRun#runs-createrun-additional_messages
142-
tools: The tools.
145+
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch, AzureAISearch). When provided,
146+
overrides the tools from the agent definition. Does not affect kernel function availability;
147+
use function_choice_behavior for that.
143148
temperature: The temperature.
144149
top_p: The top p.
145150
max_prompt_tokens: The max prompt tokens.
@@ -150,6 +155,9 @@ async def invoke(
150155
metadata: The metadata.
151156
polling_options: The polling options defined at the run-level. These will override the agent-level
152157
polling options.
158+
function_choice_behavior: Controls which kernel functions are allowed to execute during this run.
159+
Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific
160+
functions. Only Auto is supported; other types will raise an error.
153161
kwargs: Additional keyword arguments.
154162
155163
Returns:
@@ -158,7 +166,11 @@ async def invoke(
158166
arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs)
159167
kernel = kernel or agent.kernel
160168

161-
tools = cls._get_tools(agent=agent, kernel=kernel) # type: ignore
169+
cls._validate_function_choice_behavior(function_choice_behavior)
170+
171+
tools = cls._get_tools(
172+
agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior
173+
) # type: ignore
162174

163175
base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments)
164176

@@ -232,7 +244,11 @@ async def invoke(
232244

233245
chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"]
234246
_ = await cls._invoke_function_calls(
235-
kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments
247+
kernel=kernel,
248+
fccs=fccs,
249+
chat_history=chat_history,
250+
arguments=arguments,
251+
function_choice_behavior=function_choice_behavior,
236252
)
237253

238254
tool_outputs = cls._format_tool_outputs(fccs, chat_history)
@@ -467,6 +483,7 @@ async def invoke_stream(
467483
temperature: float | None = None,
468484
top_p: float | None = None,
469485
truncation_strategy: TruncationObject | None = None,
486+
function_choice_behavior: FunctionChoiceBehavior | None = None,
470487
**kwargs: Any,
471488
) -> AsyncIterable["StreamingChatMessageContent"]:
472489
"""Invoke the agent stream and yield ChatMessageContent continuously.
@@ -489,10 +506,15 @@ async def invoke_stream(
489506
formed from the streamed chunks.
490507
parallel_tool_calls: Whether to configure parallel tool calls.
491508
response_format: The response format.
492-
tools: The tools.
509+
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch, AzureAISearch). When provided,
510+
overrides the tools from the agent definition. Does not affect kernel function availability;
511+
use function_choice_behavior for that.
493512
temperature: The temperature.
494513
top_p: The top p.
495514
truncation_strategy: The truncation strategy.
515+
function_choice_behavior: Controls which kernel functions are allowed to execute during this run.
516+
Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific
517+
functions. Only Auto is supported; other types will raise an error.
496518
kwargs: Additional keyword arguments.
497519
498520
Returns:
@@ -502,7 +524,11 @@ async def invoke_stream(
502524
kernel = kernel or agent.kernel
503525
arguments = agent._merge_arguments(arguments)
504526

505-
tools = cls._get_tools(agent=agent, kernel=kernel) # type: ignore
527+
cls._validate_function_choice_behavior(function_choice_behavior)
528+
529+
tools = cls._get_tools(
530+
agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior
531+
) # type: ignore
506532

507533
base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments)
508534

@@ -549,6 +575,7 @@ async def invoke_stream(
549575
arguments=arguments,
550576
function_steps=function_steps,
551577
active_messages=active_messages,
578+
function_choice_behavior=function_choice_behavior,
552579
):
553580
if content:
554581
yield content
@@ -564,6 +591,7 @@ async def _process_stream_events(
564591
function_steps: dict[str, FunctionCallContent],
565592
active_messages: dict[str, RunStep],
566593
output_messages: "list[ChatMessageContent] | None" = None,
594+
function_choice_behavior: FunctionChoiceBehavior | None = None,
567595
) -> AsyncIterable["StreamingChatMessageContent"]:
568596
"""Process events from the main stream and delegate tool output handling as needed."""
569597
thread_msg_id = None
@@ -671,6 +699,7 @@ async def _process_stream_events(
671699
run=run,
672700
function_steps=function_steps,
673701
arguments=arguments,
702+
function_choice_behavior=function_choice_behavior,
674703
)
675704
if action_result is None:
676705
raise RuntimeError(
@@ -959,12 +988,74 @@ def _deduplicate_tools(existing_tools: list[dict], new_tools: list[dict]) -> lis
959988
}
960989
return [tool for tool in new_tools if tool.get("function", {}).get("name") not in existing_names]
961990

991+
@staticmethod
992+
def _validate_function_choice_behavior(
993+
function_choice_behavior: FunctionChoiceBehavior | None,
994+
) -> None:
995+
"""Validate the function choice behavior is compatible with agent invocations."""
996+
if function_choice_behavior is None:
997+
return
998+
if function_choice_behavior.type_ != FunctionChoiceType.AUTO:
999+
raise AgentInvokeException(
1000+
f"FunctionChoiceBehavior with type '{function_choice_behavior.type_}' is not supported for agent "
1001+
"invocations. Use FunctionChoiceBehavior.Auto(filters=...) to control which kernel functions "
1002+
"are available."
1003+
)
1004+
if not function_choice_behavior.auto_invoke_kernel_functions:
1005+
raise AgentInvokeException(
1006+
"FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. "
1007+
"The agent run loop manages tool invocation; disabling auto_invoke is not compatible."
1008+
)
1009+
valid_filter_keys: set[str] = {
1010+
"excluded_plugins",
1011+
"included_plugins",
1012+
"excluded_functions",
1013+
"included_functions",
1014+
}
1015+
if function_choice_behavior.filters is not None:
1016+
if not function_choice_behavior.filters:
1017+
raise AgentInvokeException(
1018+
"FunctionChoiceBehavior filters must not be empty. Provide at least one filter key "
1019+
f"from {sorted(valid_filter_keys)}, or omit filters entirely to include all "
1020+
"kernel functions."
1021+
)
1022+
unknown_keys = {str(k) for k in function_choice_behavior.filters} - valid_filter_keys
1023+
if unknown_keys:
1024+
raise AgentInvokeException(
1025+
f"Unknown filter key(s): {sorted(unknown_keys)}. "
1026+
f"Valid filter keys are: {sorted(valid_filter_keys)}."
1027+
)
1028+
9621029
@classmethod
963-
def _get_tools(cls: type[_T], agent: "AzureAIAgent", kernel: "Kernel") -> list[dict[str, Any] | ToolDefinition]:
964-
"""Get the tools for the agent."""
965-
tools: list[Any] = list(agent.definition.tools)
966-
funcs = kernel.get_full_list_of_function_metadata()
967-
cls._validate_function_tools_registered(tools, funcs)
1030+
def _get_tools(
1031+
cls: type[_T],
1032+
agent: "AzureAIAgent",
1033+
kernel: "Kernel",
1034+
tools_override: list[ToolDefinition] | None = None,
1035+
function_choice_behavior: FunctionChoiceBehavior | None = None,
1036+
) -> list[dict[str, Any] | ToolDefinition]:
1037+
"""Get the tools for the agent.
1038+
1039+
Args:
1040+
agent: The agent instance.
1041+
kernel: The kernel to use for function metadata.
1042+
tools_override: When provided, overrides agent.definition.tools (SDK-level tools only).
1043+
function_choice_behavior: When provided, filters which kernel functions are included.
1044+
"""
1045+
tools: list[Any] = list(tools_override) if tools_override is not None else list(agent.definition.tools)
1046+
1047+
# Always validate against the full kernel function list to catch truly
1048+
# unregistered functions, regardless of FCB filtering.
1049+
all_funcs = kernel.get_full_list_of_function_metadata()
1050+
cls._validate_function_tools_registered(tools, all_funcs)
1051+
1052+
# Determine which kernel functions to advertise based on function_choice_behavior
1053+
if function_choice_behavior is not None and not function_choice_behavior.enable_kernel_functions:
1054+
funcs: list[KernelFunctionMetadata] = []
1055+
elif function_choice_behavior is not None and function_choice_behavior.filters:
1056+
funcs = kernel.get_list_of_function_metadata(function_choice_behavior.filters)
1057+
else:
1058+
funcs = all_funcs
9681059
dict_defs = [kernel_function_metadata_to_function_call_format(f) for f in funcs]
9691060
deduped_defs = cls._deduplicate_tools(tools, dict_defs)
9701061
tools.extend(deduped_defs)
@@ -1071,6 +1162,7 @@ async def _invoke_function_calls(
10711162
fccs: list["FunctionCallContent"],
10721163
chat_history: "ChatHistory",
10731164
arguments: KernelArguments,
1165+
function_choice_behavior: FunctionChoiceBehavior | None = None,
10741166
) -> list["AutoFunctionInvocationContext | None"]:
10751167
"""Invoke the function calls."""
10761168
return await asyncio.gather(
@@ -1079,6 +1171,7 @@ async def _invoke_function_calls(
10791171
function_call=function_call,
10801172
chat_history=chat_history,
10811173
arguments=arguments,
1174+
function_behavior=function_choice_behavior,
10821175
)
10831176
for function_call in fccs
10841177
],
@@ -1111,6 +1204,7 @@ async def _handle_streaming_requires_action(
11111204
run: ThreadRun,
11121205
function_steps: dict[str, "FunctionCallContent"],
11131206
arguments: KernelArguments,
1207+
function_choice_behavior: FunctionChoiceBehavior | None = None,
11141208
**kwargs: Any,
11151209
) -> FunctionActionResult | None:
11161210
"""Handle the requires action event for a streaming run."""
@@ -1121,7 +1215,11 @@ async def _handle_streaming_requires_action(
11211215

11221216
chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"]
11231217
results = await cls._invoke_function_calls(
1124-
kernel=kernel, fccs=fccs, chat_history=chat_history, arguments=arguments
1218+
kernel=kernel,
1219+
fccs=fccs,
1220+
chat_history=chat_history,
1221+
arguments=arguments,
1222+
function_choice_behavior=function_choice_behavior,
11251223
)
11261224

11271225
function_result_streaming_content = merge_streaming_function_results(

python/semantic_kernel/agents/azure_ai/azure_ai_agent.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from semantic_kernel.agents.channels.agent_channel import AgentChannel
4242
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
4343
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
44+
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
4445
from semantic_kernel.contents.chat_message_content import ChatMessageContent
4546
from semantic_kernel.contents.utils.author_role import AuthorRole
4647
from semantic_kernel.exceptions.agent_exceptions import (
@@ -647,6 +648,7 @@ async def get_response(
647648
parallel_tool_calls: bool | None = None,
648649
metadata: dict[str, str] | None = None,
649650
polling_options: RunPollingOptions | None = None,
651+
function_choice_behavior: FunctionChoiceBehavior | None = None,
650652
**kwargs: Any,
651653
) -> AgentResponseItem[ChatMessageContent]:
652654
"""Get a response from the agent on a thread.
@@ -671,6 +673,8 @@ async def get_response(
671673
parallel_tool_calls: Whether to allow parallel tool calls.
672674
metadata: Metadata for the agent.
673675
polling_options: The polling options for the agent.
676+
function_choice_behavior: The function choice behavior to control which kernel
677+
functions are available. Only Auto is supported; other types will raise an error.
674678
**kwargs: Additional keyword arguments.
675679
676680
Returns:
@@ -716,6 +720,7 @@ async def get_response(
716720
thread_id=thread.id,
717721
kernel=kernel,
718722
arguments=arguments,
723+
function_choice_behavior=function_choice_behavior,
719724
**run_level_params, # type: ignore
720725
):
721726
if is_visible and response.metadata.get("code") is not True:
@@ -752,6 +757,7 @@ async def invoke(
752757
parallel_tool_calls: bool | None = None,
753758
metadata: dict[str, str] | None = None,
754759
polling_options: RunPollingOptions | None = None,
760+
function_choice_behavior: FunctionChoiceBehavior | None = None,
755761
**kwargs: Any,
756762
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
757763
"""Invoke the agent on the specified thread.
@@ -777,6 +783,8 @@ async def invoke(
777783
parallel_tool_calls: Whether to allow parallel tool calls.
778784
polling_options: The polling options for the agent.
779785
metadata: Metadata for the agent.
786+
function_choice_behavior: The function choice behavior to control which kernel
787+
functions are available. Only Auto is supported; other types will raise an error.
780788
**kwargs: Additional keyword arguments.
781789
782790
Yields:
@@ -821,6 +829,7 @@ async def invoke(
821829
thread_id=thread.id,
822830
kernel=kernel,
823831
arguments=arguments,
832+
function_choice_behavior=function_choice_behavior,
824833
**run_level_params, # type: ignore
825834
):
826835
message.metadata["thread_id"] = thread.id
@@ -856,6 +865,7 @@ async def invoke_stream(
856865
response_format: AgentsApiResponseFormatOption | None = None,
857866
parallel_tool_calls: bool | None = None,
858867
metadata: dict[str, str] | None = None,
868+
function_choice_behavior: FunctionChoiceBehavior | None = None,
859869
**kwargs: Any,
860870
) -> AsyncIterable[AgentResponseItem["StreamingChatMessageContent"]]:
861871
"""Invoke the agent on the specified thread with a stream of messages.
@@ -881,6 +891,8 @@ async def invoke_stream(
881891
response_format: Response format for the agent.
882892
parallel_tool_calls: Whether to allow parallel tool calls.
883893
metadata: Metadata for the agent.
894+
function_choice_behavior: The function choice behavior to control which kernel
895+
functions are available. Only Auto is supported; other types will raise an error.
884896
**kwargs: Additional keyword arguments.
885897
886898
Yields:
@@ -928,6 +940,7 @@ async def invoke_stream(
928940
output_messages=collected_messages,
929941
kernel=kernel,
930942
arguments=arguments,
943+
function_choice_behavior=function_choice_behavior,
931944
**run_level_params, # type: ignore
932945
):
933946
# Before yielding the current streamed message, emit any new full messages first

0 commit comments

Comments
 (0)