hermes-agent/tests/agent/test_gemini_schema.py
Teknium 1f9c368622
fix(gemini): drop integer/number/boolean enums from tool schemas (#15082)
Gemini's Schema validator requires every `enum` entry to be a string,
even when the parent `type` is integer/number/boolean. Discord's
`auto_archive_duration` parameter (`type: integer, enum: [60, 1440,
4320, 10080]`) tripped this on every request that shipped the full
tool catalog to generativelanguage.googleapis.com, surfacing as
`Gateway: Non-retryable client error: Gemini HTTP 400 (INVALID_ARGUMENT)
Invalid value ... (TYPE_STRING), 60` and aborting the turn.

Sanitize by dropping the `enum` key when the declared type is numeric
or boolean and any entry is non-string. The `type` and `description`
survive, so the model still knows the allowed values; the tool handler
keeps its own runtime validation. Other providers (OpenAI,
OpenRouter, Anthropic) are unaffected — the sanitizer only runs for
native Gemini / cloudcode adapters.

Reported by @selfhostedsoul on Discord with hermes debug share.
2026-04-24 03:40:00 -07:00

140 lines
5.9 KiB
Python

"""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"]