diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py b/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py index b7ca5b58c7..c43cb332b9 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py @@ -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 = {} @@ -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 diff --git a/integrations/adk-middleware/python/tests/test_client_proxy_tool.py b/integrations/adk-middleware/python/tests/test_client_proxy_tool.py index 9a2ee8006b..f7989212bf 100644 --- a/integrations/adk-middleware/python/tests/test_client_proxy_tool.py +++ b/integrations/adk-middleware/python/tests/test_client_proxy_tool.py @@ -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)."""