Repository navigation
Conversation
105f79e to
e47cf7c
Compare
|
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. |
Why not? How do we know that this change fixes the issue? |
krwc
left a comment
There was a problem hiding this comment.
Please test e2e and add some logs / test results to the PR. Thanks!
|
Fair question. When I opened the PR I didn't have an Anthropic API key set up, so I had only run the mocked tests. I've now run it end to end against the live API with In short: on ADK doesn't set |
| RedactedThinkingBlockParam.builder().data(signature.get()).build()); | ||
| } | ||
| // Other thoughts go out as text (ADK Python sends thinking), or not at all if empty. | ||
| if (part.text().map(String::isEmpty).orElse(false)) { |
There was a problem hiding this comment.
A thought with no text and no usable signature still falls through to UnsupportedOperationException("Not supported yet."), for example a thought part that carries only a binary signature, or Part.builder().thought(true).build(). The description says these are dropped. Could you please use .orElse(true) here (together with the change above) and add a small test for it?
There was a problem hiding this comment.
Done. generateContent_thoughtWithoutTextOrUsableSignature_isNotSent sends Part.builder().thought(true).build() and a thought carrying only a binary signature next to a text part; both threw before, and now only the text block goes out.
One case is left: if a thought carrying only a binary signature is the only part of an earlier turn, that message is now empty, which the API rejects (before this change the same turn threw). ADK Python filters out empty messages in google/adk-python@a5d6238cb, the same change as the signature-only part under "Additional context", so I'd cover both there.
There was a problem hiding this comment.
Please cover both of those cases.
There was a problem hiding this comment.
Done here, as asked. Following google/adk-python@a5d6238cb, partToAnthropicMessageBlock now drops a non-thought part that carries only a signature (text absent or empty, none of the content fields _is_content_free_signature checks; metadata such as partMetadata doesn't count) and logs the same warning, and generateContent leaves out a message whose blocks were all dropped.
generateContent_turnOfOnlyDroppedThoughts_isNotSent: an earlier turn holding only the two thoughts from my test above, which the previous commit sent as an empty message.generateContent_turnOfOnlyContentFreeSignature_isNotSentandgenerateContent_contentFreeSignature_isNotSent: a signature-only part alone in a turn and next to text, including thetext=""shape the Gemini API sends.generateContent_signatureBesideContent_isKept: a signed call and Claude's thinking and redacted thinking still go out.
I've updated the description to match.
e47cf7c to
40b0a6c
Compare
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.
It also ports google/adk-python@a5d6238cb. A non-thought part that
carries only a thought signature, which Gemini leaves in the history,
is dropped with a warning instead of throwing or going out as an empty
text block, and a message whose parts were all dropped is left out,
since Anthropic rejects empty messages. The part stays in the session,
so without this every later turn failed the same way.
Three 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 it has no text, since there
is nothing to send back and Anthropic rejects empty text blocks.
ADK Python sends an unsigned thought with text as a thinking block
with an empty signature.
- A part with a function call or response is always sent as tool_use
or tool_result, even if it is a thought or also has text, so that
the call and its tool_result stay paired. ADK Python checks
non-empty text and a thought's signature before the call, so the
call is lost in those cases.
40b0a6c to
d1d9dc2
Compare
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.anthropicContentBlockToPartconverts onlytextandtool_useblocks and throwsUnsupportedOperationException("Not supported yet.")for any other block. The Claude 5 models use adaptive thinking when the request has nothinkingfield, which ADK Java never sets, so a response can contain athinkingblock without being asked, and the agent run ends with that exception. The request side has the matching gap:partToAnthropicMessageBlocksends a thoughtPartwith text as a plain text block, dropping its signature, and throws for one that has only a signature. A non-thoughtPartthat carries only a signature, which Gemini leaves in the history, fails as well: it throws when it has no text, and withtext=""(the shape the Gemini API sends) it goes out as an empty text block, which Anthropic rejects. The part stays in the session, so every later turn fails the same way.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).thinkingblock when it has text and aredacted_thinkingblock 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._is_content_free_signature, that means the text is absent or empty and none of the content fields that function checks is set; metadata such aspartMetadatadoes not count. Gemini leaves such parts in the history (GeminiTest.processRawResponses_contentFreeSignaturePart_isKeptandprocessRawResponses_emptyTextSignaturePart_isKept). Claude cannot verify that signature, and the session keeps the part for a later Gemini turn.Three deliberate differences from ADK Python, each marked with a one-line comment:
display: "omitted", the Claude 5 default, returns) goes back as thethinkingblock it came from. ADK Python'sif part.thought and part.text:treats the empty text as absent and sends aredacted_thinkingblock whosedatais the signature. In Java,Part.text()is anOptional, so empty and absent text can be told apart.thinkingblock. It is still sent as text, as before, or dropped if it has no text, since there is nothing to send back and Anthropic rejects empty text blocks; ADK Python's_is_empty_text_part(google/adk-python@6e8546c88) drops an empty-text thought without a signature the same way. ADK Python sends an unsigned thought with text as athinkingblock withsignature="".tool_useortool_result, even if it is a thought or also has text, so the call and itstool_resultstay paired;Contents.isPartInvisiblealready treats thought-flagged calls and responses as calls and responses. ADK Python checks non-empty text and a thought's signature before the call, so the call is lost in those cases.Testing Plan
Unit Tests:
./mvnw -pl core test -Dtest=ClaudeTest: 18 tests, 0 failures (Microsoft Build of OpenJDK 17.0.19, Windows 11). The ten new tests go throughgenerateContentwith a mockedAnthropicClient:maingenerateContent_thinkingBlocks_becomeThoughtPartsUnsupportedOperationExceptiongenerateContent_toolTurn_sendsThinkingBlocksBackUnchanged:[thinking, redacted_thinking, text, thinking, tool_use], then the next requestUnsupportedOperationExceptiongenerateContent_emptyThoughtWithoutSignature_isNotSentgenerateContent_thoughtWithBinarySignature_isSentAsTextmainsends every thought with text as text)generateContent_thoughtWithoutTextOrUsableSignature_isNotSent:Part.builder().thought(true).build()and a thought with only a binary signatureUnsupportedOperationExceptiongenerateContent_thoughtFlaggedCallsAndResponses_areSentAsToolUseAndToolResult: thought-flagged calls and responses with a signature and no text, withtext="", and with text and a signaturegenerateContent_contentFreeSignature_isNotSent: a part withtext=""and a binary signature and one with only a signature, next to textUnsupportedOperationExceptiongenerateContent_signatureBesideContent_isKept: a call with a signature, a thought with text and one without, and a signature with onlypartMetadataUnsupportedOperationExceptiongenerateContent_turnOfOnlyDroppedThoughts_isNotSent: an earlier turn holding onlyPart.builder().thought(true).build()and a thought with a binary signatureUnsupportedOperationExceptiongenerateContent_turnOfOnlyContentFreeSignature_isNotSent: an earlier turn holding only a signatureUnsupportedOperationExceptionPartial implementations fail them too: ignoring
redacted_thinkingfails 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. The fifth and sixth catch each part of the first follow-up to review: leaving out the call/response condition or itsfunctionResponsehalf, keeping.orElse(false), or checking text before the call or the response each fails one of them. The last four catch the second: treating empty text as content, ignoring thethoughtflag or the call, countingpartMetadataas content, checking for such a part only where the method used to throw, dropping every part it cannot map, or leaving out the empty-message filter each fails one of them.Full
./mvnw -pl core test: 2130 run, 0 failures (24 skipped).Manual End-to-End (E2E) Tests:
Run against the live Messages API on 2026-10-12 (anthropic-java 2.15.0). A throwaway JUnit test, not part of this PR, runs an
LlmAgentwithFunctionTools throughInMemoryRunnerfor two user turns, with a recording wrapper around a realAnthropicClientthat logs each request and response.These runs used an earlier version of this commit (
e47cf7cf). The follow-ups to review only change what happens to parts that Claude's own responses never produce (thought-flagged calls and responses, thoughts with neither text nor a usable signature, parts with both text and a call or response, and non-thought parts that carry only a signature) and to messages left with no blocks, which these runs never had, so every request in these runs is the same with the current commit.Requests as ADK sends them (no
thinkingfield), onclaude-opus-5-5,claude-sonnet-5-5andclaude-haiku-5-5. The one-tool scenario ran once withClaude.javafrommainand once with this change; the two-tool scenario ran with this change only:main[thinking, text]after a tool result, with empty thinking text and a signatureUnsupportedOperationExceptionatClaude.java:391; the run ends[thinking, tool_use]between two tool calls[thinking (same signature), tool_use]with thetool_result; the API accepts it, and the run completesAll three models behaved the same. Because thinking is adaptive, the test reruns a model when the response a scenario needs doesn't appear; no rerun was needed.
In these runs the thinking text was always empty (
displaydefaults to"omitted"), and noredacted_thinkingblock came back. To cover those cases too, two more runs with this change had the wrapper add athinkingsetting to every request (ADK itself never sends one):{type: "adaptive", display: "summarized"}, two-tool scenario, same three models[thinking, tool_use]with non-empty thinking text{type: "enabled", budget_tokens: 2000}onclaude-sonnet-4-6, with the test string from Anthropic's extended thinking cookbook that triggers redacted thinking[redacted_thinking, text], then[redacted_thinking, text, tool_use]data; the API accepts them, and the run completesWith requests as ADK sends them, the three Claude 5.5 models answered the same test string with a regular
thinkingblock, notredacted_thinking.The instruction was a fixed string in all runs, so none of them exercise the known limitation below.
No message content is logged: only block types and lengths, tool names, stop reasons and token usage. A signature (or
redacted_thinkingdata) is shown as its length and the first 12 hex digits of its SHA-256, and non-empty thinking text as its length and SHA-256 prefix (textsha), so the same block can be matched across requests.Logs:
main, one-tool scenarioLogs: this change, one-tool scenario
Logs: this change, two-tool scenario
Logs: this change, two-tool scenario,
display: "summarized"addedLogs: this change, redacted-thinking test string,
claude-sonnet-4-6withbudget_tokens: 2000addedLogs: this change, redacted-thinking test string, requests as ADK sends them
Checklist
Additional context
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 they have no text, which matches the preserved thinking FAQ ("Send the other model's output astextandtool_usecontent"). If you would rather drop them, it is a small change.system,toolsand 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), andContentskeeps 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, andthinking.block_binding.prefix_mismatch_behavior: "drop_block"(beta headerthinking-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.thinking,effortanddisplayrequest settings, themaxTokensdefault, and streaming (feat: implement streaming support for Claude model #1221, which also needs thinking deltas and signatures).