Skip to content
Open
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
34 changes: 30 additions & 4 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 @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
59 changes: 58 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 Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────────────────────


Expand Down