diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 220ae17f1..fd8fb7d76 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -56,6 +56,8 @@ if TYPE_CHECKING: # that ``unittest.mock.patch`` can find and replace it. AsyncOpenAI: Any = None +_GEMINI_SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator" + def _is_hosted_web_search_type(value: object) -> bool: return isinstance(value, str) and ( @@ -690,6 +692,8 @@ class OpenAICompatProvider(LLMProvider): if strip_reasoning: for msg in sanitized: msg.pop("reasoning_content", None) + if self._spec and self._spec.name == "gemini": + sanitized = self._ensure_gemini_thought_signatures(sanitized) def map_id(value: Any) -> Any: if not isinstance(value, str): @@ -767,6 +771,81 @@ class OpenAICompatProvider(LLMProvider): clean["content"] = self._coerce_content_to_string(clean.get("content")) return self._enforce_role_alternation(sanitized) + @staticmethod + def _gemini_thought_signature(tool_call: dict[str, Any]) -> str | None: + """Return Gemini's thought signature attached to a tool call, if any. + + Gemini's OpenAI-compatible endpoint returns tool calls with an + ``extra_content`` field: ``{"google": {"thought_signature": "..."}}``. + nanobot preserves it through the parse -> serialize round-trip so + replayed calls stay valid. Calls produced by other providers (e.g. + after a mid-conversation model switch) carry no signature. + """ + extra = tool_call.get("extra_content") + if not isinstance(extra, dict): + return None + google = cast(dict[str, Any], extra).get("google") + if not isinstance(google, dict): + return None + signature = cast(dict[str, Any], google).get("thought_signature") + if isinstance(signature, str) and signature: + return signature + return None + + def _ensure_gemini_thought_signatures( + self, messages: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Keep migrated tool history wire-valid without losing tool context. + + Gemini requires the first call in each function-call step to carry a + thought signature. Native parallel calls intentionally leave later + calls unsigned, so they must remain in their original order. For a + fully unsigned step imported from another provider, Google documents + ``skip_thought_signature_validator`` as a last-resort migration value. + """ + kept: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + calls = msg.get("tool_calls") + if role != "assistant" or not isinstance(calls, list) or not calls: + kept.append(msg) + continue + + call_values = cast(list[object], calls) + typed_calls = [ + cast(dict[str, Any], tool_call) + for tool_call in call_values + if isinstance(tool_call, dict) + ] + if not typed_calls: + if msg.get("content"): + clean = dict(msg) + clean.pop("tool_calls", None) + kept.append(clean) + continue + + clean_calls = typed_calls + if self._gemini_thought_signature(typed_calls[0]) is None: + first = dict(typed_calls[0]) + extra_value = first.get("extra_content") + extra = dict(cast(dict[str, Any], extra_value)) if isinstance( + extra_value, dict + ) else {} + google_value = extra.get("google") + google = dict(cast(dict[str, Any], google_value)) if isinstance( + google_value, dict + ) else {} + google["thought_signature"] = _GEMINI_SKIP_THOUGHT_SIGNATURE + extra["google"] = google + first["extra_content"] = extra + clean_calls = [first, *typed_calls[1:]] + + if clean_calls != call_values: + msg = dict(msg) + msg["tool_calls"] = clean_calls + kept.append(msg) + return kept + # ------------------------------------------------------------------ # Build kwargs # ------------------------------------------------------------------ diff --git a/tests/agent/test_gemini_thought_signature.py b/tests/agent/test_gemini_thought_signature.py index 630392521..d1ab8dcba 100644 --- a/tests/agent/test_gemini_thought_signature.py +++ b/tests/agent/test_gemini_thought_signature.py @@ -10,6 +10,7 @@ from unittest.mock import patch from nanobot.providers.base import ToolCallRequest from nanobot.providers.openai_compat_provider import OpenAICompatProvider +from nanobot.providers.registry import ProviderSpec GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}} @@ -243,3 +244,251 @@ def test_stale_extra_content_in_tool_calls_survives_sanitize() -> None: sanitized = provider._sanitize_messages(messages) assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA + + +# ── Replay to Gemini: preserve or backfill thought signatures ───────── + +def _gemini_provider() -> OpenAICompatProvider: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + return OpenAICompatProvider( + spec=ProviderSpec( + name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY" + ) + ) + + +def _tool_call(tc_id: str, name: str, *, signed: bool = False) -> dict: + tc: dict = { + "id": tc_id, + "type": "function", + "function": {"name": name, "arguments": "{}"}, + } + if signed: + tc["extra_content"] = GEMINI_EXTRA + return tc + + +def test_gemini_backfills_unsigned_tool_calls_and_keeps_results() -> None: + """Cross-provider history stays intact and receives the documented fallback.""" + provider = _gemini_provider() + messages = [ + {"role": "user", "content": "check the sensor"}, + { + "role": "assistant", + "content": "On it.", + "tool_calls": [_tool_call("default_api:exec", "exec")], + }, + {"role": "tool", "content": "done", "tool_call_id": "default_api:exec"}, + {"role": "user", "content": "thanks"}, + ] + + sanitized = provider._sanitize_messages(messages) + + assert [m["role"] for m in sanitized] == ["user", "assistant", "tool", "user"] + call = sanitized[1]["tool_calls"][0] + assert call["extra_content"]["google"]["thought_signature"] == ( + "skip_thought_signature_validator" + ) + assert sanitized[2]["tool_call_id"] == call["id"] + assert sanitized[2]["content"] == "done" + + +def test_gemini_preserves_parallel_calls_when_only_first_is_signed() -> None: + """Gemini signs only the first native parallel call; all calls must replay.""" + provider = _gemini_provider() + messages = [ + {"role": "user", "content": "do both"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + _tool_call("call_signed", "read_file", signed=True), + _tool_call("default_api:exec", "exec"), + ], + }, + {"role": "tool", "content": "file contents", "tool_call_id": "call_signed"}, + {"role": "tool", "content": "done", "tool_call_id": "default_api:exec"}, + {"role": "user", "content": "thanks"}, + ] + + sanitized = provider._sanitize_messages(messages) + + assert [m["role"] for m in sanitized] == [ + "user", + "assistant", + "tool", + "tool", + "user", + ] + calls = sanitized[1]["tool_calls"] + assert len(calls) == 2 + assert calls[0]["extra_content"] == GEMINI_EXTRA + assert sanitized[2]["tool_call_id"] == calls[0]["id"] + assert sanitized[2]["content"] == "file contents" + assert "extra_content" not in calls[1] + assert sanitized[3]["tool_call_id"] == calls[1]["id"] + assert sanitized[3]["content"] == "done" + + +def test_gemini_backfills_only_first_cross_provider_parallel_call() -> None: + provider = _gemini_provider() + messages = [ + {"role": "user", "content": "do both"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + _tool_call("call_1", "read_file"), + _tool_call("call_2", "exec"), + ], + }, + {"role": "tool", "content": "file contents", "tool_call_id": "call_1"}, + {"role": "tool", "content": "done", "tool_call_id": "call_2"}, + ] + + sanitized = provider._sanitize_messages(messages) + + calls = sanitized[1]["tool_calls"] + assert len(calls) == 2 + assert calls[0]["extra_content"]["google"]["thought_signature"] == ( + "skip_thought_signature_validator" + ) + assert "extra_content" not in calls[1] + assert [message["content"] for message in sanitized[2:]] == ["file contents", "done"] + + +def test_gemini_requires_signature_on_first_parallel_call() -> None: + provider = _gemini_provider() + messages = [ + {"role": "user", "content": "do both"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + _tool_call("call_1", "read_file"), + _tool_call("call_2", "exec", signed=True), + ], + }, + {"role": "tool", "content": "contents", "tool_call_id": "call_1"}, + {"role": "tool", "content": "done", "tool_call_id": "call_2"}, + ] + + sanitized = provider._sanitize_messages(messages) + + calls = sanitized[1]["tool_calls"] + assert calls[0]["extra_content"]["google"]["thought_signature"] == ( + "skip_thought_signature_validator" + ) + assert calls[1]["extra_content"] == GEMINI_EXTRA + + +def test_gemini_replay_preserves_signed_tool_calls() -> None: + """A pure Gemini-origin history replays unchanged (signature intact).""" + provider = _gemini_provider() + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [_tool_call("call_1", "get_weather", signed=True)], + }, + {"role": "tool", "content": "sunny", "tool_call_id": "call_1"}, + {"role": "user", "content": "thanks"}, + ] + + sanitized = provider._sanitize_messages(messages) + + assert [m["role"] for m in sanitized] == ["user", "assistant", "tool", "user"] + calls = sanitized[1]["tool_calls"] + assert len(calls) == 1 + assert calls[0]["extra_content"] == GEMINI_EXTRA + assert sanitized[2]["tool_call_id"] == calls[0]["id"] + + +def test_non_gemini_provider_keeps_unsigned_tool_calls() -> None: + """The filter is Gemini-scoped: other providers still replay unsigned calls.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [_tool_call("default_api:exec", "exec")], + }, + {"role": "tool", "content": "done", "tool_call_id": "default_api:exec"}, + {"role": "user", "content": "thanks"}, + ] + + sanitized = provider._sanitize_messages(messages) + + assert len(sanitized[1]["tool_calls"]) == 1 + assert sanitized[2]["role"] == "tool" + assert sanitized[2]["tool_call_id"] == sanitized[1]["tool_calls"][0]["id"] + + +def test_gemini_drops_malformed_tool_call_entries_without_crashing() -> None: + provider = _gemini_provider() + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "tool_calls": [None]}, + {"role": "user", "content": "continue"}, + ] + + sanitized = provider._sanitize_messages(messages) + + assert not any(message.get("tool_calls") for message in sanitized) + + +def test_gemini_matches_duplicate_tool_ids_by_call_instance() -> None: + provider = _gemini_provider() + messages = [ + {"role": "user", "content": "old request"}, + { + "role": "assistant", + "content": None, + "tool_calls": [_tool_call("reused", "old_tool")], + }, + {"role": "tool", "content": "old result", "tool_call_id": "reused"}, + {"role": "user", "content": "new request"}, + { + "role": "assistant", + "content": None, + "tool_calls": [_tool_call("reused", "new_tool", signed=True)], + }, + {"role": "tool", "content": "new result", "tool_call_id": "reused"}, + ] + + sanitized = provider._sanitize_messages(messages) + + assert any(message.get("content") == "old result" for message in sanitized) + assert any(message.get("content") == "new result" for message in sanitized) + calls = [ + call + for message in sanitized + for call in message.get("tool_calls", []) + ] + assert len(calls) == 2 + assert calls[0]["function"]["name"] == "old_tool" + assert calls[0]["extra_content"]["google"]["thought_signature"] == ( + "skip_thought_signature_validator" + ) + assert calls[1]["function"]["name"] == "new_tool" + + +def test_gemini_backfill_does_not_mutate_caller_history() -> None: + provider = _gemini_provider() + call = _tool_call("call_1", "read_file") + messages = [ + {"role": "user", "content": "read it"}, + {"role": "assistant", "content": None, "tool_calls": [call]}, + {"role": "tool", "content": "contents", "tool_call_id": "call_1"}, + ] + + sanitized = provider._sanitize_messages(messages) + + assert "extra_content" not in call + assert sanitized[1]["tool_calls"][0]["extra_content"]["google"][ + "thought_signature" + ] == "skip_thought_signature_validator"