diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e42f53..92f6c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Gemini 3 as the pilot.** Three things stood between the provider and a current Gemini + model, found by pointing a duck at `gemini-3.5-flash`. The schema cleaner did not strip + `exclusiveMinimum` / `exclusiveMaximum` — pydantic writes `gt=0` that way, so every verb + with a `timeout_s` or a `duration_s` carried one — and google-genai 2.x validates the + declaration and refuses the keyword; the executor still enforces the bound on the way in. + The default model, `gemini-2.5-pro`, now answers *"no longer available to new users"*; the + default is the `gemini-pro-latest` alias, on purpose, because a default that 404s is worse + than one that moves. And Gemini 3 signs each function call with a `thought_signature` the + next turn must hand back on that same call, or it is refused with a 400; `ToolCall` carries + it as base64 text (`signature`, empty for every other provider) and `render_contents` puts + the bytes back on the part. Verified with a two-step run to success on `gemini-3.5-flash` + with thought summaries on. + ## [0.8.0] — 2026-09-09 Two things, mainly. A run narrates itself now: the system prompt once, then per turn the diff --git a/quackd/agent/providers/base.py b/quackd/agent/providers/base.py index 8871700..978d378 100644 --- a/quackd/agent/providers/base.py +++ b/quackd/agent/providers/base.py @@ -19,6 +19,10 @@ class ToolCall(BaseModel): id: str = "" name: str arguments: dict[str, Any] = Field(default_factory=dict) + signature: str = "" + """Opaque and provider-owned, base64 text. Gemini 3 signs every function call it makes + and refuses the next turn unless the signature is handed back on that same call; other + providers leave it empty and nothing reads it.""" class Usage(BaseModel): diff --git a/quackd/agent/providers/gemini.py b/quackd/agent/providers/gemini.py index 423353c..f9db7cc 100644 --- a/quackd/agent/providers/gemini.py +++ b/quackd/agent/providers/gemini.py @@ -9,6 +9,7 @@ from __future__ import annotations +import base64 import os from typing import Any @@ -22,8 +23,20 @@ Usage, ) -DEFAULT_MODEL = "gemini-2.5-pro" -UNSUPPORTED_SCHEMA_KEYS = {"additionalProperties", "title", "default", "$schema", "$id"} +DEFAULT_MODEL = "gemini-pro-latest" +"""An alias, on purpose: `gemini-2.5-pro` went "no longer available to new users" within +weeks of being the default here, and a default that 404s is worse than one that moves.""" +UNSUPPORTED_SCHEMA_KEYS = { + "additionalProperties", + "title", + "default", + "$schema", + "$id", + # pydantic writes `gt=0` as exclusiveMinimum; google-genai >= 2 validates the schema and + # refuses the keyword outright. The executor still enforces the bound on the way in. + "exclusiveMinimum", + "exclusiveMaximum", +} def clean_schema(schema: Any) -> Any: @@ -79,7 +92,10 @@ def render_contents(history: list[Exchange]) -> list[dict[str, Any]]: model_parts: list[dict[str, Any]] = [] if ex.decision.text: model_parts.append({"text": ex.decision.text}) - model_parts.append({"function_call": {"name": tc.name, "args": tc.arguments}}) + call: dict[str, Any] = {"function_call": {"name": tc.name, "args": tc.arguments}} + if tc.signature: + call["thought_signature"] = base64.b64decode(tc.signature) + model_parts.append(call) contents.append({"role": "model", "parts": model_parts}) return contents @@ -104,7 +120,17 @@ def parse_response(response: Any) -> ProviderTurn: fc = getattr(part, "function_call", None) if fc is not None and getattr(fc, "name", None): args = dict(getattr(fc, "args", None) or {}) - tool_calls.append(ToolCall(id=f"gemini-{i}", name=str(fc.name), arguments=args)) + # Gemini 3 signs the call; the signature rides on the part, not on the call, and + # the next request is refused unless it comes back on the same function_call + sig = getattr(part, "thought_signature", None) + tool_calls.append( + ToolCall( + id=f"gemini-{i}", + name=str(fc.name), + arguments=args, + signature=base64.b64encode(sig).decode() if sig else "", + ) + ) elif getattr(part, "text", None): # a thought part is the model's reasoning, not its answer: kept out of `text`, or # it would be shown as the reply and replayed to the model as something it said diff --git a/tests/test_providers.py b/tests/test_providers.py index a7a8ea2..ec3e5fa 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json from collections.abc import Callable from types import SimpleNamespace as NS @@ -397,7 +398,7 @@ async def test_gemini_request_and_response_mapping() -> None: client = FakeGemini(response) turn = await GeminiProvider(client=client).step("SYS", history(), TOOLS) kw = client.kwargs - assert kw["model"] == "gemini-2.5-pro" + assert kw["model"] == "gemini-pro-latest" assert kw["config"]["system_instruction"] == "SYS" assert kw["config"]["tool_config"] == {"function_calling_config": {"mode": "ANY"}} decl = kw["config"]["tools"][0]["function_declarations"][0] @@ -428,6 +429,62 @@ def test_gemini_first_turn_has_no_function_response() -> None: assert contents[0]["parts"][0] == {"text": "hi"} +def test_gemini_drops_the_bounds_google_genai_refuses() -> None: + """pydantic writes `gt=0` as exclusiveMinimum; google-genai >= 2 validates the schema and + raises on the keyword. Every verb with a timeout_s or a duration_s carries one.""" + schema = { + "type": "object", + "properties": { + "timeout_s": {"type": "number", "exclusiveMinimum": 0, "description": "seconds"}, + "n": {"type": "integer", "exclusiveMaximum": 10, "minimum": 1}, + }, + } + cleaned = clean_schema(schema) + assert cleaned["properties"]["timeout_s"] == {"type": "number", "description": "seconds"} + assert cleaned["properties"]["n"] == {"type": "integer", "minimum": 1} + + +async def test_gemini_hands_the_thought_signature_back() -> None: + """Gemini 3 signs each function call and refuses the next turn without the signature on + that same call. It arrives as bytes on the part; it goes into the transcript as text and + comes back out as bytes.""" + part = NS( + function_call=NS(name="walk", args={"vx": 0.25}), text=None, thought_signature=b"\x01sig" + ) + response = NS( + candidates=[NS(content=NS(parts=[part]), finish_reason="STOP")], + usage_metadata=NS(prompt_token_count=1, candidates_token_count=1), + ) + turn = await GeminiProvider(client=FakeGemini(response)).step("SYS", history(), TOOLS) + (tc,) = turn.tool_calls + assert tc.signature == base64.b64encode(b"\x01sig").decode() + + replay = render_contents( + [ + Exchange( + observation=Observation(text="go"), + decision=Decision(tool_call=tc, text=None), + ), + Exchange(observation=Observation(text="walked", tool_call_id=tc.id)), + ] + ) + call = replay[1]["parts"][-1] + assert call["function_call"] == {"name": "walk", "args": {"vx": 0.25}} + assert call["thought_signature"] == b"\x01sig" + + +def test_gemini_an_unsigned_call_is_replayed_without_a_signature() -> None: + """Gemini 2.x signs nothing, and a part with no signature must not grow an empty one.""" + tc = ToolCall(id="gemini-0", name="walk", arguments={}) + replay = render_contents( + [ + Exchange(observation=Observation(text="go"), decision=Decision(tool_call=tc)), + Exchange(observation=Observation(text="ok", tool_call_id=tc.id)), + ] + ) + assert "thought_signature" not in replay[1]["parts"][-1] + + # ── factory ─────────────────────────────────────────────────────────────────────────────