Skip to content
Open
52 changes: 49 additions & 3 deletions python/semantic_kernel/functions/kernel_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ def as_agent_framework_tool(
Returns:
AIFunction: The agent framework tool.
"""
import inspect
import json

from pydantic import Field, create_model
Expand All @@ -451,15 +452,60 @@ def as_agent_framework_tool(
kernel = Kernel()
name = name or self.name
description = description or self.description

# When the function has a real Python callable, its signature is the source of truth
# for each field. The parameter metadata loses information: `is_required` is False
# for `x: str | None` with no default, and a union such as `str | int | None` is
# flattened to the string "str, int" with no type object, which pydantic cannot
# evaluate.
method = getattr(self, "method", None)
signature = inspect.signature(method, eval_str=True) if method is not None else None

fields = {}
for param in self.parameters:
if param.include_in_function_choices:
if param.default_value is not None:
sig_param = signature.parameters.get(param.name) if signature is not None and param.name else None
if sig_param is not None:
annotation: Any = Any if sig_param.annotation is inspect.Parameter.empty else sig_param.annotation
if sig_param.default is inspect.Parameter.empty:
fields[param.name] = (annotation, Field(description=param.description))
else:
fields[param.name] = (
annotation,
Field(description=param.description, default=sig_param.default),
)
continue
# _process_signature stores inspect.Parameter.empty as type_object for an
# unannotated parameter, and pydantic cannot build a schema from that, so it
# counts the same as "no usable type object" alongside None.
type_object = param.type_object
if type_object is inspect.Parameter.empty:
type_object = None
annotation = param.type_
if param.is_required:
fields[param.name] = (annotation, Field(description=param.description))
else:
# A parameter that isn't required may still carry an actual default of None
# (e.g. `tag: str | None = None`), which is different from having no default
# at all. The annotation needs to accept None in that case, or pydantic
# rejects the None default and any explicit None passed by the caller.
if type_object is not None:
# Prefer the real type object when we have one: it is unambiguous,
# unlike the type_ string, which turns a multi-type union such as
# `str | int | None` into "str, int", a string pydantic cannot evaluate.
annotation = type_object
if param.default_value is None:
annotation = type_object | None
else:
# type_ is a comma-joined string for a multi-type union (e.g. "str, int")
# when no single non-None type could be resolved; appending "| None" to
# that would produce an invalid forward reference, so it is left alone.
if param.default_value is None and param.type_ and "," not in param.type_:
annotation = f"{param.type_} | None"
fields[param.name] = (
param.type_,
annotation,
Field(description=param.description, default=param.default_value),
)
fields[param.name] = (param.type_, Field(description=param.description))
input_model = create_model("InputModel", **fields) # type: ignore

async def wrapper(*args: Any, **kwargs: Any) -> Any:
Expand Down
176 changes: 176 additions & 0 deletions python/tests/unit/functions/test_kernel_function_from_method.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
import types
from asyncio import CancelledError
from collections.abc import AsyncGenerator, Iterable
from contextlib import nullcontext
Expand All @@ -10,6 +12,7 @@

import pytest
from opentelemetry.trace import StatusCode
from pydantic import ValidationError

from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
from semantic_kernel.exceptions import FunctionExecutionException, FunctionInitializationError, KernelInvokeException
Expand Down Expand Up @@ -883,3 +886,176 @@ def test_function_model_dump_json(get_custom_type_function_pydantic):
model_dump = func.model_dump_json()
assert isinstance(model_dump, str)
assert "metadata" in model_dump


@pytest.fixture
def stub_agent_framework(monkeypatch):
"""Stub the optional `agent_framework` dependency so as_agent_framework_tool can be exercised."""

class AIFunction:
def __init__(self, *, name, description, input_model, func):
self.name = name
self.description = description
self.input_model = input_model
self.func = func

module = types.ModuleType("agent_framework")
module.AIFunction = AIFunction
monkeypatch.setitem(sys.modules, "agent_framework", module)


def test_as_agent_framework_tool_keeps_defaults_and_required(stub_agent_framework):
@kernel_function(name="search", description="search things")
def search(
query: Annotated[str, "search query"],
top_k: Annotated[int, "number of results"] = 5,
include_meta: Annotated[bool, "include metadata"] = False,
offset: Annotated[int, "results to skip"] = 0,
label: Annotated[str, "label"] = "",
y=3, # unannotated parameter with a default: type_object is inspect.Parameter.empty
) -> str:
return f"{query}:{top_k}:{include_meta}:{offset}:{label}:{y}"

func = KernelFunction.from_method(search, "test")
tool = func.as_agent_framework_tool()
schema = tool.input_model.model_json_schema()

# Only the parameter without a default should be required.
assert schema.get("required") == ["query"]
# Falsy defaults (0, False, "") must survive, not be dropped as if they were None.
assert schema["properties"]["top_k"]["default"] == 5
assert schema["properties"]["include_meta"]["default"] is False
assert schema["properties"]["offset"]["default"] == 0
assert schema["properties"]["label"]["default"] == ""
# An unannotated parameter with a default must not crash tool creation (its
# type_object is inspect.Parameter.empty, which pydantic cannot build a schema
# from) and must fall back to being optional with its default preserved.
assert schema["properties"]["y"]["default"] == 3
assert "y" not in schema.get("required", [])


def test_as_agent_framework_tool_none_defaults_accept_none(stub_agent_framework):
@kernel_function(name="configure", description="configure things")
def configure(
q: Annotated[str, "required query"],
tag: Annotated[str | None, "tag"] = None,
n: Annotated[int | None, "n"] = None,
) -> str:
return f"{q}:{tag}:{n}"

func = KernelFunction.from_method(configure, "test")
tool = func.as_agent_framework_tool()
schema = tool.input_model.model_json_schema()

# Only the parameter without any default is required; a None default still
# makes a parameter optional, it must not be conflated with "no default".
assert schema.get("required") == ["q"]
assert schema["properties"]["tag"]["default"] is None
assert schema["properties"]["n"]["default"] is None

# The model must accept both an omitted value and an explicit None for the
# None-defaulted parameters.
instance = tool.input_model(q="x")
assert instance.tag is None
assert instance.n is None

instance_explicit = tool.input_model(q="x", tag=None, n=None)
assert instance_explicit.tag is None
assert instance_explicit.n is None


@dataclass
class _CustomType:
id: str = ""


def test_as_agent_framework_tool_none_default_generic_and_model_types(stub_agent_framework):
"""A None-defaulted `list[str]` or dataclass parameter has a real
`type_object`, unlike a plain string, so the annotation can be built from it
directly (`param.type_object | None`) instead of the type_ string.
"""

@kernel_function(name="configure", description="configure things")
def configure(
q: Annotated[str, "required query"],
b: Annotated[list[str] | None, "b"] = None,
c: Annotated[_CustomType | None, "c"] = None,
) -> str:
return f"{q}:{b}:{c}"

func = KernelFunction.from_method(configure, "test")
tool = func.as_agent_framework_tool()
schema = tool.input_model.model_json_schema()

assert schema.get("required") == ["q"]
assert schema["properties"]["b"]["default"] is None
assert schema["properties"]["c"]["default"] is None

instance = tool.input_model(q="x")
assert instance.b is None
assert instance.c is None

instance_explicit = tool.input_model(q="x", b=["y"], c=_CustomType(id="z"))
assert instance_explicit.b == ["y"]
assert instance_explicit.c == _CustomType(id="z")


def test_as_agent_framework_tool_optional_annotation_without_default_stays_required(stub_agent_framework):
"""`x: str | None` (no default) still makes `is_required=False` in the decorator's own
metadata, because that only checks whether the annotation contains None, not whether the
Python signature has a default. Python itself still requires `x`, so the bridge recovers
that from the real callable's signature and keeps `x` required, while `y: str | None = None`,
which genuinely has a default, stays optional.
"""

@kernel_function(name="probe", description="probe things")
def probe(x: Annotated[str | None, "x, no default"], y: Annotated[str | None, "y, defaulted"] = None) -> str:
return f"{x}:{y}"

func = KernelFunction.from_method(probe, "test")
assert func.parameters[0].is_required is False # the decorator's own metadata, unchanged

tool = func.as_agent_framework_tool()
schema = tool.input_model.model_json_schema()

assert schema.get("required") == ["x"]
assert "y" not in (schema.get("required") or [])
assert schema["properties"]["y"]["default"] is None

with pytest.raises(ValidationError):
tool.input_model()

instance = tool.input_model(x="hi")
assert instance.x == "hi"
assert instance.y is None

# x is required (no default in the signature), but its annotation still includes
# None, so an explicit null must validate rather than be rejected as the wrong type.
instance_explicit_none = tool.input_model(x=None)
assert instance_explicit_none.x is None


def test_as_agent_framework_tool_multi_member_union(stub_agent_framework):
"""A union of several non-None types is flattened to the string "str, int" in the
parameter metadata, with no type object, so the bridge reads the annotation from
the callable's signature instead, along with what is required and each default.
"""

@kernel_function(name="probe", description="probe things")
def probe(x: str | int | None, y: Annotated[str | int | None, "y"] = None, z: str = None) -> str: # type: ignore[assignment]
return f"{x}:{y}:{z}"

func = KernelFunction.from_method(probe, "test")
assert func.parameters[0].type_ == "str, int"

tool = func.as_agent_framework_tool()
schema = tool.input_model.model_json_schema()

assert schema.get("required") == ["x"]
assert schema["properties"]["y"]["default"] is None
assert tool.input_model(x="a", y=1).y == 1
assert tool.input_model(x=2).x == 2
assert tool.input_model(x=2).z is None
assert tool.input_model(x=None).x is None
with pytest.raises(ValidationError):
tool.input_model(x=1.5j)
Loading