diff --git a/src/agents/agent_tool_input.py b/src/agents/agent_tool_input.py index 19a81e62e6..c744be5476 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,42 @@ 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`. + + 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_label: 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 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_label is None or not has_null: + return None + return f"{base_label} | 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..fed9719d8b 100644 --- a/tests/test_agent_tool_input.py +++ b/tests/test_agent_tool_input.py @@ -125,3 +125,68 @@ 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 + ) + + +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