mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
Port from openclaw/openclaw#104567: Gemini requires enum metadata to be strings even when the declared tool parameter type is numeric or boolean.
140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
"""Helpers for translating OpenAI-style tool schemas to Gemini's schema subset."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any, Dict
|
|
|
|
# Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema``
|
|
# object, which is only a subset of OpenAPI 3.0 / JSON Schema. Strip fields
|
|
# outside that subset before sending Hermes tool schemas to Google.
|
|
_GEMINI_SCHEMA_ALLOWED_KEYS = {
|
|
"type",
|
|
"format",
|
|
"title",
|
|
"description",
|
|
"nullable",
|
|
"enum",
|
|
"maxItems",
|
|
"minItems",
|
|
"properties",
|
|
"required",
|
|
"minProperties",
|
|
"maxProperties",
|
|
"minLength",
|
|
"maxLength",
|
|
"pattern",
|
|
"example",
|
|
"anyOf",
|
|
"propertyOrdering",
|
|
"default",
|
|
"items",
|
|
"minimum",
|
|
"maximum",
|
|
}
|
|
|
|
|
|
def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
|
"""Return a Gemini-compatible copy of a tool parameter schema.
|
|
|
|
Hermes tool schemas are OpenAI-flavored JSON Schema and may contain keys
|
|
such as ``$schema`` or ``additionalProperties`` that Google's Gemini
|
|
``Schema`` object rejects. This helper preserves the documented Gemini
|
|
subset and recursively sanitizes nested ``properties`` / ``items`` /
|
|
``anyOf`` definitions.
|
|
"""
|
|
|
|
if not isinstance(schema, dict):
|
|
return {}
|
|
|
|
cleaned: Dict[str, Any] = {}
|
|
for key, value in schema.items():
|
|
if key not in _GEMINI_SCHEMA_ALLOWED_KEYS:
|
|
continue
|
|
if key == "properties":
|
|
if not isinstance(value, dict):
|
|
continue
|
|
props: Dict[str, Any] = {}
|
|
for prop_name, prop_schema in value.items():
|
|
if not isinstance(prop_name, str):
|
|
continue
|
|
props[prop_name] = sanitize_gemini_schema(prop_schema)
|
|
cleaned[key] = props
|
|
continue
|
|
if key == "items":
|
|
cleaned[key] = sanitize_gemini_schema(value)
|
|
continue
|
|
if key == "anyOf":
|
|
if not isinstance(value, list):
|
|
continue
|
|
cleaned[key] = [
|
|
sanitize_gemini_schema(item)
|
|
for item in value
|
|
if isinstance(item, dict)
|
|
]
|
|
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``.
|
|
# Preserve those constraints by stringifying scalar values while keeping
|
|
# the declared type intact; Gemini uses the strings as schema metadata and
|
|
# still emits typed tool arguments at runtime.
|
|
enum_val = cleaned.get("enum")
|
|
type_val = cleaned.get("type")
|
|
if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}:
|
|
stringified = []
|
|
for item in enum_val:
|
|
if isinstance(item, str):
|
|
value = item
|
|
elif isinstance(item, bool):
|
|
value = "true" if item else "false"
|
|
elif (
|
|
isinstance(item, (int, float))
|
|
and not isinstance(item, bool)
|
|
and math.isfinite(item)
|
|
):
|
|
value = str(item)
|
|
else:
|
|
continue
|
|
if value not in stringified:
|
|
stringified.append(value)
|
|
if stringified:
|
|
cleaned["enum"] = stringified
|
|
else:
|
|
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
|
|
|
|
|
|
def sanitize_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
|
"""Normalize tool parameters to a valid Gemini object schema."""
|
|
|
|
cleaned = sanitize_gemini_schema(parameters)
|
|
if not cleaned:
|
|
return {"type": "object", "properties": {}}
|
|
return cleaned
|