From d6ae9a108b8612a0c22ac7c58eed0097c7cc6cfa Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 8 May 2026 04:32:33 -0700 Subject: [PATCH 1/2] fix(agent_tool_input): summarize Pydantic Optional[T] anyOf fields The schema summary helper rejected any field with an `anyOf` key, but Pydantic renders `T | None` as `anyOf: [{type: T}, {type: "null"}]`. Because `_summarize_json_schema` returns None for the whole schema when any single field is unsupported, one Optional field would silently suppress the entire structured-input summary - even for sibling fields with valid types and descriptions. Detect the 2-branch `anyOf` of a simple type and `null` and render it as `T | null`, matching the existing `type: ["T", "null"]` path. Other `anyOf` shapes (unions of two non-null types, anyOf with nested objects, etc.) are still rejected. --- src/agents/agent_tool_input.py | 35 +++++++++++++++++++++++++++++++++- tests/test_agent_tool_input.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/agents/agent_tool_input.py b/src/agents/agent_tool_input.py index 19a81e62e6..0c4aa274b1 100644 --- a/src/agents/agent_tool_input.py +++ b/src/agents/agent_tool_input.py @@ -212,10 +212,21 @@ def _describe_json_schema_field( if not isinstance(field_schema, dict): return None - if any(key in field_schema for key in ("properties", "items", "oneOf", "anyOf", "allOf")): + if any(key in field_schema for key in ("properties", "items", "oneOf", "allOf")): return None description = _read_schema_description(field_schema) + + # Pydantic renders `T | None` as `anyOf: [{type: T}, {type: "null"}]`. + # Treat that exact shape as the same as `type: ["T", "null"]` so optional + # simple fields still appear in the schema summary. + any_of = field_schema.get("anyOf") + if any_of is not None: + nullable_label = _describe_nullable_anyof(any_of) + if nullable_label is None: + return None + return _SchemaFieldDescription(type=nullable_label, description=description) + raw_type = field_schema.get("type") if isinstance(raw_type, list): @@ -245,6 +256,28 @@ def _describe_json_schema_field( return None +def _describe_nullable_anyof(any_of: Any) -> str | None: + """Render a 2-branch `anyOf` of a simple type and `null` as `T | null`.""" + if not isinstance(any_of, list) or len(any_of) != 2: + return None + base_type: str | None = None + has_null = False + for entry in any_of: + if not isinstance(entry, dict): + return None + entry_type = entry.get("type") + if entry_type == "null": + has_null = True + continue + if entry_type in _SIMPLE_JSON_SCHEMA_TYPES and base_type is None: + base_type = cast(str, entry_type) + continue + return None + if base_type is None or not has_null: + return None + return f"{base_type} | null" + + def _read_schema_description(value: Any) -> str | None: if not isinstance(value, dict): return None diff --git a/tests/test_agent_tool_input.py b/tests/test_agent_tool_input.py index 93f72efc7b..7cfbb2ef8f 100644 --- a/tests/test_agent_tool_input.py +++ b/tests/test_agent_tool_input.py @@ -125,3 +125,38 @@ def test_private_schema_helper_edge_cases() -> None: assert _format_enum_label([]) == "enum" assert "..." in _format_enum_label([1, 2, 3, 4, 5, 6]) assert _format_literal_label({}) == "literal" + + +def test_schema_summary_handles_pydantic_optional_anyof() -> None: + # Pydantic emits `T | None` as anyOf:[{type:T},{type:"null"}]; without + # support for that shape, a single Optional field nukes the whole summary. + schema = { + "type": "object", + "properties": { + "count": { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "description": "Optional count.", + }, + "name": {"type": "string", "description": "A name."}, + }, + "required": ["name"], + } + summary = _build_schema_summary(schema) + assert summary is not None + assert "- count (integer | null, optional) - Optional count." in summary + assert "- name (string, required) - A name." in summary + + # Non-nullable anyOf shapes (e.g. union of two simple types, or anyOf with + # nested objects) should still be rejected. + assert ( + _build_schema_summary( + { + "type": "object", + "description": "x", + "properties": { + "u": {"anyOf": [{"type": "integer"}, {"type": "string"}]}, + }, + } + ) + is None + ) From 5a990597e38da9603fab73c4747cdcc4ef2a47f0 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 11 May 2026 16:30:51 -0700 Subject: [PATCH 2/2] fix(agent_tool_input): preserve enum/const in nullable anyOf Pydantic emits Optional[Literal[...]] as a 2-branch anyOf where one branch carries both enum/const and type: 'string'. The previous fix collapsed that to 'string | null', hiding the allowed values from the nested-agent schema summary. Prefer the enum/const label when present. --- src/agents/agent_tool_input.py | 26 ++++++++++++++++++++------ tests/test_agent_tool_input.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/agents/agent_tool_input.py b/src/agents/agent_tool_input.py index 0c4aa274b1..c744be5476 100644 --- a/src/agents/agent_tool_input.py +++ b/src/agents/agent_tool_input.py @@ -257,10 +257,14 @@ def _describe_json_schema_field( def _describe_nullable_anyof(any_of: Any) -> str | None: - """Render a 2-branch `anyOf` of a simple type and `null` as `T | null`.""" + """Render a 2-branch `anyOf` of a simple type and `null` as `T | null`. + + Also handles `Optional[Literal[...]]`, which Pydantic emits as an `enum`/`const` + branch (possibly with `type: "string"`) plus a `null` branch. + """ if not isinstance(any_of, list) or len(any_of) != 2: return None - base_type: str | None = None + base_label: str | None = None has_null = False for entry in any_of: if not isinstance(entry, dict): @@ -269,13 +273,23 @@ def _describe_nullable_anyof(any_of: Any) -> str | None: if entry_type == "null": has_null = True continue - if entry_type in _SIMPLE_JSON_SCHEMA_TYPES and base_type is None: - base_type = cast(str, entry_type) + if base_label is not None: + return None + # Prefer `enum`/`const` over a bare `type` so `Optional[Literal[...]]` + # surfaces the allowed values rather than just e.g. `string | null`. + if isinstance(entry.get("enum"), list): + base_label = _format_enum_label(entry.get("enum")) + continue + if "const" in entry: + base_label = _format_literal_label(entry) + continue + if entry_type in _SIMPLE_JSON_SCHEMA_TYPES: + base_label = cast(str, entry_type) continue return None - if base_type is None or not has_null: + if base_label is None or not has_null: return None - return f"{base_type} | null" + return f"{base_label} | null" def _read_schema_description(value: Any) -> str | None: diff --git a/tests/test_agent_tool_input.py b/tests/test_agent_tool_input.py index 7cfbb2ef8f..fed9719d8b 100644 --- a/tests/test_agent_tool_input.py +++ b/tests/test_agent_tool_input.py @@ -160,3 +160,33 @@ def test_schema_summary_handles_pydantic_optional_anyof() -> None: ) is None ) + + +def test_schema_summary_preserves_enum_const_in_nullable_anyof() -> None: + # Pydantic renders `Optional[Literal["a", "b"]]` as an anyOf branch carrying + # both an `enum` (with `type: "string"`) and a `null` branch. Surface the + # allowed values rather than collapsing to `string | null`. + schema = { + "type": "object", + "properties": { + "color": { + "anyOf": [ + {"enum": ["red", "blue"], "type": "string"}, + {"type": "null"}, + ], + "description": "Pick a color.", + }, + "tag": { + "anyOf": [ + {"const": "x", "type": "string"}, + {"type": "null"}, + ], + "description": "A tag.", + }, + }, + "required": ["color", "tag"], + } + summary = _build_schema_summary(schema) + assert summary is not None + assert '- color (enum("red" | "blue") | null, required) - Pick a color.' in summary + assert '- tag (literal("x") | null, required) - A tag.' in summary