diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcef74..da34328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -432,6 +432,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that said `responses`. The Chat Completions path the rewrite lifted out and re-keyed is still every `gpt-5` run and every local server, and it now has the guard it never had. +- **Gemini 3 as the pilot.** Two 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, and `move` and + `go_to` are core verbs that both do, so this was every robot, not some of them — and + google-genai 2.x validates the declaration and refuses the keyword; the executor still + enforces the bound on the way in. 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. Both are on the default path: the catalogue's first Gemini entry + is a Gemini 3 model. 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 d12dfe3..19a6b24 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 @@ -23,7 +24,17 @@ ) from quackd.agent.providers.catalogue import default_model_for -UNSUPPORTED_SCHEMA_KEYS = {"additionalProperties", "title", "default", "$schema", "$id"} +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 +90,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 +118,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 724e042..6b74118 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 @@ -14,10 +15,16 @@ from quackd.agent.providers.base import Decision, Exchange, Observation, ProviderError, ToolCall from quackd.agent.providers.catalogue import default_model_for, find_model from quackd.agent.providers.factory import make_provider -from quackd.agent.providers.gemini import GeminiProvider, clean_schema, render_contents +from quackd.agent.providers.gemini import ( + UNSUPPORTED_SCHEMA_KEYS, + GeminiProvider, + clean_schema, + render_contents, +) from quackd.agent.providers.grok import GrokProvider from quackd.agent.providers.openai import OpenAIProvider from quackd.agent.providers.openai import render_messages as o_messages +from quackd.verbs.registry import default_registry PNG = b"\x89PNG\r\n\x1a\nfake" TOOLS = [ @@ -477,6 +484,80 @@ 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} + + +def test_gemini_cleans_the_real_verbs_not_only_a_written_one() -> None: + """The test above proves `clean_schema` works on a schema written here. This one proves it + on the schemas quackd actually sends, which is where the bug was: `move` and `go_to` are + core verbs, both bound with `gt=0`, so the 400 was every robot rather than some of them. + + The first assertion is the one that matters. Without it this test passes for the wrong + reason the day no verb carries a bound any more, and stops guarding the agreement between + `quackd/verbs/core.py` and `UNSUPPORTED_SCHEMA_KEYS` that it exists to guard. + """ + schemas = default_registry().tool_schemas() + carriers = [t["name"] for t in schemas if "exclusiveM" in json.dumps(t)] + assert carriers, "no verb carries a bound any more — this test now proves nothing, fix it" + + cleaned = json.dumps([clean_schema(t) for t in schemas]) + for key in UNSUPPORTED_SCHEMA_KEYS: + assert key not in cleaned, f"{key} survived clean_schema and google-genai will refuse it" + + +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 ─────────────────────────────────────────────────────────────────────────────