Files
hermes-agent/agent/gemini_schema.py
Teknium bbcf1ee180 fix: preserve native Gemini union constraints
Complete the type-array normalization salvaged from #55643: stringify mixed
union enum metadata, preserve existing anyOf constraints, and keep array
items and object properties/required on the corresponding typed branches.

Exercise real native request serialization over loopback and Google SDK
validation with a scalar control; no live Google credentials were available.
2026-09-07 21:10:50 -07:00

115 lines
5.2 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
from tools.schema_sanitizer import _normalize_type_array
# Gemini's ``FunctionDeclaration.parameters`` accepts only a subset of OpenAPI 3.0 /
# JSON Schema (the ``Schema`` object); everything else is stripped.
_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",
}
_GEMINI_STRUCTURAL_KEYS = {
"array": {"items", "minItems", "maxItems"},
"object": {"properties", "required", "minProperties", "maxProperties", "propertyOrdering"},
}
def _stringify_enum_value(item: Any) -> Any:
"""Gemini-safe string for a scalar enum entry, or None to drop it."""
if isinstance(item, bool):
return "true" if item else "false"
if isinstance(item, (int, float)) and math.isfinite(item):
return str(item)
return item if isinstance(item, str) else None
def _normalize_gemini_type_array(type_array: list, cleaned: Dict[str, Any]) -> None:
"""Keep union alternatives and their branch-local structural constraints."""
derived: Dict[str, Any] = {}
_normalize_type_array(type_array, derived)
if "anyOf" in derived:
constraints = {"anyOf": cleaned["anyOf"]} if "anyOf" in cleaned else {}
# Gemini requires items/properties on the typed branch itself, not
# its typeless parent. Keep required paired with those properties.
structural = {key: cleaned.pop(key) for keys in _GEMINI_STRUCTURAL_KEYS.values()
for key in keys if key in cleaned}
cleaned["anyOf"] = [
sanitize_gemini_schema({**branch, **constraints, **{
key: value for key, value in structural.items()
if key in _GEMINI_STRUCTURAL_KEYS.get(branch["type"], ())
}}) for branch in derived["anyOf"]
]
else:
cleaned["type"] = derived["type"]
if derived.get("nullable"):
# Derived from "null" in the array. Set AFTER the loop so it beats an input
# ``nullable: false`` regardless of which key the producer emitted first.
cleaned["nullable"] = True
def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
"""Gemini-compatible copy of a tool parameter schema: keeps only the documented subset
(drops e.g. ``$schema`` / ``additionalProperties``) and recursively sanitizes nested
``properties`` / ``items`` / ``anyOf``."""
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 isinstance(value, dict):
cleaned[key] = {name: sanitize_gemini_schema(sub) for name, sub in value.items() if isinstance(name, str)}
elif key == "items":
cleaned[key] = sanitize_gemini_schema(value)
elif key == "anyOf":
if isinstance(value, list):
cleaned[key] = [sanitize_gemini_schema(item) for item in value if isinstance(item, dict)]
else:
cleaned[key] = value
type_array = cleaned.get("type")
if isinstance(type_array, list):
cleaned.pop("type")
_normalize_gemini_type_array(type_array, cleaned)
# Gemini requires every ``enum`` entry to be a string even for
# integer/number/boolean types; the declared type stays intact and Gemini
# still emits typed tool arguments at runtime. dict.fromkeys = ordered dedupe.
enum_val = cleaned.get("enum")
if isinstance(enum_val, list) and (
isinstance(type_array, list) or cleaned.get("type") in {"integer", "number", "boolean"}
):
if stringified := list(dict.fromkeys(v for v in map(_stringify_enum_value, enum_val) if v is not None)):
cleaned["enum"] = stringified
else:
cleaned.pop("enum", None)
# Gemini validates ``required`` strictly against the same node's ``properties`` (HTTP 400
# "property is not defined") and one bad tool schema fails the ENTIRE request. MCP servers
# routinely emit ``required`` without ``properties``, so keep only names that exist here;
# the tool handler still validates required fields at execution time.
required_val = cleaned.get("required")
if isinstance(required_val, list):
props_val = cleaned.get("properties")
prop_names = set(props_val) 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."""
return sanitize_gemini_schema(parameters) or {"type": "object", "properties": {}}