Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions python/semantic_kernel/functions/kernel_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions python/tests/unit/functions/test_kernel_function_from_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading