diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 4469eea47ee9..d9dd609c34e9 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -13,6 +13,7 @@
+
diff --git a/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj b/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj
index 0539702b56ff..781ac3615450 100644
--- a/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj
+++ b/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj
@@ -22,6 +22,7 @@
+
diff --git a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj
index ba48cd90be4d..a34483d663e8 100644
--- a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj
+++ b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/samples/Demos/ProcessFrameworkWithSignalR/src/ProcessFramework.Aspire.SignalR.AppHost/ProcessFramework.Aspire.SignalR.AppHost.csproj b/dotnet/samples/Demos/ProcessFrameworkWithSignalR/src/ProcessFramework.Aspire.SignalR.AppHost/ProcessFramework.Aspire.SignalR.AppHost.csproj
index 2c292d919d37..ec0ac0c9a32c 100644
--- a/dotnet/samples/Demos/ProcessFrameworkWithSignalR/src/ProcessFramework.Aspire.SignalR.AppHost/ProcessFramework.Aspire.SignalR.AppHost.csproj
+++ b/dotnet/samples/Demos/ProcessFrameworkWithSignalR/src/ProcessFramework.Aspire.SignalR.AppHost/ProcessFramework.Aspire.SignalR.AppHost.csproj
@@ -18,6 +18,7 @@
+
diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs
index 479a4759d750..5a485662553b 100644
--- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs
+++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs
@@ -299,6 +299,72 @@ public void ItCleansUpRestrictedSchemaKeywords(string typeName, string keyword,
}
}
+ [Fact]
+ public void ItDoesNotInsertDuplicateNullInTypeArrayForOptionalParameter()
+ {
+ // Arrange — schema with type array already containing "null" (as AIJsonUtilities produces for Nullable)
+ var parameterSchema = KernelJsonSchema.Parse("""{"type":["string","null"],"description":"A nullable param"}""");
+ OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
+ () => { },
+ parameters: [new KernelParameterMetadata("param1") { Description = "A nullable param", IsRequired = false, Schema = parameterSchema }]).Metadata.ToOpenAIFunction();
+
+ // Act
+ ChatTool result = f.ToFunctionDefinition(allowStrictSchemaAdherence: true);
+ ParametersData pd = JsonSerializer.Deserialize(result.FunctionParameters.ToString())!;
+
+ // Assert
+ Assert.NotNull(pd.properties);
+ Assert.Single(pd.properties);
+ var expectedSchema = """{"type":["string","null"],"description":"A nullable param"}""";
+ Assert.Equal(
+ JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
+ JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
+ }
+
+ [Fact]
+ public void ItDoesNotInsertDuplicateNullInTypeArrayForNullableKeyword()
+ {
+ // Arrange — schema with "nullable": true and type array already containing "null"
+ var parameterSchema = KernelJsonSchema.Parse("""{"type":["string","null"],"nullable":true,"description":"A nullable param"}""");
+ OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
+ () => { },
+ parameters: [new KernelParameterMetadata("param1") { Description = "A nullable param", IsRequired = true, Schema = parameterSchema }]).Metadata.ToOpenAIFunction();
+
+ // Act
+ ChatTool result = f.ToFunctionDefinition(allowStrictSchemaAdherence: true);
+ ParametersData pd = JsonSerializer.Deserialize(result.FunctionParameters.ToString())!;
+
+ // Assert — "nullable" keyword is removed in strict mode, type array should not gain duplicate "null"
+ Assert.NotNull(pd.properties);
+ Assert.Single(pd.properties);
+ var expectedSchema = """{"type":["string","null"],"description":"A nullable param"}""";
+ Assert.Equal(
+ JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
+ JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
+ }
+
+ [Fact]
+ public void ItInsertsNullInTypeArrayWhenAbsent()
+ {
+ // Arrange — schema with type array that does NOT contain "null"
+ var parameterSchema = KernelJsonSchema.Parse("""{"type":["string"],"description":"An optional param"}""");
+ OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
+ () => { },
+ parameters: [new KernelParameterMetadata("param1") { Description = "An optional param", IsRequired = false, Schema = parameterSchema }]).Metadata.ToOpenAIFunction();
+
+ // Act
+ ChatTool result = f.ToFunctionDefinition(allowStrictSchemaAdherence: true);
+ ParametersData pd = JsonSerializer.Deserialize(result.FunctionParameters.ToString())!;
+
+ // Assert — "null" should be added to the type array
+ Assert.NotNull(pd.properties);
+ Assert.Single(pd.properties);
+ var expectedSchema = """{"type":["string","null"],"description":"An optional param"}""";
+ Assert.Equal(
+ JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
+ JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
+ }
+
#pragma warning disable CA1812 // uninstantiated internal class
private sealed class ParametersData
{
diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs
index af082282e7e8..87435c7ea44d 100644
--- a/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs
+++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs
@@ -313,7 +313,8 @@ private static void InsertNullTypeIfRequired(bool insertNullType, JsonObject jso
{
return;
}
- if (typeValue is JsonArray jsonArray && !jsonArray.Contains(NullType))
+ if (typeValue is JsonArray jsonArray &&
+ !jsonArray.Any(static x => x is JsonValue jv && jv.GetValueKind() == JsonValueKind.String && jv.GetValue() == NullType))
{
jsonArray.Add(NullType);
}
diff --git a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs
index 5bfa5a8a1ff2..4f4cd2a48948 100644
--- a/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs
+++ b/dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs
@@ -412,19 +412,39 @@ value is string { } strValue &&
};
///
- /// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal.
+ /// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal,
+ /// including percent-encoded forms (e.g. "%2e%2e") that canonicalizes at request time.
/// ".." navigates up one path segment, enabling traversal to unintended endpoints.
/// "." refers to the current directory — harmless but unexpected, so rejected to prevent misuse.
///
/// The path to validate.
private static void ValidatePathSegments(string path)
{
- var segments = path.Split('/');
- for (int i = 0; i < segments.Length; i++)
+ // Split on the structural path separator first.
+ foreach (var rawSegment in path.Split('/'))
{
- if (segments[i] == "." || segments[i] == "..")
+ // Decode percent-encoding until stable to catch encoded ("%2e") and
+ // double-encoded ("%252e") dot-segments before URI canonicalization.
+ var decoded = rawSegment;
+ for (int i = 0; i < 5; i++)
{
- throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
+ var unescaped = Uri.UnescapeDataString(decoded);
+ if (string.Equals(unescaped, decoded, StringComparison.Ordinal))
+ {
+ break;
+ }
+
+ decoded = unescaped;
+ }
+
+ // A decoded segment may itself contain encoded separators ("%2f"/"%5c"),
+ // so re-split on both '/' and '\' and reject any resulting dot-segment.
+ foreach (var segment in decoded.Split('/', '\\'))
+ {
+ if (segment == "." || segment == "..")
+ {
+ throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
+ }
}
}
}
diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs
index 9b17ae442731..6273c80d494e 100644
--- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs
+++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs
@@ -1467,6 +1467,92 @@ public void ItShouldAllowDotsInNonSegmentPathParameterValues()
Assert.Equal("https://example.com/api/files/report.v2.txt", url.OriginalString);
}
+ [Theory]
+ [InlineData("/resources/%2e%2e/admin")]
+ [InlineData("/resources/%2E%2E/admin")]
+ [InlineData("/resources/%2e./admin")]
+ [InlineData("/resources/.%2e/admin")]
+ [InlineData("/resources/%2e%2e%2fadmin")]
+ [InlineData("/resources/%252e%252e/admin")]
+ [InlineData("/resources/%2e/admin")]
+ public void ItShouldRejectEncodedDotSegmentInPathTemplate(string path)
+ {
+ // Arrange — operation path template contains an encoded dot-segment that
+ // System.Uri would canonicalize into a path-traversal at request time.
+ var sut = new RestApiOperation(
+ id: "fake_id",
+ servers: [new RestApiServer("https://example.com/api")],
+ path: path,
+ method: HttpMethod.Get,
+ description: "fake_description",
+ parameters: [],
+ responses: new Dictionary(),
+ securityRequirements: []
+ );
+
+ var arguments = new Dictionary();
+
+ // Act & Assert — encoded dot-segments must be rejected before URL is built
+ var ex = Assert.Throws(() => sut.BuildOperationUrl(arguments));
+ Assert.Contains("dot-segment", ex.Message);
+ }
+
+ [Fact]
+ public void ItShouldRejectEncodedDotSegmentInPathParameter()
+ {
+ // Arrange — path parameter value is an encoded ".." (%2e%2e)
+ var parameters = new List {
+ new(
+ name: "id",
+ type: "string",
+ isRequired: true,
+ expand: false,
+ location: RestApiParameterLocation.Path,
+ style: RestApiParameterStyle.Simple)
+ };
+
+ var sut = new RestApiOperation(
+ id: "fake_id",
+ servers: [new RestApiServer("https://example.com/api")],
+ path: "/resources/{id}/details",
+ method: HttpMethod.Get,
+ description: "fake_description",
+ parameters: parameters,
+ responses: new Dictionary(),
+ securityRequirements: []
+ );
+
+ var arguments = new Dictionary { { "id", "%2e%2e" } };
+
+ // Act & Assert — encoded dot-segments in parameter values must be rejected
+ var ex = Assert.Throws(() => sut.BuildOperationUrl(arguments));
+ Assert.Contains("dot-segment", ex.Message);
+ }
+
+ [Fact]
+ public void ItShouldAllowEncodedNonDotSegmentCharactersInPathTemplate()
+ {
+ // Arrange — path contains encoded characters that are NOT dot-segments
+ var sut = new RestApiOperation(
+ id: "fake_id",
+ servers: [new RestApiServer("https://example.com/api")],
+ path: "/resources/a%20b/details",
+ method: HttpMethod.Get,
+ description: "fake_description",
+ parameters: [],
+ responses: new Dictionary(),
+ securityRequirements: []
+ );
+
+ var arguments = new Dictionary();
+
+ // Act
+ var url = sut.BuildOperationUrl(arguments);
+
+ // Assert — legitimate encoded characters must not be rejected
+ Assert.Equal("https://example.com/api/resources/a%20b/details", url.OriginalString);
+ }
+
[Fact]
public void ItShouldEncodeServerVariableValuesLookedUpByArgumentName()
{
diff --git a/python/semantic_kernel/__init__.py b/python/semantic_kernel/__init__.py
index 48e177634ca3..00fafbc1bae6 100644
--- a/python/semantic_kernel/__init__.py
+++ b/python/semantic_kernel/__init__.py
@@ -2,7 +2,7 @@
from semantic_kernel.kernel import Kernel
-__version__ = "1.43.0"
+__version__ = "1.43.1"
DEFAULT_RC_VERSION = f"{__version__}-rc9"
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..7015c1ef4eef 100644
--- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py
+++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py
@@ -68,6 +68,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_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.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.utils.author_role import AuthorRole
@@ -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,11 @@ 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 +244,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
+ 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 +483,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 +506,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 +524,11 @@ 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 +575,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 +591,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 +699,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,12 +988,74 @@ 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."
+ )
+ 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(
+ "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 = {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)}. "
+ f"Valid filter keys are: {sorted(valid_filter_keys)}."
+ )
+
@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()
- cls._validate_function_tools_registered(tools, funcs)
+ 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)
+
+ # 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: 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 = 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)
@@ -1071,6 +1162,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 +1171,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 +1204,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 +1215,11 @@ 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/azure_ai/azure_ai_agent.py b/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py
index 44af0f22fee8..d181ae5738b4 100644
--- a/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py
+++ b/python/semantic_kernel/agents/azure_ai/azure_ai_agent.py
@@ -41,6 +41,7 @@
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_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 (
@@ -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/assistant_thread_actions.py b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py
index 3a6679df643f..6941d8f45c6e 100644
--- a/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py
+++ b/python/semantic_kernel/agents/open_ai/assistant_thread_actions.py
@@ -34,6 +34,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_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.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
@@ -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,11 @@ 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 +272,11 @@ 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 +390,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 +413,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 +430,11 @@ 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 +522,7 @@ async def invoke_stream(
run,
function_steps,
arguments,
+ function_choice_behavior=function_choice_behavior,
)
if action_result is None:
raise AgentInvokeException(
@@ -553,6 +580,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 +591,11 @@ 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 +690,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 +699,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 +871,80 @@ 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."
+ )
+ 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(
+ "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 = {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)}. "
+ f"Valid filter keys are: {sorted(valid_filter_keys)}."
+ )
+
@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/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/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py
index 570a4352892c..52e1c8aac59f 100644
--- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py
+++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py
@@ -2,7 +2,7 @@
import re
from typing import Any, Final
-from urllib.parse import ParseResult, ParseResultBytes, quote, urlencode, urljoin, urlparse, urlunparse
+from urllib.parse import ParseResult, ParseResultBytes, quote, unquote, urlencode, urljoin, urlparse, urlunparse
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import (
RestApiExpectedResponse,
@@ -289,8 +289,31 @@ def build_path(self, path_template: str, arguments: dict[str, Any]) -> str:
)
continue
path_template = path_template.replace(f"{{{parameter.name}}}", quote(str(argument), safe=""))
+ self._validate_path_segments(path_template)
return path_template
+ @staticmethod
+ def _validate_path_segments(path: str) -> None:
+ """Reject dot-segments (. or ..), including percent-encoded forms, that enable path traversal.
+
+ The operation is selected using the raw path but the request URL is built from a canonicalized
+ path, so encoded dot-segments such as "%2e%2e" must be rejected before the URL is constructed.
+ """
+ for segment in path.split("/"):
+ decoded = segment
+ for _ in range(5):
+ unescaped = unquote(decoded)
+ if unescaped == decoded:
+ break
+ decoded = unescaped
+ # A decoded segment may contain encoded separators ("%2f"/"%5c"), so re-split on
+ # both "/" and "\" and reject any resulting dot-segment.
+ for part in decoded.replace("\\", "/").split("/"):
+ if part in (".", ".."):
+ raise FunctionExecutionException(
+ f"Path '{path}' contains a dot-segment, which could lead to path traversal."
+ )
+
def build_query_string(self, arguments: dict[str, Any]) -> str:
"""Build the query string for the operation."""
segments = []
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..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
@@ -3,6 +3,7 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
+import pytest
from azure.ai.agents.models import (
MessageTextContent,
MessageTextDetails,
@@ -26,9 +27,14 @@
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.functions.kernel_function_decorator import kernel_function
+from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.kernel import Kernel
@@ -354,3 +360,303 @@ 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
+
+
+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_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
+
+ 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"]})
+ 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)
+ 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()
+
+
+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
+
+
+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/azure_ai_agent/test_azure_ai_agent.py b/python/tests/unit/agents/azure_ai_agent/test_azure_ai_agent.py
index b5dc1178b6a1..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
@@ -398,3 +399,80 @@ 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):
+ 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):
+ 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):
+ 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_assistant_thread_actions.py b/python/tests/unit/agents/openai_assistant/test_assistant_thread_actions.py
index 1bb688bb42c0..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
@@ -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
@@ -853,3 +854,327 @@ 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
+
+
+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_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)
+ 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"]})
+ 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)
+ 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()
+
+
+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
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..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
@@ -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
@@ -497,3 +498,82 @@ 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):
+ 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):
+ 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
+):
+ 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
diff --git a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py
index 2dd2488fc506..c59be28e9c55 100644
--- a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py
+++ b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py
@@ -435,9 +435,9 @@ def test_build_path_prevents_path_traversal():
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
)
arguments = {"id": "../../admin"}
- result = operation.build_path(operation.path, arguments)
- # The slashes must be encoded so ../../admin becomes a single path segment, not a traversal
- assert result == "/resource/..%2F..%2Fadmin"
+ # Encoded separators that decode into dot-segments must be rejected, not silently encoded
+ with pytest.raises(FunctionExecutionException, match="dot-segment"):
+ operation.build_path(operation.path, arguments)
def test_build_path_double_encodes_pre_encoded_values():
@@ -462,6 +462,40 @@ def test_build_path_encodes_unicode_characters():
assert result == "/resource/caf%C3%A9%20r%C3%A9sum%C3%A9"
+@pytest.mark.parametrize(
+ "path",
+ [
+ "/resources/../admin",
+ "/resources/./admin",
+ "/resources/%2e%2e/admin",
+ "/resources/%2E%2E/admin",
+ "/resources/%2e/admin",
+ "/resources/%2e%2e%2fadmin",
+ "/resources/%252e%252e/admin",
+ ],
+)
+def test_build_path_rejects_dot_segment_in_template(path):
+ operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path=path, params=[])
+ with pytest.raises(FunctionExecutionException, match="dot-segment"):
+ operation.build_path(operation.path, {})
+
+
+def test_build_path_rejects_dot_segment_via_parameter():
+ parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
+ operation = RestApiOperation(
+ id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}/details", params=parameters
+ )
+ with pytest.raises(FunctionExecutionException, match="dot-segment"):
+ operation.build_path(operation.path, {"id": ".."})
+
+
+def test_build_path_allows_encoded_non_dot_segment_characters():
+ operation = RestApiOperation(
+ id="test", method="GET", servers=["https://example.com/"], path="/resources/a%20b/details", params=[]
+ )
+ assert operation.build_path(operation.path, {}) == "/resources/a%20b/details"
+
+
def test_build_query_string_with_required_parameter():
parameters = [
RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True)