diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index 904c99d31..3608837a1 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -73,6 +73,20 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: ] continue cleaned[key] = value + + # Gemini's Schema validator requires every ``enum`` entry to be a string, + # even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``. + # OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's + # ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``), + # so we only drop the ``enum`` when it would collide with Gemini's rule. + # Keeping ``type: integer`` plus the human-readable description gives the + # model enough guidance; the tool handler still validates the value. + enum_val = cleaned.get("enum") + type_val = cleaned.get("type") + if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}: + if any(not isinstance(item, str) for item in enum_val): + cleaned.pop("enum", None) + return cleaned diff --git a/tests/agent/test_gemini_schema.py b/tests/agent/test_gemini_schema.py new file mode 100644 index 000000000..069c99a21 --- /dev/null +++ b/tests/agent/test_gemini_schema.py @@ -0,0 +1,140 @@ +"""Tests for agent.gemini_schema — OpenAI→Gemini tool parameter translation.""" + +from agent.gemini_schema import ( + sanitize_gemini_schema, + sanitize_gemini_tool_parameters, +) + + +class TestSanitizeGeminiSchema: + def test_strips_unknown_top_level_keys(self): + """$schema / additionalProperties etc. must not reach Gemini.""" + schema = { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": False, + "properties": {"foo": {"type": "string"}}, + } + cleaned = sanitize_gemini_schema(schema) + assert "$schema" not in cleaned + assert "additionalProperties" not in cleaned + assert cleaned["type"] == "object" + assert cleaned["properties"] == {"foo": {"type": "string"}} + + def test_preserves_string_enums(self): + """String-valued enums are valid for Gemini and must pass through.""" + schema = {"type": "string", "enum": ["pending", "done", "cancelled"]} + cleaned = sanitize_gemini_schema(schema) + assert cleaned["type"] == "string" + assert cleaned["enum"] == ["pending", "done", "cancelled"] + + def test_drops_integer_enum_to_satisfy_gemini(self): + """Gemini rejects int-typed enums; the sanitizer must drop the enum. + + Regression for the Discord tool's ``auto_archive_duration``: + ``{type: integer, enum: [60, 1440, 4320, 10080]}`` caused + Gemini HTTP 400 INVALID_ARGUMENT + "Invalid value ... (TYPE_STRING), 60" on every request that + shipped the full tool catalog to generativelanguage.googleapis.com. + """ + schema = { + "type": "integer", + "enum": [60, 1440, 4320, 10080], + "description": "Minutes (60, 1440, 4320, 10080).", + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["type"] == "integer" + assert "enum" not in cleaned + # description must survive so the model still sees the allowed values + assert cleaned["description"].startswith("Minutes") + + def test_drops_number_enum(self): + """Same rule applies to ``type: number``.""" + schema = {"type": "number", "enum": [0.5, 1.0, 2.0]} + cleaned = sanitize_gemini_schema(schema) + assert cleaned["type"] == "number" + assert "enum" not in cleaned + + def test_drops_boolean_enum(self): + """And to ``type: boolean`` (Gemini rejects non-string entries).""" + schema = {"type": "boolean", "enum": [True, False]} + cleaned = sanitize_gemini_schema(schema) + assert cleaned["type"] == "boolean" + assert "enum" not in cleaned + + def test_keeps_string_enum_even_when_numeric_values_coexist_as_strings(self): + """Stringified-numeric enums ARE valid for Gemini; don't drop them.""" + schema = {"type": "string", "enum": ["60", "1440", "4320", "10080"]} + cleaned = sanitize_gemini_schema(schema) + assert cleaned["enum"] == ["60", "1440", "4320", "10080"] + + def test_drops_nested_integer_enum_inside_properties(self): + """The fix must apply recursively — the Discord case is nested.""" + schema = { + "type": "object", + "properties": { + "auto_archive_duration": { + "type": "integer", + "enum": [60, 1440, 4320, 10080], + "description": "Thread archive duration in minutes.", + }, + "status": { + "type": "string", + "enum": ["active", "archived"], + }, + }, + } + cleaned = sanitize_gemini_schema(schema) + props = cleaned["properties"] + # Integer enum is dropped... + assert props["auto_archive_duration"]["type"] == "integer" + assert "enum" not in props["auto_archive_duration"] + # ...but the sibling string enum is preserved. + assert props["status"]["enum"] == ["active", "archived"] + + def test_drops_integer_enum_inside_array_items(self): + """Array item schemas recurse through ``items``.""" + schema = { + "type": "array", + "items": {"type": "integer", "enum": [1, 2, 3]}, + } + cleaned = sanitize_gemini_schema(schema) + assert cleaned["items"]["type"] == "integer" + assert "enum" not in cleaned["items"] + + def test_non_dict_input_returns_empty(self): + assert sanitize_gemini_schema(None) == {} + assert sanitize_gemini_schema("not a schema") == {} + assert sanitize_gemini_schema([1, 2, 3]) == {} + + +class TestSanitizeGeminiToolParameters: + def test_empty_parameters_return_valid_object_schema(self): + """Gemini requires ``parameters`` to be a valid object schema.""" + cleaned = sanitize_gemini_tool_parameters({}) + assert cleaned == {"type": "object", "properties": {}} + + def test_discord_create_thread_parameters_no_longer_trip_gemini(self): + """End-to-end regression: the exact shape that was rejected in prod.""" + params = { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["create_thread"]}, + "auto_archive_duration": { + "type": "integer", + "enum": [60, 1440, 4320, 10080], + "description": "Thread archive duration in minutes " + "(create_thread, default 1440).", + }, + }, + "required": ["action"], + } + cleaned = sanitize_gemini_tool_parameters(params) + aad = cleaned["properties"]["auto_archive_duration"] + # The field that triggered the Gemini 400 is gone. + assert "enum" not in aad + # Type + description survive so the model still knows what to send. + assert aad["type"] == "integer" + assert "1440" in aad["description"] + # And the string-enum sibling is untouched. + assert cleaned["properties"]["action"]["enum"] == ["create_thread"]