From 851efcef79618f0443bb1223686fbe4bc6293b02 Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:08:02 +0530 Subject: [PATCH 1/3] Python: fix kernel_function decorator mutating shared Annotated metadata dicts _parse_parameter() called meta.pop("description") on dict metadata used in typing.Annotated, mutating the user-provided dict. When the same metadata dict is reused across multiple kernel functions (shared parameter docs), every function after the first silently lost its parameter description, and the dict itself was permanently modified. Use meta.get() instead of meta.pop() and filter the description key out when applying the remaining metadata keys, so semantics are unchanged but the caller's dict is left untouched. --- .../functions/kernel_function_decorator.py | 6 ++- .../test_kernel_function_decorators.py | 41 +++++++++++++++++++ 2 files changed, 45 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..3176ed2bb4ef 100644 --- a/python/semantic_kernel/functions/kernel_function_decorator.py +++ b/python/semantic_kernel/functions/kernel_function_decorator.py @@ -152,9 +152,11 @@ def _parse_parameter(name: str, param: Any, default: Any) -> dict[str, Any]: ret["description"] = meta elif isinstance(meta, dict): # only override from the metadata if it is not already set - if "description" not in ret and (description := meta.pop("description", None)): + # note: do not mutate the metadata dict itself, it is user-provided + # and can be shared between multiple functions/parameters + if "description" not in ret and (description := meta.get("description", None)): ret["description"] = description - ret.update(meta) + ret.update({key: value for key, value in meta.items() if key != "description"}) else: logger.debug(f"Unknown metadata type: {meta}") if hasattr(param, "__origin__"): diff --git a/python/tests/unit/functions/test_kernel_function_decorators.py b/python/tests/unit/functions/test_kernel_function_decorators.py index 803e0d2ffc7a..e47615fffc94 100644 --- a/python/tests/unit/functions/test_kernel_function_decorators.py +++ b/python/tests/unit/functions/test_kernel_function_decorators.py @@ -280,3 +280,44 @@ def test_annotation_parsing(name, annotation, description, type_, is_required): assert description == annotation_dict.get("description") assert type_ == annotation_dict["type_"] assert is_required == annotation_dict["is_required"] + + +def test_shared_annotation_metadata_dict_not_mutated(): + """A dict used as Annotated metadata must not be mutated when parsed. + + The same metadata dict can be reused across multiple kernel functions + (e.g. shared parameter docs); parsing one function's signature must not + remove the "description" key for the next function. + """ + shared_meta = {"description": "shared description"} + + @kernel_function + def func_one(arg: Annotated[int, shared_meta]) -> str: + return "one" + + @kernel_function + def func_two(arg: Annotated[int, shared_meta]) -> str: + return "two" + + # the user-provided dict is unchanged + assert shared_meta == {"description": "shared description"} + + params_one = {p["name"]: p.get("description") for p in func_one.__kernel_function_parameters__} + params_two = {p["name"]: p.get("description") for p in func_two.__kernel_function_parameters__} + + assert params_one["arg"] == "shared description" + assert params_two["arg"] == "shared description" + + +def test_annotation_metadata_dict_other_keys_still_applied(): + """Keys other than 'description' in a metadata dict must still be applied, without mutation.""" + meta = {"description": "the description", "default_value": 42} + + @kernel_function + def func(arg: Annotated[int, meta]) -> str: + return "ok" + + assert meta == {"description": "the description", "default_value": 42} + param = func.__kernel_function_parameters__[0] + assert param["description"] == "the description" + assert param["default_value"] == 42 From 48141765fcb34bde8f7eb30000660d10aa7b9e5c Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Thu, 5 Nov 2026 14:06:47 +0530 Subject: [PATCH 2/3] Revert to main's copy based implementation --- .../functions/kernel_function_decorator.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function_decorator.py b/python/semantic_kernel/functions/kernel_function_decorator.py index 3176ed2bb4ef..debff126821e 100644 --- a/python/semantic_kernel/functions/kernel_function_decorator.py +++ b/python/semantic_kernel/functions/kernel_function_decorator.py @@ -71,7 +71,9 @@ def decorator(func: Callable[..., object]) -> Callable[..., object]: setattr(func, "__kernel_function_parameters__", annotations) return_annotation = ( - _parse_parameter("return", func_sig.return_annotation, None) if func_sig.return_annotation else {} + _parse_parameter("return", func_sig.return_annotation, Parameter.empty) + if func_sig.return_annotation + else {} ) setattr(func, "__kernel_function_return_type__", return_annotation.get("type_", "None")) setattr(func, "__kernel_function_return_type_object__", return_annotation.get("type_object", None)) @@ -120,7 +122,8 @@ def _process_signature(func_sig: Signature) -> list[dict[str, Any]]: if arg.name == "self": continue annotation = arg.annotation - default = arg.default if arg.default != arg.empty else None + # Parameter.empty means "no default"; a default of None is still a default. + default = arg.default 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) @@ -137,7 +140,7 @@ def _parse_parameter(name: str, param: Any, default: Any) -> dict[str, Any]: logger.debug(f"Parsing param: {name}") logger.debug(f"Parsing annotation: {param}") ret: dict[str, Any] = {"name": name} - if default is not None: + if default is not Parameter.empty: ret["default_value"] = default ret["is_required"] = False else: @@ -151,15 +154,14 @@ def _parse_parameter(name: str, param: Any, default: Any) -> dict[str, Any]: if isinstance(meta, str): ret["description"] = meta elif isinstance(meta, dict): + meta = meta.copy() # only override from the metadata if it is not already set - # note: do not mutate the metadata dict itself, it is user-provided - # and can be shared between multiple functions/parameters - if "description" not in ret and (description := meta.get("description", None)): + if "description" not in ret and (description := meta.pop("description", None)): ret["description"] = description - ret.update({key: value for key, value in meta.items() if key != "description"}) + ret.update(meta) else: logger.debug(f"Unknown metadata type: {meta}") - if hasattr(param, "__origin__"): + if hasattr(param, "__origin__") and param.__origin__ not in (Union, types.UnionType): ret.update(_parse_parameter(name, param.__origin__, default)) if hasattr(param, "__args__"): args = [] From 930efb08d2c2e928b6d918a1a39f207baf6db500 Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Thu, 5 Nov 2026 14:06:49 +0530 Subject: [PATCH 3/3] Cover description precedence and the shared metadata contract --- .../test_kernel_function_decorators.py | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/python/tests/unit/functions/test_kernel_function_decorators.py b/python/tests/unit/functions/test_kernel_function_decorators.py index e47615fffc94..aa55a4e35682 100644 --- a/python/tests/unit/functions/test_kernel_function_decorators.py +++ b/python/tests/unit/functions/test_kernel_function_decorators.py @@ -282,13 +282,21 @@ def test_annotation_parsing(name, annotation, description, type_, is_required): assert is_required == annotation_dict["is_required"] -def test_shared_annotation_metadata_dict_not_mutated(): - """A dict used as Annotated metadata must not be mutated when parsed. +def test_annotation_description_precedence_across_metadata_entries(): + """A later dict metadata entry overrides an earlier string description.""" + meta = {"description": "later dictionary"} + + @kernel_function + def func(arg: Annotated[int, "earlier string", meta]) -> str: + return "ok" - The same metadata dict can be reused across multiple kernel functions - (e.g. shared parameter docs); parsing one function's signature must not - remove the "description" key for the next function. - """ + assert func.__kernel_function_parameters__[0]["description"] == "later dictionary" + # The caller-owned dict is untouched by decoration. + assert meta == {"description": "later dictionary"} + + +def test_shared_annotation_metadata_dict_not_mutated(): + """Decorating with one shared Annotated metadata dict keeps it usable twice.""" shared_meta = {"description": "shared description"} @kernel_function @@ -299,25 +307,10 @@ def func_one(arg: Annotated[int, shared_meta]) -> str: def func_two(arg: Annotated[int, shared_meta]) -> str: return "two" - # the user-provided dict is unchanged assert shared_meta == {"description": "shared description"} - - params_one = {p["name"]: p.get("description") for p in func_one.__kernel_function_parameters__} - params_two = {p["name"]: p.get("description") for p in func_two.__kernel_function_parameters__} - - assert params_one["arg"] == "shared description" - assert params_two["arg"] == "shared description" - - -def test_annotation_metadata_dict_other_keys_still_applied(): - """Keys other than 'description' in a metadata dict must still be applied, without mutation.""" - meta = {"description": "the description", "default_value": 42} - - @kernel_function - def func(arg: Annotated[int, meta]) -> str: - return "ok" - - assert meta == {"description": "the description", "default_value": 42} - param = func.__kernel_function_parameters__[0] - assert param["description"] == "the description" - assert param["default_value"] == 42 + descriptions = [ + param["description"] + for func in (func_one, func_two) + for param in func.__kernel_function_parameters__ + ] + assert descriptions == ["shared description", "shared description"]