Skip to content

Python: pass explicit None to optional kernel function parameters unchanged - #14764

Open
Anubhav Lal (anubhavlal07) wants to merge 2 commits into
microsoft:mainfrom
anubhavlal07:fix/none-optional-param-coercion
Open

Anubhav Lal (anubhavlal07) wants to merge 2 commits into
microsoft:mainfrom
anubhavlal07:fix/none-optional-param-coercion

Conversation

@anubhavlal07

Copy link
Copy Markdown

Title: Python: skip type coercion for None arguments in KernelFunctionFromMethod

Motivation and Context

KernelFunctionFromMethod.gather_function_parameters passes every supplied argument through _parse_parameter, which calls the parameter's type on the value. When an argument is present but explicitly None, this goes wrong for optional parameters:

  • count: int | None = None gets int(None), which raises TypeError. The call fails with FunctionExecutionException: Parameter count is expected to be parsed to <class 'int'> but is not.
  • label: str | None = None gets str(None), so the function receives the string "None" instead of None.
  • item: MyDataclass | None = None gets MyDataclass(None) and fails.

This happens when a caller passes None directly. It also happens during auto function calling, where a model sends null for an optional tool parameter ({"count": null}). Instead of running the tool with None, the invocation fails.

Minimal repro:

from semantic_kernel import Kernel
from semantic_kernel.functions import KernelArguments, kernel_function

@kernel_function
def search(query: str, limit: int | None = None) -> str:
    return f"{query}:{limit}"

kernel = Kernel()
func = kernel.add_function(plugin_name="demo", function=search)
await kernel.invoke(func, KernelArguments(query="x", limit=None))
# KernelInvokeException <- FunctionExecutionException: Parameter limit is expected to be parsed to <class 'int'> but is not.

The .NET side has a similar report for null optional arguments (#14652).

Description

In gather_function_parameters, a None value now skips the type coercion step and is passed to the method unchanged. Non-None values are coerced exactly as before, so "3" still becomes 3 for an int parameter.

Test added in tests/unit/functions/test_kernel_function_from_method.py:

  • test_explicit_none_for_optional_parameters_is_not_coerced invokes a function with optional int | None, Annotated[str | None, ...] and dataclass | None parameters set to None, and checks that each one receives None. It also checks that string-to-int coercion still works for non-None values.

Without the fix the test fails: the int and dataclass parameters raise, and the str parameter would receive "None".

Tests: tests/unit/functions/test_kernel_function_from_method.py passes (73 tests). The new test_explicit_none_for_optional_parameters_is_not_coerced fails on main and passes with the change. ruff check and mypy are clean on the changed file.

Contribution Checklist

This change was prepared with the help of an AI assistant (Claude), and I reviewed and tested it.

Explicit None for optional parameters crashed or became the string None.
@anubhavlal07
Anubhav Lal (anubhavlal07) requested a review from a team as a code owner November 9, 2026 16:57
Copilot AI balanced review requested due to automatic review settings November 9, 2026 16:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@anubhavlal07

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is right for Optional parameters. But the new value is not None check (kernel_function_from_method.py:180) also skips parsing for parameters that don't accept None.

Take def f(count: int) (required, not optional), invoked with KernelArguments(count=None), or by a model that sends {"count": null}. On main that raised FunctionExecutionException: Parameter count is expected to be parsed to <class 'int'>, a clear error the auto-invoke loop reports back to the model. With this change, None is passed into the function, and it fails later with a less useful error inside the user's code (or, worse, runs with None). The same goes for count: int = 5: an explicit null now overrides the default with None rather than raising.

The decorator already records whether NoneType is in the annotation (kernel_function_decorator.py:169-170 sets is_required = False for it), but is_required also becomes False for any parameter with a default, so it can't tell the two cases apart by itself. One option is to check the annotation itself, for example by keeping the skip only when type(None) in get_args(param.type_object) or when the parameter's default_value is None. Otherwise, keep raising as before. A test with a required count: int given None would pin the boundary.

The new test's second invoke (count="3" → 3) is a good check that coercion still runs for non-None values.

I read the code at the PR head, and git merge-tree against main is clean. I didn't run the tests.

Reviewed with help from Claude Code.

@semantic-kernel-automation semantic-kernel-automation Bot added the python Pull requests for the Python Semantic Kernel label Nov 11, 2026
@anubhavlal07

Copy link
Copy Markdown
Author

Thanks Muhammad Abdullah Butt (@1AbdullahButt), good catch. Fixed in 7236ac3.

An explicit None now skips coercion only when the parameter is optional and defaults to None (count: int | None, x: str = None). A required count: int and count: int = 5 go through parsing as on main and raise Parameter count is expected to be parsed to <class 'int'>. I added test_explicit_none_for_non_optional_parameters_raises to cover both.

I used the metadata rather than get_args(param.type_object), because _process_signature stores the unwrapped type (int for int | None), so NoneType is gone by then. The decorator does set is_required=False and default_value=None whenever NoneType is in the annotation, which gives the same boundary.

This branch was successfully deployed

1 active deployment
github-app-auth — 7236ac3f Deployed Nov 11, 2026 by anubhavlal07 via add_label #29671
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests for the Python Semantic Kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants