Skip to content

Commit a5d6238

Browse files
google-genai-botcopybara-github
authored andcommitted
fix(models): stop a content-free thought signature from wedging a session
A part carrying only a `thought_signature` matched no branch in `_part_to_message_block` and raised NotImplementedError. The part stays in session history, so every later turn raised again and the session was permanently dead. Such a signature comes from another model, which Claude cannot verify, and the part carries no content, so it is now dropped. A turn left with no blocks at all is omitted rather than sent as an empty message, which Anthropic rejects. Parts whose signature rides along with real content are unaffected. PiperOrigin-RevId: 978081482
1 parent ae30506 commit a5d6238

2 files changed

Lines changed: 128 additions & 4 deletions

File tree

‎src/google/adk/models/anthropic_llm.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,34 @@ def _is_pdf_part(part: types.Part) -> bool:
339339
)
340340

341341

342+
# The fields that mean a part carries something to send. The rest of a Part is
343+
# annotation -- `part_metadata`, `video_metadata`, `media_resolution` and the
344+
# like -- and ADK's own A2A converter sets some of them, so asking "is anything
345+
# else set?" would leave the part in place and the session wedged.
346+
_PART_CONTENT_FIELDS = (
347+
"audio_transcription",
348+
"code_execution_result",
349+
"executable_code",
350+
"file_data",
351+
"function_call",
352+
"function_response",
353+
"inline_data",
354+
"tool_call",
355+
"tool_response",
356+
)
357+
358+
359+
def _is_content_free_signature(part: types.Part) -> bool:
360+
"""Whether a part is a thought signature with nothing to send beside it.
361+
362+
A part that still has its `thought` flag is redacted thinking Claude issued
363+
itself, and the branches in `_part_to_message_block` handle it.
364+
"""
365+
if not part.thought_signature or part.thought or part.text:
366+
return False
367+
return not any(getattr(part, f, None) for f in _PART_CONTENT_FIELDS)
368+
369+
342370
def _normalize_image_media_type(mime_type: str) -> _ImageMediaType:
343371
normalized = mime_type.split(";", 1)[0].strip().lower()
344372
if normalized not in _ANTHROPIC_IMAGE_MEDIA_TYPES:
@@ -578,6 +606,12 @@ def _content_to_message_param(
578606
logger.warning("PDF data is not supported in Claude for assistant turns.")
579607
continue
580608

609+
# A signature with nothing to send beside it: there is no block to build
610+
# from it, and it used to raise and wedge the session for good.
611+
if _is_content_free_signature(part):
612+
logger.warning("Dropping a thought signature from another model.")
613+
continue
614+
581615
message_block.append(_part_to_message_block(part, sanitizer))
582616

583617
return {
@@ -995,9 +1029,17 @@ async def generate_content_async(
9951029
self, llm_request: LlmRequest, stream: bool = False
9961030
) -> AsyncGenerator[LlmResponse, None]:
9971031
sanitizer = _ToolUseIdSanitizer()
1032+
# A turn whose parts are all dropped above leaves no blocks behind, and
1033+
# Anthropic rejects a message with empty content. Sending one re-wedges the
1034+
# session exactly as the NotImplementedError did: the offending part stays
1035+
# in history, so every later turn fails the same way.
9981036
messages = [
999-
_content_to_message_param(content, sanitizer)
1000-
for content in llm_request.contents or []
1037+
message
1038+
for message in (
1039+
_content_to_message_param(content, sanitizer)
1040+
for content in llm_request.contents or []
1041+
)
1042+
if message["content"]
10011043
]
10021044
tools: Iterable[anthropic_types.ToolUnionParam] | NotGiven = NOT_GIVEN
10031045
function_declarations: list[types.FunctionDeclaration] = []

‎tests/unittests/models/test_anthropic_llm.py‎

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1859,6 +1859,83 @@ def test_content_block_to_part_redacted_thinking():
18591859
assert part.thought_signature == b"redacted_data"
18601860

18611861

1862+
def test_content_to_message_param_drops_content_free_signature():
1863+
"""A signature with no content of its own used to wedge the session.
1864+
1865+
It matched no branch in `_part_to_message_block`, so the turn raised
1866+
NotImplementedError. The part stays in session history, so every later turn
1867+
raised again and the session was dead for good. It came from another model,
1868+
carries nothing, and Claude cannot verify it, so it is dropped.
1869+
"""
1870+
from google.adk.models.anthropic_llm import content_to_message_param
1871+
1872+
content = types.Content(
1873+
role="model",
1874+
parts=[
1875+
types.Part(text="", thought_signature=b"\x01\x8f=k_\xae@L"), # binary
1876+
types.Part(thought_signature=b"AY89a18D"), # and one that is text
1877+
types.Part(text="hello"),
1878+
],
1879+
)
1880+
1881+
message = content_to_message_param(content)
1882+
1883+
assert [block["type"] for block in message["content"]] == ["text"]
1884+
1885+
1886+
@pytest.mark.asyncio
1887+
async def test_turn_of_only_dropped_parts_is_not_sent_as_empty_message():
1888+
"""A turn that is nothing but a foreign signature must not become `content: []`.
1889+
1890+
Dropping the part is right -- there is nothing Claude can be given -- but
1891+
emitting the message anyway trades NotImplementedError for a 400 that
1892+
repeats on every later turn, which is the same permanent wedge.
1893+
"""
1894+
request = _cache_test_request(
1895+
contents=[
1896+
Content(role="user", parts=[Part.from_text(text="Question")]),
1897+
Content(
1898+
role="model",
1899+
parts=[types.Part(thought_signature=b"\x01\x8f=k_")],
1900+
),
1901+
]
1902+
)
1903+
1904+
kwargs = await _sent_anthropic_kwargs(request)
1905+
1906+
assert all(m["content"] for m in kwargs["messages"])
1907+
assert [m["role"] for m in kwargs["messages"]] == ["user"]
1908+
1909+
1910+
def test_content_to_message_param_keeps_signature_on_real_content():
1911+
"""A signature riding along with content must not take the part with it."""
1912+
from google.adk.models.anthropic_llm import content_to_message_param
1913+
1914+
content = types.Content(
1915+
role="model",
1916+
parts=[
1917+
types.Part(
1918+
function_call=types.FunctionCall(id="c1", name="do_it", args={}),
1919+
thought_signature=b"\x01\x8f=k_",
1920+
),
1921+
types.Part(text="hi", thought=True, thought_signature=b"sig"),
1922+
# Claude's own redacted thinking: no content, but the flag is intact,
1923+
# so it must be sent back rather than dropped.
1924+
types.Part(thought=True, thought_signature=b"opaque"),
1925+
# An annotation is not content, so this one still goes.
1926+
types.Part(thought_signature=b"gone", part_metadata={"a": 1}),
1927+
],
1928+
)
1929+
1930+
message = content_to_message_param(content)
1931+
1932+
assert [block["type"] for block in message["content"]] == [
1933+
"tool_use",
1934+
"thinking",
1935+
"redacted_thinking",
1936+
]
1937+
1938+
18621939
def test_message_to_generate_content_response_with_thinking():
18631940
"""Message with ThinkingBlock + TextBlock yields both parts."""
18641941
from google.adk.models.anthropic_llm import message_to_generate_content_response
@@ -3906,7 +3983,11 @@ async def test_cache_breakpoint_skips_a_reasoning_block(
39063983

39073984
@pytest.mark.asyncio
39083985
async def test_cache_breakpoint_skips_a_turn_left_with_no_blocks():
3909-
"""An assistant turn holding only an image is dropped, so it cannot carry one."""
3986+
"""An assistant turn holding only an image is dropped, so it cannot carry one.
3987+
3988+
It used to be sent as a message with empty content, which Anthropic rejects;
3989+
the turn is now left out of the request entirely.
3990+
"""
39103991
request = _cache_test_request(
39113992
contents=[
39123993
Content(role="user", parts=[Part.from_text(text="Question")]),
@@ -3921,7 +4002,8 @@ async def test_cache_breakpoint_skips_a_turn_left_with_no_blocks():
39214002

39224003
kwargs = await _sent_anthropic_kwargs(request)
39234004

3924-
assert kwargs["messages"][-1]["content"] == []
4005+
assert [m["role"] for m in kwargs["messages"]] == ["user"]
4006+
assert all(m["content"] for m in kwargs["messages"])
39254007
assert _breakpoints(kwargs) == {
39264008
"system[0]": _EPHEMERAL,
39274009
"tools[1]": _EPHEMERAL,

0 commit comments

Comments
 (0)