Skip to content

fix: round-trip thinking blocks in the Claude model - #1634

Open
innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/claude-thinking-blocks
Open

innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/claude-thinking-blocks

Conversation

@innoprej

@innoprej innoprej commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

Claude.anthropicContentBlockToPart converts only text and tool_use blocks and throws UnsupportedOperationException("Not supported yet.") for any other block. The Claude 5 models use adaptive thinking when the request has no thinking field, which ADK Java never sets, so a response can contain a thinking block without being asked, and the agent run ends with that exception. The request side has the matching gap: partToAnthropicMessageBlock sends a thought Part with text as a plain text block, dropping its signature, and throws for one that has only a signature.

Solution:

Use the same mapping as ADK Python (google/adk-python@16952bd39, src/google/adk/models/anthropic_llm.py):

  • thinking → Part(text, thought=true, thoughtSignature=signature).
  • redacted_thinking → Part(thought=true, thoughtSignature=data).
  • On the way back, a thought with a signature becomes a thinking block when it has text and a redacted_thinking block when it has none. A tool-use turn therefore sends the blocks back unchanged and in order, which the Anthropic thinking docs ("Preserving thinking blocks") require.
  • Only signatures stored this way, as UTF-8 text, are sent back. A thought that another model left with a binary signature (Gemini's are binary) is not Claude's, and Anthropic always returns a 400 for a signature it cannot decode.

Two deliberate differences from ADK Python, each marked with a one-line comment:

  • A thought with empty text (what display: "omitted", the Claude 5 default, returns) goes back as the thinking block it came from. ADK Python's if part.thought and part.text: treats the empty text as absent and sends a redacted_thinking block whose data is the signature. In Java, Part.text() is an Optional, so empty and absent text can be told apart.
  • Any other thought is never sent as a thinking block. It is still sent as text, as before, or dropped if its text is empty, as ADK Python's _is_empty_text_part does (google/adk-python@6e8546c88), because Anthropic rejects empty text blocks. ADK Python sends an unsigned thought with text as a thinking block with signature="".

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

./mvnw -pl core test -Dtest=ClaudeTest: 12 tests, 0 failures (Microsoft Build of OpenJDK 17.0.19, Windows 11). The four new tests go through generateContent with a mocked AnthropicClient:

Test On main With this change
generateContent_thinkingBlocks_becomeThoughtParts throws UnsupportedOperationException passes
generateContent_toolTurn_sendsThinkingBlocksBackUnchanged: [thinking, redacted_thinking, text, thinking, tool_use], then the next request throws UnsupportedOperationException passes
generateContent_emptyThoughtWithoutSignature_isNotSent fails: the empty thoughts are sent as empty text blocks passes
generateContent_thoughtWithBinarySignature_isSentAsText passes (main sends every thought with text as text) passes; guards the UTF-8 check

Partial implementations fail them too: ignoring redacted_thinking fails the first two; sending a zero-length signature fails the third; ADK Python's "text is non-empty" rule, or sending the thinking text back empty, fails the round-trip test; and dropping the UTF-8 check fails the fourth.

Full ./mvnw -pl core test: 2124 run, 0 failures (24 skipped).

Manual End-to-End (E2E) Tests:

Not run against the live API. With a mocked AnthropicClient, an LlmAgent with a FunctionTool run through InMemoryRunner receives [thinking, tool_use] and then text: the second request sends [thinking (same signature), tool_use], and the run ends with the text.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

  • Thought Parts produced by another model in the same agent's history (the question in Claude model throws UnsupportedOperationException("Not supported yet.") on thinking blocks from Claude 5 models #1630): with this change they go out as text, as before, or are dropped if empty, which matches the preserved thinking FAQ ("Send the other model's output as text and tool_use content"). If you would rather drop them, it is a small change.
  • Known limitation, same as ADK Python: on Claude Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5, a thinking block that is sent back is valid only while system, tools and the earlier messages are unchanged, and for accounts created on or after 2026-08-31 the API returns a 400 when they change. ADK rebuilds the system instruction on every request (for example {state} placeholders), and Contents keeps every earlier turn's signed thoughts, so once the instruction changes, later requests in that session fail. Sending back only the current turn's thinking blocks would make this much rarer, and thinking.block_binding.prefix_mismatch_behavior: "drop_block" (beta header thinking-binding-controls-2026-08-01) makes the API drop such blocks instead. Both change behavior beyond this fix, so I left them for a follow-up.
  • Not changed here: the thinking, effort and display request settings, the maxTokens default, and streaming (feat: implement streaming support for Claude model #1221, which also needs thinking deltas and signatures). A non-thought Part that carries only a signature, which Gemini leaves in the history, still throws in partToAnthropicMessageBlock; ADK Python drops it since google/adk-python@a5d6238cb, and I can send that as a separate change.

@hemasekhar-p hemasekhar-p self-assigned this Oct 8, 2026
Claude.anthropicContentBlockToPart converted only text and tool_use
blocks and threw UnsupportedOperationException("Not supported yet.")
for any other block. The Claude 5 models use adaptive thinking when the
request has no thinking field, which ADK Java never sets, so their
responses can contain thinking blocks without being asked, and each
such response ended the agent run.

Convert the blocks the same way as ADK Python:
- thinking -> Part(text, thought=true, thoughtSignature=signature)
- redacted_thinking -> Part(thought=true, thoughtSignature=data)

partToAnthropicMessageBlock sends them back unchanged, which Anthropic
requires within a tool-use turn: a thought with a signature becomes a
thinking block when it has text and a redacted_thinking block when it
has none. Only signatures stored this way, as UTF-8 text, are sent
back. A binary signature that another model such as Gemini left on a
thought is not Claude's, and Anthropic rejects signatures it cannot
decode.

Two deliberate differences from ADK Python:
- A thought with empty text (display "omitted", the Claude 5 default)
  goes back as the thinking block it came from. ADK Python treats the
  empty text as absent and sends a redacted_thinking block whose data
  is the signature.
- Any other thought is never sent as a thinking block. It is still
  sent as text, as before, or dropped if its text is empty, as in ADK
  Python, because Anthropic rejects empty text blocks. ADK Python
  sends an unsigned thought with text as a thinking block with an
  empty signature.
@hemasekhar-p
hemasekhar-p force-pushed the fix/claude-thinking-blocks branch from 105f79e to e47cf7c Compare October 8, 2026 10:54
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @innoprej, We appreciate your contribution and the effort you put into this pull request. It is currently under review by our team. We will update you if any additional details are needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude model throws UnsupportedOperationException("Not supported yet.") on thinking blocks from Claude 5 models

2 participants