From daa7e3d4377662e175ff10937f8460e6a10e9943 Mon Sep 17 00:00:00 2001 From: ADIL ALPEREN CIFTCI <134228585+adilalperenciftci@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:49:18 +0300 Subject: [PATCH 1/4] fix(functions): validate complex kernel function arguments Complex and Union annotations containing commas could bypass parameter parsing in gather_function_parameters, allowing raw argument values to reach the kernel function. Validate generic annotations with Pydantic TypeAdapter and remove the condition that skipped parsing for comma-containing type metadata. --- .../functions/kernel_function_from_method.py | 10 ++++++-- .../test_kernel_function_from_method.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function_from_method.py b/python/semantic_kernel/functions/kernel_function_from_method.py index d9dcf869b95c..1ad4349d77f6 100644 --- a/python/semantic_kernel/functions/kernel_function_from_method.py +++ b/python/semantic_kernel/functions/kernel_function_from_method.py @@ -6,7 +6,7 @@ from inspect import isasyncgen, isasyncgenfunction, isawaitable, iscoroutinefunction, isgenerator, isgeneratorfunction from typing import Any -from pydantic import Field, ValidationError +from pydantic import Field, TypeAdapter, ValidationError from semantic_kernel.exceptions import FunctionExecutionException, FunctionInitializationError from semantic_kernel.filters.functions.function_invocation_context import FunctionInvocationContext @@ -140,6 +140,13 @@ def _parse_parameter(self, value: Any, param_type: Any) -> Any: 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: + if getattr(param_type, "__origin__", None) is not None: + try: + return TypeAdapter(param_type).validate_python(value) + except Exception as exc: + raise FunctionExecutionException( + f"Parameter is expected to be parsed to {param_type} but is not." + ) from exc try: if isinstance(value, dict) and hasattr(param_type, "__init__"): return param_type(**value) @@ -171,7 +178,6 @@ def gather_function_parameters(self, context: FunctionInvocationContext) -> dict value: Any = context.arguments[param.name] if ( param.type_ - and "," not in param.type_ and param.type_object and param.type_object is not inspect._empty and param.type_object is not 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 3b868625d87c..ccb3abde3d99 100644 --- a/python/tests/unit/functions/test_kernel_function_from_method.py +++ b/python/tests/unit/functions/test_kernel_function_from_method.py @@ -566,3 +566,27 @@ 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 + + +async def test_function_invoke_rejects_invalid_union_argument(kernel: Kernel): + invoked = False + + @kernel_function + def union_function(value: int | str) -> int | str: + nonlocal invoked + invoked = True + return value + + function = KernelFunction.from_method(union_function, "test") + + with pytest.raises( + FunctionExecutionException, + match=r"Parameter value is expected to be parsed to .* but is not\.", + ): + await function.invoke( + kernel=kernel, + arguments=KernelArguments(value={"unexpected": "value"}), + ) + + assert not invoked + From b7dbf3ca3355f6ad5104ba195753dc07bfbddd46 Mon Sep 17 00:00:00 2001 From: ADIL ALPEREN CIFTCI <134228585+adilalperenciftci@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:00:18 +0300 Subject: [PATCH 2/4] fix(functions): validate PEP 604 union arguments --- .../functions/kernel_function_decorator.py | 2 ++ .../functions/kernel_function_from_method.py | 4 ++-- .../functions/test_kernel_function_from_method.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function_decorator.py b/python/semantic_kernel/functions/kernel_function_decorator.py index 4d103a1a9093..c93c2309aed7 100644 --- a/python/semantic_kernel/functions/kernel_function_decorator.py +++ b/python/semantic_kernel/functions/kernel_function_decorator.py @@ -124,6 +124,8 @@ def _process_signature(func_sig: Signature) -> list[dict[str, Any]]: parsed_annotation = _parse_parameter(arg.name, annotation, default) if get_origin(annotation) is Annotated or get_origin(annotation) in {Union, types.UnionType}: underlying_type = _get_underlying_type(annotation) + if underlying_type is None: + underlying_type = annotation else: underlying_type = annotation parsed_annotation["type_object"] = underlying_type diff --git a/python/semantic_kernel/functions/kernel_function_from_method.py b/python/semantic_kernel/functions/kernel_function_from_method.py index 1ad4349d77f6..dc4125aefe43 100644 --- a/python/semantic_kernel/functions/kernel_function_from_method.py +++ b/python/semantic_kernel/functions/kernel_function_from_method.py @@ -4,7 +4,7 @@ import logging from collections.abc import Callable from inspect import isasyncgen, isasyncgenfunction, isawaitable, iscoroutinefunction, isgenerator, isgeneratorfunction -from typing import Any +from typing import Any, get_origin from pydantic import Field, TypeAdapter, ValidationError @@ -140,7 +140,7 @@ def _parse_parameter(self, value: Any, param_type: Any) -> Any: 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: - if getattr(param_type, "__origin__", None) is not None: + if get_origin(param_type) is not None: try: return TypeAdapter(param_type).validate_python(value) except Exception as exc: 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 ccb3abde3d99..f3756c341339 100644 --- a/python/tests/unit/functions/test_kernel_function_from_method.py +++ b/python/tests/unit/functions/test_kernel_function_from_method.py @@ -590,3 +590,14 @@ def union_function(value: int | str) -> int | str: assert not invoked + +@pytest.mark.parametrize("value", [42, "valid"]) +async def test_function_invoke_accepts_valid_union_argument(kernel: Kernel, value: int | str): + @kernel_function + def union_function(value: int | str) -> int | str: + return value + + function = KernelFunction.from_method(union_function, "test") + result = await function.invoke(kernel=kernel, arguments=KernelArguments(value=value)) + + assert result.value == value From 046be15048a95db0b94f1a5f647a1b8410b4de27 Mon Sep 17 00:00:00 2001 From: ADIL ALPEREN CIFTCI <134228585+adilalperenciftci@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:33:39 +0300 Subject: [PATCH 3/4] fix(python): fallback to union annotation when non-none resolution is empty Preserve PEP 604 and typing.Union type metadata during kernel function parameter inspection when unions do not contain NoneType. --- .../functions/kernel_function_decorator.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function_decorator.py b/python/semantic_kernel/functions/kernel_function_decorator.py index c93c2309aed7..834341dc6ef5 100644 --- a/python/semantic_kernel/functions/kernel_function_decorator.py +++ b/python/semantic_kernel/functions/kernel_function_decorator.py @@ -98,14 +98,14 @@ def _get_non_none_type(args: tuple) -> Any: def _get_underlying_type(annotation: Any) -> Any: """Get the underlying type of the annotation.""" if isinstance(annotation, types.UnionType): - return _get_non_none_type(annotation.__args__) + return _get_non_none_type(annotation.__args__) or annotation if hasattr(annotation, "__origin__"): if annotation.__origin__ is Union: - return _get_non_none_type(get_args(annotation)) + return _get_non_none_type(get_args(annotation)) or annotation if isinstance(annotation.__origin__, types.UnionType): - return _get_non_none_type(annotation.__origin__.__args__) + return _get_non_none_type(annotation.__origin__.__args__) or annotation.__origin__ return annotation.__origin__ @@ -124,8 +124,6 @@ def _process_signature(func_sig: Signature) -> list[dict[str, Any]]: parsed_annotation = _parse_parameter(arg.name, annotation, default) if get_origin(annotation) is Annotated or get_origin(annotation) in {Union, types.UnionType}: underlying_type = _get_underlying_type(annotation) - if underlying_type is None: - underlying_type = annotation else: underlying_type = annotation parsed_annotation["type_object"] = underlying_type From fa177a7c9a5b9f7b18c25d118e75cc4dfb5862e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Tue, 3 Nov 2026 00:40:16 +0300 Subject: [PATCH 4/4] fix(functions): allow arbitrary classes in union argument validation TypeAdapter rejected unions that include ordinary Python classes before validating the selected branch, so a valid str or custom instance passed to a parameter annotated Custom | str raised FunctionExecutionException. Configure the adapter with arbitrary_types_allowed and add regressions for the valid primitive, the valid custom instance, and the still-rejected invalid argument. --- .../functions/kernel_function_from_method.py | 5 +- .../test_kernel_function_from_method.py | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function_from_method.py b/python/semantic_kernel/functions/kernel_function_from_method.py index dc4125aefe43..6f2b15b9b0bd 100644 --- a/python/semantic_kernel/functions/kernel_function_from_method.py +++ b/python/semantic_kernel/functions/kernel_function_from_method.py @@ -6,7 +6,7 @@ from inspect import isasyncgen, isasyncgenfunction, isawaitable, iscoroutinefunction, isgenerator, isgeneratorfunction from typing import Any, get_origin -from pydantic import Field, TypeAdapter, ValidationError +from pydantic import ConfigDict, Field, TypeAdapter, ValidationError from semantic_kernel.exceptions import FunctionExecutionException, FunctionInitializationError from semantic_kernel.filters.functions.function_invocation_context import FunctionInvocationContext @@ -142,7 +142,8 @@ def _parse_parameter(self, value: Any, param_type: Any) -> Any: else: if get_origin(param_type) is not None: try: - return TypeAdapter(param_type).validate_python(value) + adapter = TypeAdapter(param_type, config=ConfigDict(arbitrary_types_allowed=True)) + return adapter.validate_python(value) except Exception as exc: raise FunctionExecutionException( f"Parameter is expected to be parsed to {param_type} but is not." 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 d41c1963c1ba..e0f91a1e9250 100644 --- a/python/tests/unit/functions/test_kernel_function_from_method.py +++ b/python/tests/unit/functions/test_kernel_function_from_method.py @@ -716,3 +716,53 @@ def union_function(value: int | str) -> int | str: result = await function.invoke(kernel=kernel, arguments=KernelArguments(value=value)) assert result.value == value + + +class CustomArgument: + """Plain Python class used to verify arbitrary types survive union validation.""" + + def __init__(self, label: str) -> None: + self.label = label + + +async def test_function_invoke_accepts_valid_primitive_in_custom_union(kernel: Kernel): + @kernel_function + def custom_union_function(value: CustomArgument | str) -> str: + return value if isinstance(value, str) else value.label + + function = KernelFunction.from_method(custom_union_function, "test") + result = await function.invoke(kernel=kernel, arguments=KernelArguments(value="plain")) + + assert result.value == "plain" + + +async def test_function_invoke_accepts_custom_instance_in_custom_union(kernel: Kernel): + @kernel_function + def custom_union_function(value: CustomArgument | str) -> str: + return value if isinstance(value, str) else value.label + + function = KernelFunction.from_method(custom_union_function, "test") + instance = CustomArgument("instance") + result = await function.invoke(kernel=kernel, arguments=KernelArguments(value=instance)) + + assert result.value == "instance" + + +async def test_function_invoke_rejects_invalid_custom_union_argument(kernel: Kernel): + invoked = False + + @kernel_function + def custom_union_function(value: CustomArgument | str) -> str: + nonlocal invoked + invoked = True + return value if isinstance(value, str) else value.label + + function = KernelFunction.from_method(custom_union_function, "test") + + with pytest.raises( + FunctionExecutionException, + match=r"Parameter value is expected to be parsed to .* but is not\.", + ): + await function.invoke(kernel=kernel, arguments=KernelArguments(value={"unexpected": "value"})) + + assert not invoked