From 53784b0b625713347e463a485a3fc74ba5a009ee Mon Sep 17 00:00:00 2001 From: Binit Mohanty Date: Sun, 19 Jul 2026 10:27:34 -0500 Subject: [PATCH 1/4] Python: Fix PropertySchema.to_json_schema() not recursing into nested schemas Nested array 'items' and object 'properties' kept the declarative 'kind' key and empty 'enum' placeholders, producing JSON Schema OpenAI rejects ('schema must have a type key'). Recursively apply the same conversion the top-level properties loop performs, including the serialized named-list properties shape and nested required arrays. Fixes #7198 (cherry picked from commit c156ffd05924fb5a1884625f2fc3d9bdc3e152b1) --- .../agent_framework_declarative/_models.py | 44 +++++++++++++++++++ .../tests/test_declarative_models.py | 33 ++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 9a23560f99f..0e6bd016619 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -203,6 +203,49 @@ def __init__( self.properties = converted_properties +def _normalize_nested_schemas(node: dict[str, Any]) -> None: + """Recursively convert a node's nested schemas to JSON Schema form. + + Nested schemas (array ``items``, object ``properties``) keep the declarative + shape after serialization: ``kind`` instead of ``type``, empty ``enum`` + placeholders, and object properties as a list of ``{"name": ..., ...}`` + entries. OpenAI rejects schemas whose nested nodes lack a ``type`` key, so + apply the same conversion the top-level properties loop performs. + """ + items = node.get("items") + if isinstance(items, dict): + _normalize_schema_node(items) + props = node.get("properties") + if isinstance(props, list): + # Serialized PropertySchema shape: [{"name": ..., "kind": ..., ...}, ...] + new_props: dict[str, Any] = {} + required_fields: list[str] = [] + for prop in props: + if not isinstance(prop, dict) or "name" not in prop: + return # unexpected shape; leave untouched + prop_name = prop.pop("name") + if prop.pop("required", False): + required_fields.append(prop_name) + _normalize_schema_node(prop) + new_props[prop_name] = prop + node["properties"] = new_props + if required_fields: + node["required"] = required_fields + elif isinstance(props, dict): + for child in props.values(): + if isinstance(child, dict): + _normalize_schema_node(child) + + +def _normalize_schema_node(node: dict[str, Any]) -> None: + """Rename ``kind`` -> ``type``, drop empty ``enum``, and recurse into children.""" + if "kind" in node: + node["type"] = node.pop("kind") + if not node.get("enum"): + node.pop("enum", None) + _normalize_nested_schemas(node) + + class PropertySchema(SerializationMixin): """Object representing a property schema.""" @@ -251,6 +294,7 @@ def to_json_schema(self) -> dict[str, Any]: # Remove empty enum arrays if not prop.get("enum"): prop.pop("enum", None) + _normalize_nested_schemas(prop) new_props[prop_name] = prop json_schema["type"] = "object" json_schema["properties"] = new_props diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index 85fa58a32f5..74e258452c0 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -302,6 +302,39 @@ def test_property_schema_with_type_field_produces_correct_json_schema(self): assert "required" not in json_schema["properties"]["language"] assert "required" not in json_schema["properties"]["answer"] + def test_property_schema_array_items_kind_renamed_recursively(self): + """Nested array 'items' schemas get kind -> type renamed and empty enums dropped.""" + schema = PropertySchema.from_dict({ + "properties": { + "issues": {"kind": "array", "items": {"kind": "string"}}, + }, + }) + + json_schema = schema.to_json_schema() + + assert json_schema["properties"]["issues"]["type"] == "array" + assert json_schema["properties"]["issues"]["items"] == {"type": "string"} + + def test_property_schema_nested_object_properties_kind_renamed_recursively(self): + """Nested object 'properties' (including arrays of objects) are converted at every depth.""" + schema = PropertySchema.from_dict({ + "properties": { + "picks": { + "kind": "array", + "items": { + "kind": "object", + "properties": {"ref": {"kind": "number"}}, + }, + }, + }, + }) + + json_schema = schema.to_json_schema() + + items = json_schema["properties"]["picks"]["items"] + assert items["type"] == "object" + assert items["properties"]["ref"] == {"type": "number"} + class TestConnection: """Tests for Connection base class.""" From d101c57633b1299769c5c3b5be94acfaa0a21078 Mon Sep 17 00:00:00 2001 From: Binit Mohanty Date: Sun, 19 Jul 2026 10:50:49 -0500 Subject: [PATCH 2/4] Python: Validate nested properties list before mutating to avoid partial conversion Review feedback: the list-shaped properties branch popped name/required from each element and returned on the first unexpected one, leaving earlier elements half-converted. Validate the whole list first so an unexpected shape leaves the node fully untouched. --- .../agent_framework_declarative/_models.py | 8 +++++--- .../tests/test_declarative_models.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 0e6bd016619..3130aa52e39 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -217,12 +217,14 @@ def _normalize_nested_schemas(node: dict[str, Any]) -> None: _normalize_schema_node(items) props = node.get("properties") if isinstance(props, list): - # Serialized PropertySchema shape: [{"name": ..., "kind": ..., ...}, ...] + # Serialized PropertySchema shape: [{"name": ..., "kind": ..., ...}, ...]. + # Validate every element BEFORE mutating any, so an unexpected shape + # leaves the node fully untouched rather than half-converted. + if not all(isinstance(prop, dict) and "name" in prop for prop in props): + return new_props: dict[str, Any] = {} required_fields: list[str] = [] for prop in props: - if not isinstance(prop, dict) or "name" not in prop: - return # unexpected shape; leave untouched prop_name = prop.pop("name") if prop.pop("required", False): required_fields.append(prop_name) diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index 74e258452c0..6475b23059c 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -335,6 +335,23 @@ def test_property_schema_nested_object_properties_kind_renamed_recursively(self) assert items["type"] == "object" assert items["properties"]["ref"] == {"type": "number"} + def test_property_schema_unexpected_nested_properties_left_untouched(self): + """A nested properties list with an unexpected element is left fully unmodified.""" + from agent_framework_declarative._models import _normalize_nested_schemas + + node = { + "type": "object", + "properties": [ + {"name": "ok", "kind": "string"}, + "not-a-dict", + ], + } + + _normalize_nested_schemas(node) + + # No partial mutation: the well-formed first element keeps its original shape too. + assert node["properties"] == [{"name": "ok", "kind": "string"}, "not-a-dict"] + class TestConnection: """Tests for Connection base class.""" From 7e3cdeb463443d0e6527fc4262e7f28adf395b77 Mon Sep 17 00:00:00 2001 From: Binit Mohanty Date: Tue, 21 Jul 2026 05:44:49 -0500 Subject: [PATCH 3/4] Python: Type nested-properties normalization for strict Pyright and drop unreachable dict branch ObjectProperty always stores nested properties as a named list, so the elif-dict branch in _normalize_nested_schemas was unreachable; remove it and flatten the list conversion behind an early return. Cast the narrowed items/props values so strict Pyright no longer reports unknown types. Co-Authored-By: Claude Fable 5 --- .../agent_framework_declarative/_models.py | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 3130aa52e39..d55ecd73eff 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -5,7 +5,7 @@ import os from collections.abc import MutableMapping from contextvars import ContextVar -from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, overload +from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, cast, overload from agent_framework._serialization import SerializationMixin @@ -214,29 +214,26 @@ def _normalize_nested_schemas(node: dict[str, Any]) -> None: """ items = node.get("items") if isinstance(items, dict): - _normalize_schema_node(items) + _normalize_schema_node(cast("dict[str, Any]", items)) props = node.get("properties") - if isinstance(props, list): - # Serialized PropertySchema shape: [{"name": ..., "kind": ..., ...}, ...]. - # Validate every element BEFORE mutating any, so an unexpected shape - # leaves the node fully untouched rather than half-converted. - if not all(isinstance(prop, dict) and "name" in prop for prop in props): - return - new_props: dict[str, Any] = {} - required_fields: list[str] = [] - for prop in props: - prop_name = prop.pop("name") - if prop.pop("required", False): - required_fields.append(prop_name) - _normalize_schema_node(prop) - new_props[prop_name] = prop - node["properties"] = new_props - if required_fields: - node["required"] = required_fields - elif isinstance(props, dict): - for child in props.values(): - if isinstance(child, dict): - _normalize_schema_node(child) + if not isinstance(props, list): + return + # Serialized PropertySchema shape: [{"name": ..., "kind": ..., ...}, ...]. + # Validate every element BEFORE mutating any, so an unexpected shape + # leaves the node fully untouched rather than half-converted. + if not all(isinstance(prop, dict) and "name" in prop for prop in cast("list[Any]", props)): + return + new_props: dict[str, Any] = {} + required_fields: list[str] = [] + for prop in cast("list[dict[str, Any]]", props): + prop_name = prop.pop("name") + if prop.pop("required", False): + required_fields.append(prop_name) + _normalize_schema_node(prop) + new_props[prop_name] = prop + node["properties"] = new_props + if required_fields: + node["required"] = required_fields def _normalize_schema_node(node: dict[str, Any]) -> None: From 74c2fb1eff771f6432848c368402190718783239 Mon Sep 17 00:00:00 2001 From: Binit Mohanty Date: Tue, 21 Jul 2026 05:55:15 -0500 Subject: [PATCH 4/4] Python: Emit additionalProperties: false on nested object nodes in PropertySchema.to_json_schema() OpenAI strict structured outputs require additionalProperties: false on every object node, but the chat clients only inject it at the schema root, so declarative schemas with nested objects (e.g. array items) failed with a schema-validation 400. Route the top-level properties loop through _normalize_schema_node so all object nodes get the key, and add a live OpenAI integration test covering the nested array-of-objects response_format shape. Verified live against the Responses API: the previous emission fails with "In context=('properties', 'issues', 'items'), 'additionalProperties' is required to be supplied and to be false"; the new emission returns valid structured output. Co-Authored-By: Claude Fable 5 --- .../agent_framework_declarative/_models.py | 10 ++-- .../tests/test_declarative_models.py | 24 ++++++++++ .../tests/openai/test_openai_chat_client.py | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index d55ecd73eff..d748dd65862 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -242,6 +242,10 @@ def _normalize_schema_node(node: dict[str, Any]) -> None: node["type"] = node.pop("kind") if not node.get("enum"): node.pop("enum", None) + if node.get("type") == "object": + # OpenAI strict structured outputs require additionalProperties: false on + # every object node; chat clients only inject it at the schema root. + node.setdefault("additionalProperties", False) _normalize_nested_schemas(node) @@ -286,14 +290,10 @@ def to_json_schema(self) -> dict[str, Any]: required_fields: list[str] = [] for prop in json_schema.get("properties", []): prop_name = prop.pop("name") - prop["type"] = prop.pop("kind", None) # Convert property-level 'required' boolean to a top-level 'required' array if prop.pop("required", False): required_fields.append(prop_name) - # Remove empty enum arrays - if not prop.get("enum"): - prop.pop("enum", None) - _normalize_nested_schemas(prop) + _normalize_schema_node(prop) new_props[prop_name] = prop json_schema["type"] = "object" json_schema["properties"] = new_props diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index 6475b23059c..043fb372792 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -334,6 +334,30 @@ def test_property_schema_nested_object_properties_kind_renamed_recursively(self) items = json_schema["properties"]["picks"]["items"] assert items["type"] == "object" assert items["properties"]["ref"] == {"type": "number"} + assert items["additionalProperties"] is False + + def test_property_schema_object_nodes_get_additional_properties_false(self): + """Every object node below the root carries additionalProperties: false. + + OpenAI strict structured outputs reject object nodes without it; chat + clients only inject it at the schema root. + """ + schema = PropertySchema.from_dict({ + "properties": { + "meta": { + "kind": "object", + "properties": { + "inner": {"kind": "object", "properties": {"leaf": {"kind": "string"}}}, + }, + }, + }, + }) + + json_schema = schema.to_json_schema() + + meta = json_schema["properties"]["meta"] + assert meta["additionalProperties"] is False + assert meta["properties"]["inner"]["additionalProperties"] is False def test_property_schema_unexpected_nested_properties_left_untouched(self): """A nested properties list with an unexpected element is left fully unmodified.""" diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 8cc7fb737ab..0497d3ddf0f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -5632,6 +5632,54 @@ async def test_integration_options( assert "seattle" in response.value["location"].lower() +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_openai_integration_tests_disabled +async def test_integration_response_format_nested_object_schema() -> None: + """A raw response_format dict with array-of-object items must round-trip in strict mode. + + The schema literal mirrors what agent_framework_declarative's + PropertySchema.to_json_schema() emits for an array-of-objects output schema, + so this package needs no declarative dependency. OpenAI strict mode requires + additionalProperties: false on every object node, not just the root. + """ + response_format: dict[str, Any] = { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "severity": {"type": "string"}, + }, + "required": ["title", "severity"], + "additionalProperties": False, + }, + }, + }, + "required": ["issues"], + } + client = OpenAIChatClient() + messages = [ + Message( + role="user", + contents=["List two code issues: a null pointer in parser.py (high) and a typo in README.md (low)."], + ) + ] + response = await client.get_response(messages=messages, options={"response_format": response_format}) + + assert response.value is not None + assert isinstance(response.value, dict) + issues = response.value["issues"] + assert isinstance(issues, list) + assert issues + for issue in issues: + assert "title" in issue + assert "severity" in issue + + @pytest.mark.timeout(300) @pytest.mark.flaky @pytest.mark.integration