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))