From e5705733e73f3f9f59d8bd74763a66a8d98bdaad Mon Sep 17 00:00:00 2001 From: Massimiliano Fiori Date: Fri, 11 Sep 2026 10:13:39 +0200 Subject: [PATCH 1/3] fix(gemini): a current Gemini model as the pilot Three faults between the provider and Gemini 3, 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, answers 'no longer available to new users'. The default is now the gemini-pro-latest alias, on purpose: a default that 404s is worse than one that moves. Gemini 3 signs each function call with a thought_signature that the next turn must hand back on that same call, or the request 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 function_call part. Verified with a two-step run to success on gemini-3.5-flash with thought summaries on; ruff, mypy and the full suite are green. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 ++++++++ quackd/agent/providers/base.py | 4 +++ quackd/agent/providers/gemini.py | 34 +++++++++++++++--- tests/test_providers.py | 59 +++++++++++++++++++++++++++++++- 4 files changed, 107 insertions(+), 5 deletions(-) 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 ───────────────────────────────────────────────────────────────────────────── From fb193600f455ea2ac287946eaf0e25e857ecd91a Mon Sep 17 00:00:00 2001 From: Rok Benko Date: Mon, 14 Sep 2026 11:22:18 +0200 Subject: [PATCH 2/3] fix(gemini): the catalogue already chose the model Bayway's third fix pointed the default at the `gemini-pro-latest` alias because `gemini-2.5-pro` had started answering "no longer available to new users". Main solved the same problem differently while the PR was open: ADR-0031 replaced the per-provider constant with the model catalogue, and `default_model_for("gemini")` now returns its first entry. So the constant this branch reintroduced was dead on arrival -- nothing read it, because the constructor takes the catalogue's answer. Removed, with the test assertion put back on `default_model_for("gemini")` rather than a literal, which is what keeps it honest when the catalogue's first entry moves again. The CHANGELOG entry loses that third item and says two. It gains the thing that matters more now: the catalogue's first Gemini entry is a Gemini 3 model, so both remaining fixes are on the default path rather than an opt-in one. The schema bug is every robot, not some -- `move` and `go_to` are core verbs and both carry a `gt=0` bound. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 16 ++++++++-------- quackd/agent/providers/gemini.py | 3 --- tests/test_providers.py | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6451e4d..da34328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -432,17 +432,17 @@ 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.** Three things stood between the provider and a current Gemini +- **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, 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 + `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. Verified with a two-step run to success on `gemini-3.5-flash` + 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 diff --git a/quackd/agent/providers/gemini.py b/quackd/agent/providers/gemini.py index f5ed587..19a6b24 100644 --- a/quackd/agent/providers/gemini.py +++ b/quackd/agent/providers/gemini.py @@ -24,9 +24,6 @@ ) from quackd.agent.providers.catalogue import default_model_for -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", diff --git a/tests/test_providers.py b/tests/test_providers.py index 9ab3b68..472a81b 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -447,7 +447,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-pro-latest" + assert kw["model"] == default_model_for("gemini") assert kw["config"]["system_instruction"] == "SYS" assert kw["config"]["tool_config"] == {"function_calling_config": {"mode": "ANY"}} decl = kw["config"]["tools"][0]["function_declarations"][0] From f3cdd9406f56dcf70025a119a7c813104bc3acf7 Mon Sep 17 00:00:00 2001 From: Rok Benko Date: Mon, 14 Sep 2026 11:24:01 +0200 Subject: [PATCH 3/3] test(gemini): the verbs quackd actually sends, not a schema written here CONTRIBUTING says anything the review found by reading, the next review should find by failing. This one was found by reading: `move` and `go_to` are core verbs, both bound `gt=0`, so pydantic gave both an exclusiveMinimum and Gemini refused the declaration on every robot. The existing test proves `clean_schema` handles a schema written in the test. This one runs `default_registry().tool_schemas()` -- the real fifteen -- and asserts no key in UNSUPPORTED_SCHEMA_KEYS survives. It also asserts that some verb still carries a bound before cleaning, because the day none does this test would pass for the wrong reason and quietly stop guarding the agreement between quackd/verbs/core.py and that set. Checked it fails without the fix: drop the two keys and exclusiveMinimum leaks. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_providers.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_providers.py b/tests/test_providers.py index 472a81b..6b74118 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -15,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 = [ @@ -493,6 +499,24 @@ def test_gemini_drops_the_bounds_google_genai_refuses() -> None: 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