Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,24 @@
})


def _clean_schema_for_genai(schema: Any) -> Any:
def _clean_schema_for_genai(schema: Any, _top_level: bool = True) -> Any:
"""Recursively clean a JSON Schema dict for google.genai.types.Schema.

Three transformations:
Four transformations:
1. Strip ``$``-prefixed keys (``$schema``, ``$id``, ``$ref``, ``$defs``,
``$comment``) — these are JSON Schema infrastructure, never in genai.
2. Map ``examples`` → ``example`` (first element only) and
``const`` → ``enum`` (single-value list), preserving useful context.
3. Filter remaining keys to only those accepted by ``types.Schema``,
using an allowlist derived from ``types.Schema.model_fields``.
4. Drop ``required`` from any sub-schema (anything that is not the
outermost parameters object). Gemini's function-calling API silently
rejects function declarations whose parameter schema carries
``required`` below the top level — e.g. on the ``items.properties``
of an array, or on a nested property's own ``properties``. The
model emits no error event and no content events, just
``RUN_STARTED → RUN_FINISHED``, which surfaces as a frozen agent.
Top-level ``required`` is preserved (Gemini accepts it there).
"""
if isinstance(schema, dict):
result = {}
Expand All @@ -74,21 +82,25 @@ def _clean_schema_for_genai(schema: Any) -> Any:
# Only keep keys that genai.types.Schema accepts
if k not in _ALLOWED_SCHEMA_KEYS:
continue
# Drop `required` everywhere except the outermost parameters
# object. See transformation 4 in the docstring.
if k == "required" and not _top_level:
continue
# "properties" and "defs" are dict-of-schemas — recurse into
# values but preserve the user-defined keys (property names).
if k in ("properties", "defs") and isinstance(v, dict):
result[k] = {
prop_name: _clean_schema_for_genai(prop_schema)
prop_name: _clean_schema_for_genai(prop_schema, _top_level=False)
for prop_name, prop_schema in v.items()
}
# "default", "example", "enum" are opaque values — don't recurse
elif k in ("default", "example", "enum"):
result[k] = v
else:
result[k] = _clean_schema_for_genai(v)
result[k] = _clean_schema_for_genai(v, _top_level=False)
return result
if isinstance(schema, list):
return [_clean_schema_for_genai(item) for item in schema]
return [_clean_schema_for_genai(item, _top_level=False) for item in schema]
return schema


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,101 @@ def test_handles_empty_dict(self):
def test_handles_empty_list(self):
assert _clean_schema_for_genai([]) == []

# --- Nested-required stripping (Gemini silent-failure workaround) ---

def test_preserves_top_level_required(self):
"""Top-level `required` is preserved — Gemini accepts it there."""
schema = {
"type": "object",
"properties": {"x": {"type": "string"}, "y": {"type": "number"}},
"required": ["x", "y"],
}
result = _clean_schema_for_genai(schema)
assert result["required"] == ["x", "y"]

def test_strips_required_inside_array_items(self):
"""`required` on object items of an array is dropped.

Gemini's function-calling API silently rejects function declarations
carrying `required` below the top level. This is the most common
shape produced by Zod's `z.array(z.object({...}))` and is the exact
schema the CopilotKit chart-rendering demo (and any similar
array-of-records pattern) uses.
"""
schema = {
"type": "object",
"required": ["data"],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"value": {"type": "number"},
},
"required": ["label", "value"],
},
}
},
}
result = _clean_schema_for_genai(schema)
# top-level required preserved
assert result["required"] == ["data"]
# nested required stripped
assert "required" not in result["properties"]["data"]["items"]
# but the nested properties themselves remain intact
items_props = result["properties"]["data"]["items"]["properties"]
assert items_props["label"]["type"] == "string"
assert items_props["value"]["type"] == "number"

def test_strips_required_inside_nested_object_property(self):
"""`required` on a nested object property is dropped."""
schema = {
"type": "object",
"required": ["address"],
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"},
},
"required": ["street", "city"],
}
},
}
result = _clean_schema_for_genai(schema)
assert result["required"] == ["address"]
assert "required" not in result["properties"]["address"]

def test_strips_required_at_arbitrary_depth(self):
"""`required` is stripped no matter how deep it's nested."""
schema = {
"type": "object",
"required": ["a"],
"properties": {
"a": {
"type": "object",
"properties": {
"b": {
"type": "array",
"items": {
"type": "object",
"properties": {"c": {"type": "string"}},
"required": ["c"],
},
}
},
"required": ["b"],
}
},
}
result = _clean_schema_for_genai(schema)
assert result["required"] == ["a"]
assert "required" not in result["properties"]["a"]
assert "required" not in result["properties"]["a"]["properties"]["b"]["items"]


class TestGetDeclarationWithJsonSchemaMeta:
"""Test _get_declaration strips JSON Schema meta-fields (issue #1349)."""
Expand Down
Loading