mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
Python: Improve function call invocation parameter consistency (#14014)
## Description This PR improves the consistency of how the function_behavior parameter is passed through internal invoke_function_call callsites. ### Changes - Thread function_behavior parameter through all internal invoke_function_call callsites for consistent parameter handling: - Realtime connector (_open_ai_realtime.py) - Responses agent (responses_agent_thread_actions.py) - Assistant agent (assistant_thread_actions.py) - Azure AI agent (agent_thread_actions.py) - Bedrock agent (bedrock_agent.py) - Add defensive logging in kernel.py when function_behavior is not provided - Add unit tests covering parameter handling scenarios ### Testing - All existing tests pass (678 passed, 0 failed) - 5 new tests added covering the updated parameter handling Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Copilot
parent
2fb749e4bb
commit
58f4a66858
@@ -681,6 +681,7 @@ class BedrockAgent(BedrockAgentBase):
|
||||
chat_history=chat_history,
|
||||
arguments=self.arguments,
|
||||
function_call_count=len(function_call_contents),
|
||||
function_behavior=self.function_choice_behavior,
|
||||
)
|
||||
for function_call in function_call_contents
|
||||
],
|
||||
|
||||
@@ -1111,11 +1111,17 @@ class ResponsesAgentThreadActions:
|
||||
|
||||
@classmethod
|
||||
async def _invoke_function_calls(
|
||||
cls: type[_T], kernel: "Kernel", fccs: list["FunctionCallContent"], chat_history: "ChatHistory"
|
||||
cls: type[_T],
|
||||
kernel: "Kernel",
|
||||
fccs: list["FunctionCallContent"],
|
||||
chat_history: "ChatHistory",
|
||||
function_behavior: "FunctionChoiceBehavior | None" = None,
|
||||
) -> list[Any]:
|
||||
"""Invoke the function calls."""
|
||||
tasks = [
|
||||
kernel.invoke_function_call(function_call=function_call, chat_history=chat_history)
|
||||
kernel.invoke_function_call(
|
||||
function_call=function_call, chat_history=chat_history, function_behavior=function_behavior
|
||||
)
|
||||
for function_call in fccs
|
||||
]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
@@ -481,7 +481,8 @@ class OpenAIRealtimeBase(OpenAIHandler, RealtimeClientBase):
|
||||
|
||||
# Step 4: Invoke the function call
|
||||
chat_history = ChatHistory()
|
||||
await self._kernel.invoke_function_call(item, chat_history)
|
||||
function_behavior = self._current_settings.function_choice_behavior if self._current_settings else None
|
||||
await self._kernel.invoke_function_call(item, chat_history, function_behavior=function_behavior)
|
||||
created_output: FunctionResultContent = chat_history.messages[-1].items[0] # type: ignore
|
||||
# Step 5: Create the function result event
|
||||
result = RealtimeFunctionResultEvent(
|
||||
|
||||
@@ -347,6 +347,13 @@ class Kernel(KernelFilterExtension, KernelFunctionExtension, KernelServicesExten
|
||||
raise FunctionExecutionException(
|
||||
f"Only functions: {allowed_functions} are allowed, {function_call.name} is not allowed."
|
||||
)
|
||||
elif function_behavior is None:
|
||||
logger.debug(
|
||||
"invoke_function_call called without function_behavior. "
|
||||
"No allowlist validation will be performed for function '%s'. "
|
||||
"Pass a FunctionChoiceBehavior with filters to enable validation.",
|
||||
function_call.name,
|
||||
)
|
||||
function_to_call = self.get_function(function_call.plugin_name, function_call.function_name)
|
||||
except Exception as exc:
|
||||
logger.exception(f"The function `{function_call.name}` is not part of the provided functions: {exc}.")
|
||||
|
||||
@@ -556,6 +556,86 @@ async def test_parse_function_call_arguments_done_fail(OpenAIWebsocket, kernel):
|
||||
iter += 1
|
||||
|
||||
|
||||
async def test_parse_function_call_arguments_done_passes_function_behavior(OpenAIWebsocket, kernel):
|
||||
"""Verify that the realtime path passes function_choice_behavior to invoke_function_call."""
|
||||
func_result = "result"
|
||||
event = ResponseFunctionCallArgumentsDoneEvent(
|
||||
call_id="call_id",
|
||||
arguments='{"x": "' + func_result + '"}',
|
||||
event_id="event_id",
|
||||
output_index=0,
|
||||
item_id="item_id",
|
||||
name="plugin_name-function_name",
|
||||
response_id="response_id",
|
||||
type="response.function_call_arguments.done",
|
||||
)
|
||||
function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["plugin_name"]})
|
||||
OpenAIWebsocket._current_settings = OpenAIRealtimeExecutionSettings(
|
||||
instructions="instructions", ai_model_id="gpt-realtime"
|
||||
)
|
||||
OpenAIWebsocket._current_settings.function_choice_behavior = function_behavior
|
||||
OpenAIWebsocket._call_id_to_function_map["call_id"] = "plugin_name-function_name"
|
||||
func = kernel_function(name="function_name", description="function_description")(lambda x: x)
|
||||
kernel.add_function(plugin_name="plugin_name", function_name="function_name", function=func)
|
||||
OpenAIWebsocket._kernel = kernel
|
||||
|
||||
# Capture the kwargs passed to invoke_function_call
|
||||
captured_kwargs = {}
|
||||
original_invoke = Kernel.invoke_function_call
|
||||
|
||||
async def spy_invoke(self, *args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return await original_invoke(self, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(Kernel, "invoke_function_call", spy_invoke),
|
||||
patch.object(OpenAIWebsocket, "_send"),
|
||||
):
|
||||
async for _ in OpenAIWebsocket._parse_function_call_arguments_done(event):
|
||||
pass
|
||||
|
||||
assert "function_behavior" in captured_kwargs
|
||||
assert captured_kwargs["function_behavior"] is function_behavior
|
||||
|
||||
|
||||
async def test_parse_function_call_arguments_done_filters_block_unallowed(OpenAIWebsocket, kernel):
|
||||
"""Verify that the realtime path blocks a function not in the allowlist."""
|
||||
event = ResponseFunctionCallArgumentsDoneEvent(
|
||||
call_id="call_id",
|
||||
arguments='{"url": "http://169.254.169.254/"}',
|
||||
event_id="event_id",
|
||||
output_index=0,
|
||||
item_id="item_id",
|
||||
name="HttpPlugin-GetAsync",
|
||||
response_id="response_id",
|
||||
type="response.function_call_arguments.done",
|
||||
)
|
||||
function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["SafePlugin"]})
|
||||
OpenAIWebsocket._current_settings = OpenAIRealtimeExecutionSettings(
|
||||
instructions="instructions", ai_model_id="gpt-realtime"
|
||||
)
|
||||
OpenAIWebsocket._current_settings.function_choice_behavior = function_behavior
|
||||
OpenAIWebsocket._call_id_to_function_map["call_id"] = "HttpPlugin-GetAsync"
|
||||
|
||||
# Register both plugins on kernel
|
||||
safe_func = kernel_function(name="safe_function", description="safe")(lambda: "safe")
|
||||
http_func = kernel_function(name="GetAsync", description="http get")(lambda url: url)
|
||||
kernel.add_function(plugin_name="SafePlugin", function_name="safe_function", function=safe_func)
|
||||
kernel.add_function(plugin_name="HttpPlugin", function_name="GetAsync", function=http_func)
|
||||
OpenAIWebsocket._kernel = kernel
|
||||
|
||||
events_received = []
|
||||
with patch.object(OpenAIWebsocket, "_send"):
|
||||
async for evt in OpenAIWebsocket._parse_function_call_arguments_done(event):
|
||||
events_received.append(evt)
|
||||
|
||||
# The function call event is yielded, then the result should contain the error
|
||||
assert len(events_received) >= 2
|
||||
result_event = events_received[-1]
|
||||
assert isinstance(result_event, RealtimeFunctionResultEvent)
|
||||
assert "not part of the provided" in str(result_event.function_result.result)
|
||||
|
||||
|
||||
async def test_send_audio(OpenAIWebsocket):
|
||||
audio_event = RealtimeAudioEvent(
|
||||
audio=AudioContent(data=b"audio data", mime_type="audio/wav"),
|
||||
|
||||
@@ -626,6 +626,101 @@ async def test_invoke_function_call_with_missing_or_unexpected_args(kernel: Kern
|
||||
), "Expected fallback message not found in chat history."
|
||||
|
||||
|
||||
async def test_invoke_function_call_with_filters_blocks_unallowed_function(kernel: Kernel):
|
||||
"""Verify that when function_behavior has filters, an unallowed function is blocked."""
|
||||
tool_call_mock = MagicMock(spec=FunctionCallContent)
|
||||
tool_call_mock.name = "HttpPlugin-GetAsync"
|
||||
tool_call_mock.function_name = "GetAsync"
|
||||
tool_call_mock.plugin_name = "HttpPlugin"
|
||||
tool_call_mock.arguments = {"url": "http://169.254.169.254/"}
|
||||
tool_call_mock.ai_model_id = None
|
||||
tool_call_mock.metadata = {}
|
||||
tool_call_mock.index = 0
|
||||
tool_call_mock.id = "test_id"
|
||||
|
||||
chat_history = ChatHistory()
|
||||
|
||||
safe_func_meta = KernelFunctionMetadata(name="safe_function", is_prompt=False, plugin_name="SafePlugin")
|
||||
function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["SafePlugin"]})
|
||||
|
||||
with patch("semantic_kernel.kernel.Kernel.get_list_of_function_metadata", return_value=[safe_func_meta]):
|
||||
await kernel.invoke_function_call(
|
||||
function_call=tool_call_mock,
|
||||
chat_history=chat_history,
|
||||
function_behavior=function_behavior,
|
||||
)
|
||||
|
||||
# The function should have been blocked — an error message should be in chat history
|
||||
assert len(chat_history.messages) == 1
|
||||
assert "not allowed" in str(chat_history.messages[0].items[0].result) or "not part of the provided" in str(
|
||||
chat_history.messages[0].items[0].result
|
||||
)
|
||||
|
||||
|
||||
async def test_invoke_function_call_with_filters_allows_matching_function(kernel: Kernel, get_tool_call_mock):
|
||||
"""Verify that when function_behavior has filters, an allowed function proceeds (not blocked)."""
|
||||
tool_call_mock = get_tool_call_mock
|
||||
chat_history_mock = MagicMock(spec=ChatHistory)
|
||||
|
||||
func_meta = KernelFunctionMetadata(
|
||||
name="function", is_prompt=False, plugin_name="test", fully_qualified_name="test-function"
|
||||
)
|
||||
|
||||
func_mock = AsyncMock(spec=KernelFunction)
|
||||
func_mock.metadata = func_meta
|
||||
func_mock.name = "function"
|
||||
func_mock.parameters = []
|
||||
func_result = FunctionResult(value="ok", function=func_meta)
|
||||
func_mock.invoke = AsyncMock(return_value=func_result)
|
||||
|
||||
function_behavior = FunctionChoiceBehavior.Auto(filters={"included_plugins": ["test"]})
|
||||
|
||||
with (
|
||||
patch("semantic_kernel.kernel.logger", autospec=True) as logger_mock,
|
||||
patch("semantic_kernel.kernel.Kernel.get_list_of_function_metadata", return_value=[func_meta]),
|
||||
patch("semantic_kernel.kernel.Kernel.get_function", return_value=func_mock),
|
||||
):
|
||||
await kernel.invoke_function_call(
|
||||
function_call=tool_call_mock,
|
||||
chat_history=chat_history_mock,
|
||||
function_behavior=function_behavior,
|
||||
)
|
||||
|
||||
# The debug message for missing function_behavior should NOT have been logged
|
||||
debug_calls = [call[0][0] for call in logger_mock.debug.call_args_list] if logger_mock.debug.called else []
|
||||
assert not any("without function_behavior" in msg for msg in debug_calls)
|
||||
# The exception logger should NOT have been called (function was allowed)
|
||||
logger_mock.exception.assert_not_called()
|
||||
|
||||
|
||||
async def test_invoke_function_call_without_function_behavior_logs_debug(kernel: Kernel, get_tool_call_mock):
|
||||
"""Verify that calling invoke_function_call without function_behavior logs a debug message."""
|
||||
tool_call_mock = get_tool_call_mock
|
||||
chat_history_mock = MagicMock(spec=ChatHistory)
|
||||
|
||||
func_mock = AsyncMock(spec=KernelFunction)
|
||||
func_meta = KernelFunctionMetadata(name="function", is_prompt=False)
|
||||
func_mock.metadata = func_meta
|
||||
func_mock.name = "function"
|
||||
func_mock.parameters = []
|
||||
func_result = FunctionResult(value="Function result", function=func_meta)
|
||||
func_mock.invoke = AsyncMock(return_value=func_result)
|
||||
|
||||
with (
|
||||
patch("semantic_kernel.kernel.logger", autospec=True) as logger_mock,
|
||||
patch("semantic_kernel.kernel.Kernel.get_function", return_value=func_mock),
|
||||
):
|
||||
await kernel.invoke_function_call(
|
||||
function_call=tool_call_mock,
|
||||
chat_history=chat_history_mock,
|
||||
# function_behavior intentionally omitted
|
||||
)
|
||||
|
||||
logger_mock.debug.assert_called()
|
||||
debug_calls = [call[0][0] for call in logger_mock.debug.call_args_list]
|
||||
assert any("without function_behavior" in msg for msg in debug_calls)
|
||||
|
||||
|
||||
# endregion
|
||||
# region Plugins
|
||||
|
||||
|
||||
Reference in New Issue
Block a user