diff --git a/python/semantic_kernel/functions/kernel_function.py b/python/semantic_kernel/functions/kernel_function.py index b8f9a3a5fdbe..3d72783ea9ec 100644 --- a/python/semantic_kernel/functions/kernel_function.py +++ b/python/semantic_kernel/functions/kernel_function.py @@ -7,8 +7,8 @@ from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from contextlib import aclosing, closing from copy import copy, deepcopy -from inspect import isasyncgen, isgenerator -from typing import TYPE_CHECKING, Any +from inspect import Parameter, isasyncgen, isgenerator, signature +from typing import TYPE_CHECKING, Annotated, Any from opentelemetry import metrics, trace from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE @@ -452,14 +452,39 @@ def as_agent_framework_tool( name = name or self.name description = description or self.description fields = {} + # Prefer the underlying callable signature when present so nullable + # annotations (str | None) stay distinct from optional defaults (= None), + # and so Field annotations remain nullable when None is allowed. + method = getattr(self, "method", None) + sig_params = signature(method).parameters if callable(method) else {} for param in self.parameters: - if param.include_in_function_choices: - if param.default_value is not None: + if not param.include_in_function_choices: + continue + sp = sig_params.get(param.name) if param.name else None + if sp is not None: + annotation: Any = ( + sp.annotation if sp.annotation is not Parameter.empty else (param.type_object or Any) + ) + origin = getattr(annotation, "__origin__", None) + if origin is Annotated: + annotation = annotation.__args__[0] + if sp.default is not Parameter.empty: fields[param.name] = ( - param.type_, - Field(description=param.description, default=param.default_value), + annotation, + Field(description=param.description, default=sp.default), ) - fields[param.name] = (param.type_, Field(description=param.description)) + else: + fields[param.name] = (annotation, Field(description=param.description)) + continue + + annotation = param.type_object or param.type_ or Any + if param.is_required: + fields[param.name] = (annotation, Field(description=param.description)) + else: + fields[param.name] = ( + annotation, + Field(description=param.description, default=param.default_value), + ) input_model = create_model("InputModel", **fields) # type: ignore async def wrapper(*args: Any, **kwargs: Any) -> Any: diff --git a/python/tests/unit/functions/test_kernel_function_from_method.py b/python/tests/unit/functions/test_kernel_function_from_method.py index 03b796d4dfef..f8db719487a6 100644 --- a/python/tests/unit/functions/test_kernel_function_from_method.py +++ b/python/tests/unit/functions/test_kernel_function_from_method.py @@ -251,6 +251,56 @@ def decorated_function() -> None: assert len(native_function.parameters) == 0 +def test_as_agent_framework_tool_preserves_optional_parameter_default(monkeypatch): + class MockAIFunction: + def __init__(self, *, input_model: Any, **kwargs: Any): + self.input_model = input_model + + monkeypatch.setitem(__import__("sys").modules, "agent_framework", Mock(AIFunction=MockAIFunction)) + + @kernel_function() + def function(count: int = 5, label: str | None = None) -> int: + return count + + native_function = KernelFunction.from_method(method=function, plugin_name="MockPlugin") + tool = native_function.as_agent_framework_tool(kernel=Mock()) + count_field = tool.input_model.model_fields["count"] + label_field = tool.input_model.model_fields["label"] + + assert count_field.default == 5 + assert not count_field.is_required() + assert label_field.default is None + assert not label_field.is_required() + assert tool.input_model().label is None + assert tool.input_model(label=None).label is None + + +def test_as_agent_framework_tool_keeps_nullable_without_default_required(monkeypatch): + from pydantic import ValidationError + + class MockAIFunction: + def __init__(self, *, input_model: Any, **kwargs: Any): + self.input_model = input_model + + monkeypatch.setitem(__import__("sys").modules, "agent_framework", Mock(AIFunction=MockAIFunction)) + + @kernel_function() + def function(x: str | None) -> str | None: + return x + + native_function = KernelFunction.from_method(method=function, plugin_name="MockPlugin") + tool = native_function.as_agent_framework_tool(kernel=Mock()) + x_field = tool.input_model.model_fields["x"] + assert x_field.is_required() + assert tool.input_model(x=None).x is None + try: + tool.input_model() + raised = False + except ValidationError: + raised = True + assert raised, "nullable-without-default must reject a missing argument" + + def test_init_method_is_none(): with pytest.raises(FunctionInitializationError): KernelFunction.from_method(method=None, plugin_name="MockPlugin")