Python: fix for kernel function type_object when not using Annotations. Add more unit tests. (#7338)

### Motivation and Context

The current kernel function decorator class parses function params, but
had a miss related to how the underlying param's type is parsed if not
using Annotations.

<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

### Description

This PR properly handles the type_object for params that don't using
typing.Annotations.
- Adds unit tests to exercise the code.
- Adds more kernel.py unit tests for near 100% test coverage.
- Adds some planner tests to improve the test coverage.
- Closes #7300 

<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [X] The code builds clean without any errors or warnings
- [X] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [X] All unit tests pass, and I have added new tests where possible
- [X] I didn't break anyone 😄
This commit is contained in:
Evan Mattson
2024-07-18 05:12:08 +00:00
committed by GitHub
parent f0e1e8c524
commit 6b01468efb
9 changed files with 759 additions and 38 deletions
@@ -11,7 +11,8 @@ from semantic_kernel.contents import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.core_plugins import MathPlugin, TimePlugin
from semantic_kernel.core_plugins.math_plugin import MathPlugin
from semantic_kernel.core_plugins.time_plugin import TimePlugin
from semantic_kernel.functions import KernelArguments
if TYPE_CHECKING:
@@ -4,7 +4,7 @@ import logging
import types
from collections.abc import Callable
from inspect import Parameter, Signature, isasyncgenfunction, isclass, isgeneratorfunction, signature
from typing import Any, ForwardRef, Union, get_args
from typing import Annotated, Any, ForwardRef, Union, get_args, get_origin
NoneType = type(None)
logger = logging.getLogger(__name__)
@@ -103,7 +103,10 @@ def _process_signature(func_sig: Signature) -> list[dict[str, Any]]:
annotation = arg.annotation
default = arg.default if arg.default != arg.empty else None
parsed_annotation = _parse_parameter(arg.name, annotation, default)
underlying_type = _get_underlying_type(annotation)
if get_origin(annotation) is Annotated or get_origin(annotation) in {Union, types.UnionType}:
underlying_type = _get_underlying_type(annotation)
else:
underlying_type = annotation
parsed_annotation["type_object"] = underlying_type
annotations.append(parsed_annotation)
@@ -121,6 +121,30 @@ class KernelFunctionFromMethod(KernelFunction):
function_arguments = self.gather_function_parameters(context)
context.result = FunctionResult(function=self.metadata, value=self.stream_method(**function_arguments))
def _parse_parameter(self, value: Any, param_type: Any) -> Any:
"""Parses the value into the specified param_type, including handling lists of types."""
if isinstance(param_type, type) and hasattr(param_type, "model_validate"):
try:
return param_type.model_validate(value)
except Exception as exc:
raise FunctionExecutionException(
f"Parameter is expected to be parsed to {param_type} but is not."
) from exc
elif hasattr(param_type, "__origin__") and param_type.__origin__ is list:
if isinstance(value, list):
item_type = param_type.__args__[0]
return [self._parse_parameter(item, item_type) for item in value]
raise FunctionExecutionException(f"Expected a list for {param_type}, but got {type(value)}")
else:
try:
if isinstance(value, dict) and hasattr(param_type, "__init__"):
return param_type(**value)
return param_type(value)
except Exception as exc:
raise FunctionExecutionException(
f"Parameter is expected to be parsed to {param_type} but is not."
) from exc
def gather_function_parameters(self, context: FunctionInvocationContext) -> dict[str, Any]:
"""Gathers the function parameters from the arguments."""
function_arguments: dict[str, Any] = {}
@@ -147,24 +171,12 @@ class KernelFunctionFromMethod(KernelFunction):
and param.type_object
and param.type_object is not inspect._empty
):
if hasattr(param.type_object, "model_validate"):
try:
value = param.type_object.model_validate(value)
except Exception as exc:
raise FunctionExecutionException(
f"Parameter {param.name} is expected to be parsed to {param.type_} but is not."
) from exc
else:
try:
if isinstance(value, dict) and hasattr(param.type_object, "__init__"):
value = param.type_object(**value)
else:
value = param.type_object(value)
except Exception as exc:
raise FunctionExecutionException(
f"Parameter {param.name} is expected to be parsed to "
f"{param.type_object} but is not."
) from exc
try:
value = self._parse_parameter(value, param.type_object)
except Exception as exc:
raise FunctionExecutionException(
f"Parameter {param.name} is expected to be parsed to {param.type_object} but is not."
) from exc
function_arguments[param.name] = value
continue
if param.is_required:
+21
View File
@@ -3,9 +3,12 @@
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from semantic_kernel.contents.function_call_content import FunctionCallContent
if TYPE_CHECKING:
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.filters.functions.function_invocation_context import FunctionInvocationContext
@@ -134,6 +137,24 @@ def create_mock_function() -> Callable:
return create_mock_function
@pytest.fixture(scope="function")
def get_tool_call_mock():
tool_call_mock = MagicMock(spec=FunctionCallContent)
tool_call_mock.split_name_dict.return_value = {"arg_name": "arg_value"}
tool_call_mock.to_kernel_arguments.return_value = {"arg_name": "arg_value"}
tool_call_mock.name = "test-function"
tool_call_mock.function_name = "function"
tool_call_mock.plugin_name = "test"
tool_call_mock.arguments = {"arg_name": "arg_value"}
tool_call_mock.ai_model_id = None
tool_call_mock.metadata = {}
tool_call_mock.index = 0
tool_call_mock.parse_arguments.return_value = {"arg_name": "arg_value"}
tool_call_mock.id = "test_id"
return tool_call_mock
@pytest.fixture(scope="function")
def chat_history() -> "ChatHistory":
from semantic_kernel.contents.chat_history import ChatHistory
@@ -63,7 +63,7 @@ class MiscClass:
return input
@kernel_function
def func_return_type_streaming(self, input: str) -> Annotated[AsyncGenerator[str, Any], "test return"]:
def func_return_type_streaming(self, input: str) -> Annotated[AsyncGenerator[str, Any], "test return"]: # type: ignore
yield input
@kernel_function
@@ -1,11 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncGenerator, Iterable
from typing import Annotated, Any
from unittest.mock import Mock
import pytest
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
from semantic_kernel.exceptions import FunctionExecutionException, FunctionInitializationError
from semantic_kernel.filters.functions.function_invocation_context import FunctionInvocationContext
from semantic_kernel.filters.kernel_filters_extension import _rebuild_function_invocation_context
from semantic_kernel.functions.function_result import FunctionResult
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function import KernelFunction
@@ -16,6 +19,38 @@ from semantic_kernel.kernel import Kernel
from semantic_kernel.kernel_pydantic import KernelBaseModel
class CustomType(KernelBaseModel):
id: str
name: str
class CustomTypeNonPydantic:
id: str
name: str
def __init__(self, id: str, name: str):
self.id = id
self.name = name
@pytest.fixture
def get_custom_type_function_pydantic():
@kernel_function
def func_default(param: list[CustomType]):
return input
return KernelFunction.from_method(func_default, "test")
@pytest.fixture
def get_custom_type_function_nonpydantic():
@kernel_function
def func_default(param: list[CustomTypeNonPydantic]):
return input
return KernelFunction.from_method(func_default, "test")
def test_init_native_function_with_input_description():
@kernel_function(description="Mock description", name="mock_function")
def mock_function(input: Annotated[str, "input"], arguments: "KernelArguments") -> None:
@@ -449,3 +484,79 @@ async def test_default_handling_2(kernel: Kernel):
res = await kernel.invoke(func, base="base")
assert str(res) == "test"
def test_parse_list_of_objects(get_custom_type_function_pydantic):
func = get_custom_type_function_pydantic
param_type = list[CustomType]
value = [{"id": "1", "name": "John"}, {"id": "2", "name": "Jane"}]
result = func._parse_parameter(value, param_type)
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(item, CustomType) for item in result)
def test_parse_individual_object(get_custom_type_function_pydantic):
value = {"id": "2", "name": "Jane"}
func = get_custom_type_function_pydantic
result = func._parse_parameter(value, CustomType)
assert isinstance(result, CustomType)
assert result.id == "2"
assert result.name == "Jane"
def test_parse_non_list_raises_exception(get_custom_type_function_pydantic):
func = get_custom_type_function_pydantic
param_type = list[CustomType]
value = {"id": "2", "name": "Jane"}
with pytest.raises(FunctionExecutionException, match=r"Expected a list for .*"):
func._parse_parameter(value, param_type)
def test_parse_invalid_dict_raises_exception(get_custom_type_function_pydantic):
func = get_custom_type_function_pydantic
value = {"id": "1"}
with pytest.raises(FunctionExecutionException, match=r"Parameter is expected to be parsed to .*"):
func._parse_parameter(value, CustomType)
def test_parse_invalid_value_raises_exception(get_custom_type_function_pydantic):
func = get_custom_type_function_pydantic
value = "invalid_value"
with pytest.raises(FunctionExecutionException, match=r"Parameter is expected to be parsed to .*"):
func._parse_parameter(value, CustomType)
def test_parse_invalid_list_raises_exception(get_custom_type_function_pydantic):
func = get_custom_type_function_pydantic
param_type = list[CustomType]
value = ["invalid_value"]
with pytest.raises(FunctionExecutionException, match=r"Parameter is expected to be parsed to .*"):
func._parse_parameter(value, param_type)
def test_parse_dict_with_init_non_pydantic(get_custom_type_function_nonpydantic):
func = get_custom_type_function_nonpydantic
value = {"id": "3", "name": "Alice"}
result = func._parse_parameter(value, CustomTypeNonPydantic)
assert isinstance(result, CustomTypeNonPydantic)
assert result.id == "3"
assert result.name == "Alice"
def test_parse_invalid_dict_raises_exception_new(get_custom_type_function_nonpydantic):
func = get_custom_type_function_nonpydantic
value = {"wrong_key": "3", "name": "Alice"}
with pytest.raises(FunctionExecutionException, match=r"Parameter is expected to be parsed to .*"):
func._parse_parameter(value, CustomTypeNonPydantic)
def test_gather_function_parameters_exception_handling(get_custom_type_function_pydantic):
kernel = Mock(spec=Kernel) # Mock kernel
func = get_custom_type_function_pydantic
_rebuild_function_invocation_context()
context = FunctionInvocationContext(kernel=kernel, function=func, arguments=KernelArguments(param="test"))
with pytest.raises(FunctionExecutionException, match=r"Parameter param is expected to be parsed to .* but is not."):
func.gather_function_parameters(context)
+219 -16
View File
@@ -19,17 +19,23 @@ from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.exceptions import (
FunctionCallInvalidArgumentsException,
KernelFunctionAlreadyExistsError,
KernelServiceNotFoundError,
)
from semantic_kernel.exceptions.kernel_exceptions import KernelFunctionNotFoundError, KernelPluginNotFoundError
from semantic_kernel.exceptions.content_exceptions import FunctionCallInvalidArgumentsException
from semantic_kernel.exceptions.kernel_exceptions import (
KernelFunctionNotFoundError,
KernelInvokeException,
KernelPluginNotFoundError,
OperationCancelledException,
)
from semantic_kernel.exceptions.template_engine_exceptions import TemplateSyntaxError
from semantic_kernel.functions.function_result import FunctionResult
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function import KernelFunction
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.prompt_template.kernel_prompt_template import KernelPromptTemplate
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
@@ -104,6 +110,21 @@ async def test_invoke_function(kernel: Kernel, create_mock_function):
assert mock_function.call_count == 1
@pytest.mark.asyncio
async def test_invoke_function_with_cancellation(kernel: Kernel, create_mock_function):
mock_function = create_mock_function(name="test_function")
with (
patch.object(Kernel, "invoke", AsyncMock(side_effect=OperationCancelledException("Operation cancelled"))),
pytest.raises(OperationCancelledException),
):
result = await kernel.invoke(function=mock_function, arguments=KernelArguments())
assert result is None
Kernel.invoke.assert_called_once_with(function=mock_function, arguments=KernelArguments())
@pytest.mark.asyncio
async def test_invoke_functions_by_name(kernel: Kernel, create_mock_function):
mock_function = kernel.add_function(plugin_name="test", function=create_mock_function(name="test_function"))
@@ -117,6 +138,21 @@ async def test_invoke_functions_by_name(kernel: Kernel, create_mock_function):
assert response[0].text == "test"
@pytest.mark.asyncio
async def test_invoke_functions_by_name_return_function_results(kernel: Kernel, create_mock_function):
mock_function = kernel.add_function(plugin_name="test", function=create_mock_function(name="test_function"))
result = await kernel.invoke(function_name="test_function", plugin_name="test", arguments=KernelArguments())
assert str(result) == "test"
assert mock_function.call_count == 1
async for _ in kernel.invoke_stream(
function_name="test_function", plugin_name="test", return_function_results=True
):
assert isinstance(result, FunctionResult)
@pytest.mark.asyncio
async def test_invoke_function_fail(kernel: Kernel, create_mock_function):
mock_function = create_mock_function(name="test_function")
@@ -182,19 +218,124 @@ async def test_invoke_prompt_no_prompt_error(kernel: Kernel):
@pytest.mark.asyncio
async def test_invoke_function_call(kernel: Kernel):
tool_call_mock = MagicMock(spec=FunctionCallContent)
tool_call_mock.split_name_dict.return_value = {"arg_name": "arg_value"}
tool_call_mock.to_kernel_arguments.return_value = {"arg_name": "arg_value"}
tool_call_mock.name = "test-function"
tool_call_mock.function_name = "function"
tool_call_mock.plugin_name = "test"
tool_call_mock.arguments = {"arg_name": "arg_value"}
tool_call_mock.ai_model_id = None
tool_call_mock.metadata = {}
tool_call_mock.index = 0
tool_call_mock.parse_arguments.return_value = {"arg_name": "arg_value"}
tool_call_mock.id = "test_id"
async def test_invoke_prompt_stream_no_prompt_throws(kernel: Kernel):
with pytest.raises(TemplateSyntaxError):
async for _ in kernel.invoke_prompt_stream(prompt=""):
pass
@pytest.mark.asyncio
async def test_invoke_prompt_stream(kernel: Kernel, create_mock_function):
mock_function = create_mock_function(name="test_function")
with (
patch(
"semantic_kernel.kernel.Kernel.invoke_stream",
) as mock_stream_invoke,
):
mock_stream_invoke.return_value.__aiter__.return_value = [
FunctionResult(function=mock_function.metadata, value="test")
]
async for response in kernel.invoke_prompt_stream(prompt="test"):
assert response.value == "test"
@pytest.mark.asyncio
async def test_invoke_prompt_stream_returns_function_results(kernel: Kernel, create_mock_function):
mock_function = create_mock_function(name="test_function")
with (
patch(
"semantic_kernel.kernel.Kernel.invoke_stream",
) as mock_stream_invoke,
):
mock_stream_invoke.return_value.__aiter__.return_value = [
FunctionResult(function=mock_function.metadata, value="test")
]
async for response in kernel.invoke_prompt_stream(prompt="test", return_function_results=True):
assert isinstance(response, FunctionResult)
@pytest.mark.asyncio
async def test_invoke_prompt_stream_raises_exception(kernel: Kernel, create_mock_function):
mock_function = create_mock_function(name="test_function")
with (
patch(
"semantic_kernel.kernel.Kernel.invoke_stream",
) as mock_stream_invoke,
pytest.raises(KernelInvokeException),
):
mock_stream_invoke.return_value.__aiter__.return_value = [
FunctionResult(
function=mock_function.metadata, value="", metadata={METADATA_EXCEPTION_KEY: KernelInvokeException()}
)
]
async for _ in kernel.invoke_prompt_stream(prompt="test"):
pass
@pytest.mark.asyncio
async def test_invoke_function_call(kernel: Kernel, get_tool_call_mock):
tool_call_mock = get_tool_call_mock
result_mock = MagicMock(spec=ChatMessageContent)
result_mock.items = [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_result = FunctionResult(value="Function result", function=func_meta)
func_mock.invoke = MagicMock(return_value=func_result)
arguments = KernelArguments()
with (
patch("semantic_kernel.kernel.logger", autospec=True),
patch("semantic_kernel.kernel.Kernel.get_list_of_function_metadata", return_value=[func_meta]),
):
await kernel.invoke_function_call(
tool_call_mock,
chat_history_mock,
arguments,
1,
0,
FunctionChoiceBehavior.Auto(filters={"included_functions": ["function"]}),
)
@pytest.mark.asyncio
async def test_invoke_function_call_throws_during_invoke(kernel: Kernel, get_tool_call_mock):
tool_call_mock = get_tool_call_mock
result_mock = MagicMock(spec=ChatMessageContent)
result_mock.items = [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_result = FunctionResult(value="Function result", function=func_meta)
func_mock.invoke = MagicMock(return_value=func_result)
arguments = KernelArguments()
with (
patch("semantic_kernel.kernel.logger", autospec=True),
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(
tool_call_mock,
chat_history_mock,
arguments,
1,
0,
FunctionChoiceBehavior.Auto(),
)
@pytest.mark.asyncio
async def test_invoke_function_call_non_allowed_func_throws(kernel: Kernel, get_tool_call_mock):
tool_call_mock = get_tool_call_mock
result_mock = MagicMock(spec=ChatMessageContent)
result_mock.items = [tool_call_mock]
chat_history_mock = MagicMock(spec=ChatHistory)
@@ -209,6 +350,36 @@ async def test_invoke_function_call(kernel: Kernel):
arguments = KernelArguments()
with patch("semantic_kernel.kernel.logger", autospec=True):
await kernel.invoke_function_call(
tool_call_mock,
chat_history_mock,
arguments,
1,
0,
FunctionChoiceBehavior.Auto(filters={"included_functions": ["unknown"]}),
)
@pytest.mark.asyncio
async def test_invoke_function_call_no_name_throws(kernel: Kernel, get_tool_call_mock):
tool_call_mock = get_tool_call_mock
tool_call_mock.name = None
result_mock = MagicMock(spec=ChatMessageContent)
result_mock.items = [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_result = FunctionResult(value="Function result", function=func_meta)
func_mock.invoke = MagicMock(return_value=func_result)
arguments = KernelArguments()
with (
patch("semantic_kernel.kernel.logger", autospec=True),
):
await kernel.invoke_function_call(
tool_call_mock,
chat_history_mock,
@@ -220,7 +391,39 @@ async def test_invoke_function_call(kernel: Kernel):
@pytest.mark.asyncio
async def test_invoke_function_call_with_continuation_on_malformed_arguments(kernel: Kernel):
async def test_invoke_function_call_not_enough_parsed_args(kernel: Kernel, get_tool_call_mock):
tool_call_mock = get_tool_call_mock
tool_call_mock.to_kernel_arguments.return_value = {}
result_mock = MagicMock(spec=ChatMessageContent)
result_mock.items = [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 = [KernelParameterMetadata(name="param1", is_required=True)]
func_result = FunctionResult(value="Function result", function=func_meta)
func_mock.invoke = MagicMock(return_value=func_result)
arguments = KernelArguments()
with (
patch("semantic_kernel.kernel.logger", autospec=True),
patch("semantic_kernel.kernel.Kernel.get_function", return_value=func_mock),
):
await kernel.invoke_function_call(
tool_call_mock,
chat_history_mock,
arguments,
1,
0,
FunctionChoiceBehavior.Auto(),
)
@pytest.mark.asyncio
async def test_invoke_function_call_with_continuation_on_malformed_arguments(kernel: Kernel, get_tool_call_mock):
tool_call_mock = MagicMock(spec=FunctionCallContent)
tool_call_mock.to_kernel_arguments.side_effect = FunctionCallInvalidArgumentsException("Malformed arguments")
tool_call_mock.name = "test-function"
@@ -6,9 +6,15 @@ import pytest
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
from semantic_kernel.contents import AuthorRole
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.exceptions.planner_exceptions import PlannerInvalidConfigurationError
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function import KernelFunction
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
from semantic_kernel.kernel import Kernel
from semantic_kernel.planners.function_calling_stepwise_planner.function_calling_stepwise_planner import (
FunctionCallingStepwisePlanner,
@@ -16,6 +22,50 @@ from semantic_kernel.planners.function_calling_stepwise_planner.function_calling
from semantic_kernel.planners.function_calling_stepwise_planner.function_calling_stepwise_planner_options import (
FunctionCallingStepwisePlannerOptions,
)
from semantic_kernel.planners.function_calling_stepwise_planner.function_calling_stepwise_planner_result import (
FunctionCallingStepwisePlannerResult,
UserInteraction,
)
@pytest.fixture
def get_function_call_content():
function_call_content = AsyncMock(spec=FunctionCallContent)
function_call_content.id = "test"
function_call_content.function_name = "function"
function_call_content.plugin_name = "plugin"
function_call_content.name = "USER_INTERACTION_SEND_FINAL_ANSWER"
function_call_content.parse_arguments.return_value = {"answer": "Final answer"}
function_call_content.ai_model_id = "test_model"
function_call_content.metadata = KernelFunctionMetadata(
name="function",
plugin_name="plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
)
return function_call_content
@pytest.fixture
def get_kernel_function_metadata_list():
[
KernelFunctionMetadata(
name="function",
plugin_name="plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
)
]
def test_initialization():
@@ -92,3 +142,169 @@ async def test_invoke_with_no_configured_AI_service_raises_exception(kernel: Ker
planner = FunctionCallingStepwisePlanner(service_id="test", options=None)
with pytest.raises(PlannerInvalidConfigurationError):
await planner.invoke(kernel, "test question")
@pytest.mark.asyncio
async def test_invoke_with_function_call_content_and_processing(
get_function_call_content, get_kernel_function_metadata_list
):
kernel_mock = AsyncMock(spec=Kernel)
question = "Test question"
arguments = KernelArguments()
options = FunctionCallingStepwisePlannerOptions(get_available_functions=AsyncMock(), max_iterations=1)
options.get_available_functions.return_value = get_kernel_function_metadata_list
planner = FunctionCallingStepwisePlanner(service_id="test_service", options=options)
chat_completion_mock = AsyncMock(spec=OpenAIChatCompletion)
kernel_mock.get_service.return_value = chat_completion_mock
planner._generate_plan = AsyncMock(return_value="generated plan")
planner._build_chat_history_for_step = AsyncMock(return_value=AsyncMock(spec=ChatHistory))
chat_completion_mock.instantiate_prompt_execution_settings.return_value = AsyncMock()
other_function_call_content = AsyncMock(spec=FunctionCallContent)
other_function_call_content.name = "SomeOtherFunction"
chat_result = AsyncMock()
chat_result.items = [other_function_call_content]
chat_completion_mock.get_chat_message_contents.return_value = [chat_result]
chat_completion_mock._process_function_call = AsyncMock(return_value=MagicMock(function_result="Function result"))
with patch(
"semantic_kernel.contents.function_result_content.FunctionResultContent.from_function_call_content_and_result",
return_value=MagicMock(),
) as frc_mock:
result = await planner.invoke(kernel_mock, question, arguments)
assert isinstance(result, FunctionCallingStepwisePlannerResult)
assert result.final_answer == ""
assert result.iterations == options.max_iterations
chat_completion_mock._process_function_call.assert_called_with(
function_call=other_function_call_content,
kernel=kernel_mock,
chat_history=planner._build_chat_history_for_step.return_value,
arguments=arguments,
function_call_count=1,
request_index=0,
function_call_behavior=chat_completion_mock.instantiate_prompt_execution_settings.return_value.function_choice_behavior,
)
frc_mock.assert_called_with(function_call_content=other_function_call_content, result="Function result")
@pytest.mark.asyncio
async def test_invoke_with_function_call_content_and_processing_error(get_kernel_function_metadata_list):
kernel_mock = AsyncMock(spec=Kernel)
question = "Test question"
arguments = KernelArguments()
options = FunctionCallingStepwisePlannerOptions(get_available_functions=AsyncMock(), max_iterations=1)
options.get_available_functions.return_value = get_kernel_function_metadata_list
planner = FunctionCallingStepwisePlanner(service_id="test_service", options=options)
chat_completion_mock = AsyncMock(spec=OpenAIChatCompletion)
kernel_mock.get_service.return_value = chat_completion_mock
planner._generate_plan = AsyncMock(return_value="generated plan")
planner._build_chat_history_for_step = AsyncMock(return_value=AsyncMock(spec=ChatHistory))
chat_completion_mock.instantiate_prompt_execution_settings.return_value = AsyncMock()
other_function_call_content = AsyncMock(spec=FunctionCallContent)
other_function_call_content.name = "SomeOtherFunction"
chat_result = AsyncMock()
chat_result.items = [other_function_call_content]
chat_completion_mock.get_chat_message_contents.return_value = [chat_result]
chat_completion_mock._process_function_call.side_effect = Exception("Function call error")
with patch(
"semantic_kernel.contents.function_result_content.FunctionResultContent.from_function_call_content_and_result",
return_value=MagicMock(),
) as frc_mock:
result = await planner.invoke(kernel_mock, question, arguments)
assert isinstance(result, FunctionCallingStepwisePlannerResult)
assert result.final_answer == ""
assert result.iterations == options.max_iterations
chat_completion_mock._process_function_call.assert_called_with(
function_call=other_function_call_content,
kernel=kernel_mock,
chat_history=planner._build_chat_history_for_step.return_value,
arguments=arguments,
function_call_count=1,
request_index=0,
function_call_behavior=chat_completion_mock.instantiate_prompt_execution_settings.return_value.function_choice_behavior,
)
frc_mock.assert_called_with(
function_call_content=other_function_call_content,
result=TextContent(text="An error occurred during planner invocation: Function call error"),
)
@pytest.mark.asyncio
async def test_invoke_with_invalid_service_type():
planner = FunctionCallingStepwisePlanner(service_id="test_service", options=None)
kernel_mock = AsyncMock(spec=Kernel)
kernel_mock.get_service.return_value = MagicMock()
with pytest.raises(PlannerInvalidConfigurationError):
await planner.invoke(kernel_mock, "test question")
@pytest.mark.asyncio
async def test_invoke_with_no_arguments():
planner = FunctionCallingStepwisePlanner(service_id="test_service", options=None)
kernel_mock = AsyncMock(spec=Kernel)
question = "Test question"
chat_completion_mock = AsyncMock(spec=OpenAIChatCompletion)
kernel_mock.get_service.return_value = chat_completion_mock
planner._generate_plan = AsyncMock(return_value="generated plan")
planner._build_chat_history_for_step = AsyncMock(return_value=AsyncMock(spec=ChatHistory))
chat_completion_mock.instantiate_prompt_execution_settings.return_value = AsyncMock()
chat_completion_mock.get_chat_message_contents.return_value = [
AsyncMock(items=[FunctionResultContent(text="Test result", id="test")])
]
result = await planner.invoke(kernel_mock, question)
assert isinstance(result, FunctionCallingStepwisePlannerResult)
assert result.final_answer == ""
assert result.iterations == planner.options.max_iterations
@patch("semantic_kernel.planners.function_calling_stepwise_planner.function_calling_stepwise_planner.yaml.safe_load")
def test_create_config_from_yaml(mock_safe_load):
mock_safe_load.return_value = {
"template": "some template",
"execution_settings": {"default": {"setting1": "value1"}},
}
kernel_mock = MagicMock(spec=Kernel)
kernel_function_mock = MagicMock(spec=KernelFunction)
kernel_mock.add_function.return_value = kernel_function_mock
planner = FunctionCallingStepwisePlanner(service_id="test_service")
planner.generate_plan_yaml = "some_yaml_content"
DEFAULT_SERVICE_NAME = "default"
with patch("semantic_kernel.const.DEFAULT_SERVICE_NAME", DEFAULT_SERVICE_NAME):
result = planner._create_config_from_yaml(kernel_mock)
mock_safe_load.assert_called_once_with("some_yaml_content")
assert result == kernel_function_mock
def test_user_interaction():
user_interaction = UserInteraction()
assert user_interaction.send_final_answer("answer") == "Thanks"
@@ -0,0 +1,154 @@
# Copyright (c) Microsoft. All rights reserved.
from unittest.mock import AsyncMock, MagicMock
import pytest
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
from semantic_kernel.planners.planner_extensions import PlannerFunctionExtension, PlannerKernelExtension
from semantic_kernel.planners.planner_options import PlannerOptions
@pytest.fixture
def sample_function_metadata():
return KernelFunctionMetadata(
name="function",
plugin_name="sample_plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
)
@pytest.mark.parametrize(
"function, expected_output",
[
(
KernelFunctionMetadata(
name="function",
plugin_name="sample_plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
),
"sample_plugin-function:\n description: A sample function\n inputs:\n - param1: Parameter 1\n - param2: Parameter 2 (default value: default)", # noqa: E501
)
],
)
def test_to_manual_string(function, expected_output):
result = PlannerFunctionExtension.to_manual_string(function)
assert result == expected_output
@pytest.mark.parametrize(
"function, expected_output",
[
(
KernelFunctionMetadata(
name="function",
plugin_name="sample_plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
),
"function:\n description: A sample function\n inputs:\n - param1: Parameter 1\n - param2: Parameter 2", # noqa: E501
)
],
)
def test_to_embedding_string(function, expected_output):
result = PlannerFunctionExtension.to_embedding_string(function)
assert result == expected_output
@pytest.mark.asyncio
async def test_get_functions_manual():
kernel = MagicMock()
arguments = MagicMock()
options = PlannerOptions(get_available_functions=None)
kernel.get_list_of_function_metadata = MagicMock(
return_value=[
KernelFunctionMetadata(
name="function",
plugin_name="sample_plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
)
]
)
result = await PlannerKernelExtension.get_functions_manual(kernel, arguments, options)
expected_output = "sample_plugin-function:\n description: A sample function\n inputs:\n - param1: Parameter 1\n - param2: Parameter 2 (default value: default)" # noqa: E501
assert result == expected_output
@pytest.mark.asyncio
async def test_get_functions_manual_with_custom_get_available_functions():
kernel = MagicMock()
arguments = MagicMock()
options = PlannerOptions(get_available_functions=AsyncMock())
# Mock get_available_functions method
options.get_available_functions.return_value = [
KernelFunctionMetadata(
name="function",
plugin_name="sample_plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
)
]
result = await PlannerKernelExtension.get_functions_manual(kernel, arguments, options)
expected_output = "sample_plugin-function:\n description: A sample function\n inputs:\n - param1: Parameter 1\n - param2: Parameter 2 (default value: default)" # noqa: E501
assert result == expected_output
@pytest.mark.asyncio
async def test_get_available_functions():
kernel = MagicMock()
arguments = MagicMock()
options = PlannerOptions(excluded_plugins=[], excluded_functions=[])
# Mock get_list_of_function_metadata method
kernel.get_list_of_function_metadata = MagicMock(
return_value=[
KernelFunctionMetadata(
name="function",
plugin_name="sample_plugin",
description="A sample function",
parameters=[
KernelParameterMetadata(name="param1", description="Parameter 1", default_value=None),
KernelParameterMetadata(name="param2", description="Parameter 2", default_value="default"),
],
is_prompt=False,
is_asynchronous=True,
)
]
)
result = await PlannerKernelExtension.get_available_functions(kernel, arguments, options)
assert len(result) == 1
assert result[0].name == "function"