mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-13 15:49:16 +03:00
fix(gemini): preserve imported tool history across model switches
Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Xubin Ren
parent
d3382d7e57
commit
0c684c5a99
@@ -56,6 +56,8 @@ if TYPE_CHECKING:
|
|||||||
# that ``unittest.mock.patch`` can find and replace it.
|
# that ``unittest.mock.patch`` can find and replace it.
|
||||||
AsyncOpenAI: Any = None
|
AsyncOpenAI: Any = None
|
||||||
|
|
||||||
|
_GEMINI_SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
||||||
|
|
||||||
|
|
||||||
def _is_hosted_web_search_type(value: object) -> bool:
|
def _is_hosted_web_search_type(value: object) -> bool:
|
||||||
return isinstance(value, str) and (
|
return isinstance(value, str) and (
|
||||||
@@ -690,6 +692,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if strip_reasoning:
|
if strip_reasoning:
|
||||||
for msg in sanitized:
|
for msg in sanitized:
|
||||||
msg.pop("reasoning_content", None)
|
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:
|
def map_id(value: Any) -> Any:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
@@ -767,6 +771,81 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
||||||
return self._enforce_role_alternation(sanitized)
|
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
|
# Build kwargs
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from nanobot.providers.base import ToolCallRequest
|
from nanobot.providers.base import ToolCallRequest
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
from nanobot.providers.registry import ProviderSpec
|
||||||
|
|
||||||
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
|
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)
|
sanitized = provider._sanitize_messages(messages)
|
||||||
|
|
||||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user