Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions quackd/agent/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
30 changes: 27 additions & 3 deletions quackd/agent/providers/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import base64
import os
from typing import Any

Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
83 changes: 82 additions & 1 deletion tests/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import base64
import json
from collections.abc import Callable
from types import SimpleNamespace as NS
Expand All @@ -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 = [
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────────────────────


Expand Down