diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index 7d5385063ec0..b0985422fbb1 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -87,6 +87,30 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: if any(not isinstance(item, str) for item in enum_val): cleaned.pop("enum", None) + # Gemini validates ``required`` strictly against the same node's + # ``properties`` — GenerateContentRequest fails with HTTP 400 + # "...items.required[0]: property is not defined" when a required name + # has no matching property in that node. MCP servers routinely emit + # this shape (e.g. the GitHub remote MCP's array item schemas carry + # ``required`` without ``properties``), and one bad tool schema fails + # the ENTIRE request before any model output. Filter ``required`` to + # names that exist in this node's ``properties`` and drop it when + # nothing valid remains. The tool handler still validates required + # fields at execution time, so this only removes what Gemini couldn't + # accept anyway. (Port of Kilo-Org/kilocode#11955.) + required_val = cleaned.get("required") + if isinstance(required_val, list): + props_val = cleaned.get("properties") + prop_names = set(props_val.keys()) if isinstance(props_val, dict) else set() + valid_required = [ + name for name in required_val + if isinstance(name, str) and name in prop_names + ] + if not valid_required: + cleaned.pop("required", None) + elif len(valid_required) != len(required_val): + cleaned["required"] = valid_required + return cleaned diff --git a/tests/agent/test_gemini_schema.py b/tests/agent/test_gemini_schema.py index 069c99a21809..88bd163eb8d2 100644 --- a/tests/agent/test_gemini_schema.py +++ b/tests/agent/test_gemini_schema.py @@ -108,6 +108,93 @@ class TestSanitizeGeminiSchema: assert sanitize_gemini_schema([1, 2, 3]) == {} +class TestRequiredPropertyPruning: + """Gemini rejects ``required`` names missing from the node's ``properties``. + + Regression for the Kilo-Org/kilocode#11955 bug class: MCP servers (e.g. + the GitHub remote MCP) emit array item schemas whose ``required`` lists + reference properties that don't exist in the same node — Google fails the + entire GenerateContentRequest with HTTP 400 "property is not defined". + """ + + def test_drops_required_when_node_has_no_properties(self): + schema = {"type": "object", "required": ["a", "b"]} + cleaned = sanitize_gemini_schema(schema) + assert "required" not in cleaned + + def test_filters_ghost_required_entries(self): + schema = { + "type": "object", + "properties": {"x": {"type": "string"}}, + "required": ["x", "ghost"], + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["required"] == ["x"] + + def test_prunes_inside_array_items(self): + """The exact shape from the GitHub MCP report — nested in items.""" + schema = { + "type": "object", + "properties": { + "issue_fields": { + "type": "array", + "items": { + "type": "object", + "required": ["field_id", "value"], + }, + }, + }, + "required": ["issue_fields"], + } + cleaned = sanitize_gemini_schema(schema) + items = cleaned["properties"]["issue_fields"]["items"] + assert "required" not in items + # Top-level required is valid and survives. + assert cleaned["required"] == ["issue_fields"] + + def test_prunes_node_without_explicit_type(self): + """Nodes carrying properties+required but no ``type`` key still prune.""" + schema = { + "properties": {"x": {"type": "string"}}, + "required": ["x", "ghost"], + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["required"] == ["x"] + + def test_valid_required_untouched(self): + schema = { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["required"] == ["a", "b"] + + def test_drops_non_string_required_entries(self): + schema = { + "type": "object", + "properties": {"a": {"type": "string"}}, + "required": ["a", 42, None], + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["required"] == ["a"] + + def test_prunes_inside_anyof_branches(self): + schema = { + "anyOf": [ + { + "type": "object", + "properties": {"x": {"type": "string"}}, + "required": ["x", "ghost"], + }, + {"type": "object", "required": ["orphan"]}, + ] + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["anyOf"][0]["required"] == ["x"] + assert "required" not in cleaned["anyOf"][1] + + class TestSanitizeGeminiToolParameters: def test_empty_parameters_return_valid_object_schema(self): """Gemini requires ``parameters`` to be a valid object schema."""