From 546888f4ed73c99fdc7bdf0582bf77c5788732d5 Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:10:14 +0530 Subject: [PATCH 1/2] Python: fix KernelJsonSchemaBuilder leaking Field constraints as descriptions build_model_schema() preferred FieldInfo.metadata over FieldInfo.description when deriving field descriptions. In pydantic v2, FieldInfo.metadata is a list of constraint objects (annotated_types.Ge, Gt, ...), so for any field declared with a constraint (e.g. Field(description=..., ge=0)) the real description was replaced by the constraint object. The resulting schema carried a non-string 'description' and json.dumps() of the function-calling tool payload failed with 'TypeError: Object of type Ge is not JSON serializable'. Only treat str and dict-with-'description' entries in metadata as descriptions (these come from Annotated metadata), and fall back to FieldInfo.description otherwise. --- .../schema/kernel_json_schema_builder.py | 19 +++++++--- .../tests/unit/schema/test_schema_builder.py | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index 5ec519b5b377..b7c243e3b36e 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -89,11 +89,20 @@ def build_model_schema( field_description = None if hasattr(model, "model_fields") and field_name in model.model_fields: field_info = model.model_fields[field_name] - if isinstance(field_info.metadata, dict): - field_description = field_info.metadata.get("description") - elif isinstance(field_info.metadata, list) and field_info.metadata: - field_description = field_info.metadata[0] - elif hasattr(field_info, "description"): + # `FieldInfo.metadata` holds constraint objects (e.g. annotated_types.Ge) + # alongside any description metadata supplied through Annotated, + # so only strings and dicts can be descriptions; never use + # `metadata[0]` blindly as it is most often a constraint. + field_metadata = getattr(field_info, "metadata", None) + if isinstance(field_metadata, (list, tuple)): + for meta in field_metadata: + if isinstance(meta, str): + field_description = meta + break + if isinstance(meta, dict) and meta.get("description"): + field_description = meta["description"] + break + if field_description is None and field_info.description: field_description = field_info.description if not cls._is_optional(field_type): required.append(field_name) diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index 5d24a599c96c..a2a93579ab81 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -455,3 +455,41 @@ def test_build_schema_with_nonpydantic_structured_output(): } assert structured_output_schema == expected_schema + + +def test_build_model_schema_field_description_with_constraints(): + """Field descriptions must survive when the field also has constraints. + + Pydantic stores constraints (e.g. `ge=0`) in `FieldInfo.metadata`; taking + `metadata[0]` as the description leaks the constraint object into the + schema, which then cannot be JSON serialized when sent to the model. + """ + from pydantic import Field + + class ModelWithConstraints(KernelBaseModel): + count: int = Field(description="number of items", ge=0) + ratio: float = Field(description="the ratio", gt=0, le=1) + label: str = Field(description="the label") + + schema = KernelJsonSchemaBuilder.build(ModelWithConstraints) + + assert schema["properties"]["count"]["description"] == "number of items" + assert schema["properties"]["ratio"]["description"] == "the ratio" + assert schema["properties"]["label"]["description"] == "the label" + # the full schema must stay JSON serializable (function calling payload) + assert json.loads(json.dumps(schema)) + + +def test_build_model_schema_annotated_descriptions(): + """Descriptions supplied via Annotated metadata must be used, also with constraints.""" + from pydantic import Field + + class ModelWithAnnotated(KernelBaseModel): + amount: Annotated[int, "amount description"] = Field(ge=1) + tagged: Annotated[str, {"description": "tagged description"}] = "x" + + schema = KernelJsonSchemaBuilder.build(ModelWithAnnotated) + + assert schema["properties"]["amount"]["description"] == "amount description" + assert schema["properties"]["tagged"]["description"] == "tagged description" + assert json.loads(json.dumps(schema)) From c1750e168fea394c02933ae8069f517678aff04e Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:18:35 +0530 Subject: [PATCH 2/2] Python: apply input_variables defaults when rendering prompt templates The per-variable default was validated and exported as metadata but never consulted at render time, so a missing argument rendered as an empty string instead of the declared default. Seed missing arguments with their defaults before rendering; explicit arguments always win. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../prompt_template/kernel_prompt_template.py | 13 ++++++++++ .../test_kernel_prompt_template.py | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/python/semantic_kernel/prompt_template/kernel_prompt_template.py b/python/semantic_kernel/prompt_template/kernel_prompt_template.py index 9a7eeccd1763..6301f65ad604 100644 --- a/python/semantic_kernel/prompt_template/kernel_prompt_template.py +++ b/python/semantic_kernel/prompt_template/kernel_prompt_template.py @@ -91,6 +91,19 @@ async def render(self, kernel: "Kernel", arguments: "KernelArguments | None" = N str: The prompt template ready to be used for an AI request """ + # Apply documented input-variable defaults for any variable that the + # caller did not supply. Without this, a declared default is validated + # (string-only) and exposed as metadata, but never actually used. + if self.prompt_template_config.input_variables: + if arguments is None: + arguments = KernelArguments() + for variable in self.prompt_template_config.input_variables: + if ( + variable.name not in arguments + and variable.name.lower() not in {k.lower() for k in arguments} + and variable.default + ): + arguments[variable.name] = variable.default return await self.render_blocks(self._blocks, kernel, arguments) async def render_blocks( diff --git a/python/tests/unit/prompt_template/test_kernel_prompt_template.py b/python/tests/unit/prompt_template/test_kernel_prompt_template.py index 6d8b1a1b2bf0..26d5048af023 100644 --- a/python/tests/unit/prompt_template/test_kernel_prompt_template.py +++ b/python/tests/unit/prompt_template/test_kernel_prompt_template.py @@ -137,3 +137,29 @@ def my_function(arguments: KernelArguments) -> str: target = create_kernel_prompt_template(template, allow_dangerously_set_content=True) with pytest.raises(TemplateRenderException): await target.render(kernel, arguments) + + +async def test_input_variable_default_used_when_argument_missing(kernel: Kernel): + config = PromptTemplateConfig( + name="test", + description="test", + template="Hello {{$name}}!", + template_format="semantic-kernel", + input_variables=[InputVariable(name="name", description="who", default="STRANGER")], + ) + template = KernelPromptTemplate(prompt_template_config=config) + rendered = await template.render(kernel) + assert rendered == "Hello STRANGER!" + + +async def test_input_variable_default_does_not_override_argument(kernel: Kernel): + config = PromptTemplateConfig( + name="test", + description="test", + template="Hello {{$name}}!", + template_format="semantic-kernel", + input_variables=[InputVariable(name="name", description="who", default="STRANGER")], + ) + template = KernelPromptTemplate(prompt_template_config=config) + rendered = await template.render(kernel, KernelArguments(name="World")) + assert rendered == "Hello World!"