diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py index ed687bffd6..093ef23921 100644 --- a/agent/gemini_cloudcode_adapter.py +++ b/agent/gemini_cloudcode_adapter.py @@ -39,6 +39,7 @@ from typing import Any, Dict, Iterator, List, Optional import httpx from agent import google_oauth +from agent.gemini_schema import sanitize_gemini_tool_parameters from agent.google_code_assist import ( CODE_ASSIST_ENDPOINT, FREE_TIER_ID, @@ -205,7 +206,7 @@ def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]: decl["description"] = str(fn["description"]) params = fn.get("parameters") if isinstance(params, dict): - decl["parameters"] = params + decl["parameters"] = sanitize_gemini_tool_parameters(params) declarations.append(decl) if not declarations: return [] diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 72fba8f294..8418cec987 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -27,6 +27,8 @@ from typing import Any, Dict, Iterator, List, Optional import httpx +from agent.gemini_schema import sanitize_gemini_tool_parameters + logger = logging.getLogger(__name__) DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" @@ -253,7 +255,7 @@ def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]: decl["description"] = description parameters = fn.get("parameters") if isinstance(parameters, dict): - decl["parameters"] = parameters + decl["parameters"] = sanitize_gemini_tool_parameters(parameters) declarations.append(decl) return [{"functionDeclarations": declarations}] if declarations else [] diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py new file mode 100644 index 0000000000..904c99d31b --- /dev/null +++ b/agent/gemini_schema.py @@ -0,0 +1,85 @@ +"""Helpers for translating OpenAI-style tool schemas to Gemini's schema subset.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +# 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 + 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 diff --git a/tests/agent/test_gemini_cloudcode.py b/tests/agent/test_gemini_cloudcode.py index c9d2b87df8..4b382c8c06 100644 --- a/tests/agent/test_gemini_cloudcode.py +++ b/tests/agent/test_gemini_cloudcode.py @@ -652,6 +652,42 @@ class TestBuildGeminiRequest: assert decls[0]["description"] == "foo" assert decls[0]["parameters"] == {"type": "object"} + def test_tools_strip_json_schema_only_fields_from_parameters(self): + from agent.gemini_cloudcode_adapter import build_gemini_request + + req = build_gemini_request( + messages=[{"role": "user", "content": "hi"}], + tools=[ + {"type": "function", "function": { + "name": "fn1", + "description": "foo", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": False, + "properties": { + "city": { + "type": "string", + "$schema": "ignored", + "description": "City name", + "additionalProperties": False, + } + }, + "required": ["city"], + }, + }}, + ], + ) + params = req["tools"][0]["functionDeclarations"][0]["parameters"] + assert "$schema" not in params + assert "additionalProperties" not in params + assert params["type"] == "object" + assert params["required"] == ["city"] + assert params["properties"]["city"] == { + "type": "string", + "description": "City name", + } + def test_tool_choice_auto(self): from agent.gemini_cloudcode_adapter import build_gemini_request diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index 0141c74104..a36b1e71c1 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -85,6 +85,46 @@ def test_build_native_request_uses_original_function_name_for_tool_result(): assert tool_response["name"] == "get_weather" +def test_build_native_request_strips_json_schema_only_fields_from_tool_parameters(): + from agent.gemini_native_adapter import build_gemini_request + + request = build_gemini_request( + messages=[{"role": "user", "content": "Hello"}], + tools=[ + { + "type": "function", + "function": { + "name": "lookup_weather", + "description": "Weather lookup", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": False, + "properties": { + "city": { + "type": "string", + "$schema": "ignored", + "description": "City name", + } + }, + "required": ["city"], + }, + }, + } + ], + tool_choice=None, + ) + + params = request["tools"][0]["functionDeclarations"][0]["parameters"] + assert "$schema" not in params + assert "additionalProperties" not in params + assert params["type"] == "object" + assert params["properties"]["city"] == { + "type": "string", + "description": "City name", + } + + def test_translate_native_response_surfaces_reasoning_and_tool_calls(): from agent.gemini_native_adapter import translate_gemini_response