Skip to content

Commit e47cf7c

Browse files
innoprejhemasekhar-p
authored andcommitted
fix: round-trip thinking blocks in the Claude model
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.
1 parent c239870 commit e47cf7c

2 files changed

Lines changed: 223 additions & 1 deletion

File tree

‎core/src/main/java/com/google/adk/models/Claude.java‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@
1616

1717
package com.google.adk.models;
1818

19+
import static java.nio.charset.StandardCharsets.UTF_8;
20+
1921
import com.anthropic.client.AnthropicClient;
2022
import com.anthropic.models.messages.ContentBlock;
2123
import com.anthropic.models.messages.ContentBlockParam;
2224
import com.anthropic.models.messages.Message;
2325
import com.anthropic.models.messages.MessageCreateParams;
2426
import com.anthropic.models.messages.MessageParam;
2527
import com.anthropic.models.messages.MessageParam.Role;
28+
import com.anthropic.models.messages.RedactedThinkingBlockParam;
2629
import com.anthropic.models.messages.TextBlockParam;
30+
import com.anthropic.models.messages.ThinkingBlockParam;
2731
import com.anthropic.models.messages.Tool;
2832
import com.anthropic.models.messages.ToolChoice;
2933
import com.anthropic.models.messages.ToolChoiceAuto;
@@ -32,6 +36,7 @@
3236
import com.anthropic.models.messages.ToolUseBlockParam;
3337
import com.fasterxml.jackson.core.type.TypeReference;
3438
import com.google.adk.JsonBaseModel;
39+
import com.google.common.base.Utf8;
3540
import com.google.common.collect.ImmutableList;
3641
import com.google.common.collect.ImmutableMap;
3742
import com.google.genai.types.*;
@@ -44,6 +49,7 @@
4449
import java.util.Objects;
4550
import java.util.Optional;
4651
import java.util.stream.Collectors;
52+
import org.jspecify.annotations.Nullable;
4753
import org.slf4j.Logger;
4854
import org.slf4j.LoggerFactory;
4955

@@ -173,7 +179,29 @@ private MessageParam contentToAnthropicMessageParam(Content content) {
173179
.build();
174180
}
175181

176-
private ContentBlockParam partToAnthropicMessageBlock(Part part) {
182+
private @Nullable ContentBlockParam partToAnthropicMessageBlock(Part part) {
183+
if (part.thought().orElse(false)) {
184+
// Signatures this class stores are UTF-8; a binary one (e.g. Gemini's) is not Claude's.
185+
Optional<String> signature =
186+
part.thoughtSignature()
187+
.filter(bytes -> bytes.length > 0 && Utf8.isWellFormed(bytes))
188+
.map(bytes -> new String(bytes, UTF_8));
189+
if (signature.isPresent()) {
190+
// Unlike ADK Python, empty text (display "omitted") stays thinking, not redacted_thinking.
191+
return part.text().isPresent()
192+
? ContentBlockParam.ofThinking(
193+
ThinkingBlockParam.builder()
194+
.thinking(part.text().get())
195+
.signature(signature.get())
196+
.build())
197+
: ContentBlockParam.ofRedactedThinking(
198+
RedactedThinkingBlockParam.builder().data(signature.get()).build());
199+
}
200+
// Other thoughts go out as text (ADK Python sends thinking), or not at all if empty.
201+
if (part.text().map(String::isEmpty).orElse(false)) {
202+
return null;
203+
}
204+
}
177205
if (part.text().isPresent()) {
178206
return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build());
179207
} else if (part.functionCall().isPresent()) {
@@ -387,6 +415,18 @@ private Part anthropicContentBlockToPart(ContentBlock block) {
387415
.convert(new TypeReference<Map<String, Object>>() {}))
388416
.build())
389417
.build();
418+
} else if (block.isThinking()) {
419+
return Part.builder()
420+
.text(block.asThinking().thinking())
421+
.thought(true)
422+
.thoughtSignature(block.asThinking().signature().getBytes(UTF_8))
423+
.build();
424+
} else if (block.isRedactedThinking()) {
425+
// Keep the encrypted data in thoughtSignature so the block can be sent back unchanged.
426+
return Part.builder()
427+
.thought(true)
428+
.thoughtSignature(block.asRedactedThinking().data().getBytes(UTF_8))
429+
.build();
390430
}
391431
throw new UnsupportedOperationException("Not supported yet.");
392432
}

‎core/src/test/java/com/google/adk/models/ClaudeTest.java‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,44 +17,62 @@
1717
package com.google.adk.models;
1818

1919
import static com.google.common.truth.Truth.assertThat;
20+
import static java.nio.charset.StandardCharsets.UTF_8;
2021
import static org.junit.Assert.assertEquals;
2122
import static org.junit.jupiter.api.Assertions.assertTrue;
23+
import static org.mockito.ArgumentMatchers.any;
2224
import static org.mockito.Mockito.mock;
25+
import static org.mockito.Mockito.times;
26+
import static org.mockito.Mockito.verify;
2327
import static org.mockito.Mockito.when;
2428

2529
import com.anthropic.client.AnthropicClient;
2630
import com.anthropic.core.JsonValue;
31+
import com.anthropic.models.messages.ContentBlock;
2732
import com.anthropic.models.messages.ContentBlockParam;
33+
import com.anthropic.models.messages.DirectCaller;
2834
import com.anthropic.models.messages.Message;
35+
import com.anthropic.models.messages.MessageCreateParams;
36+
import com.anthropic.models.messages.RedactedThinkingBlock;
37+
import com.anthropic.models.messages.TextBlock;
38+
import com.anthropic.models.messages.ThinkingBlock;
2939
import com.anthropic.models.messages.Tool;
3040
import com.anthropic.models.messages.ToolResultBlockParam;
41+
import com.anthropic.models.messages.ToolUseBlock;
3142
import com.anthropic.models.messages.Usage;
43+
import com.anthropic.services.blocking.MessageService;
3244
import com.fasterxml.jackson.core.type.TypeReference;
3345
import com.google.common.collect.ImmutableList;
3446
import com.google.common.collect.ImmutableMap;
47+
import com.google.genai.types.Content;
3548
import com.google.genai.types.FunctionDeclaration;
3649
import com.google.genai.types.FunctionResponse;
3750
import com.google.genai.types.Part;
3851
import com.google.genai.types.Schema;
3952
import java.lang.reflect.Method;
4053
import java.util.Collections;
54+
import java.util.List;
4155
import java.util.Map;
4256
import org.junit.Before;
4357
import org.junit.Test;
4458
import org.junit.runner.RunWith;
4559
import org.junit.runners.JUnit4;
60+
import org.mockito.ArgumentCaptor;
4661
import org.mockito.Mockito;
4762

4863
@RunWith(JUnit4.class)
4964
public final class ClaudeTest {
5065

5166
private Claude claude;
67+
private MessageService messageService;
5268
private Method partToAnthropicMessageBlockMethod;
5369
private Method functionDeclarationToAnthropicToolMethod;
5470

5571
@Before
5672
public void setUp() throws Exception {
5773
AnthropicClient mockClient = Mockito.mock(AnthropicClient.class);
74+
messageService = Mockito.mock(MessageService.class);
75+
when(mockClient.messages()).thenReturn(messageService);
5876
claude = new Claude("claude-3-opus", mockClient);
5977

6078
// Access private method for testing the extraction logic
@@ -74,6 +92,38 @@ private static Map<String, Object> inputSchemaProperties(Tool tool) {
7492
return properties.convert(new TypeReference<Map<String, Object>>() {});
7593
}
7694

95+
private static Message message(ContentBlock... blocks) {
96+
Message message = mock(Message.class);
97+
when(message.content()).thenReturn(ImmutableList.copyOf(blocks));
98+
return message;
99+
}
100+
101+
private static ContentBlock thinkingBlock(String thinking, String signature) {
102+
return ContentBlock.ofThinking(
103+
ThinkingBlock.builder().thinking(thinking).signature(signature).build());
104+
}
105+
106+
private static ContentBlock redactedThinkingBlock(String data) {
107+
return ContentBlock.ofRedactedThinking(RedactedThinkingBlock.builder().data(data).build());
108+
}
109+
110+
private static ContentBlock textBlock(String text) {
111+
return ContentBlock.ofText(
112+
TextBlock.builder().text(text).citations(ImmutableList.of()).build());
113+
}
114+
115+
private static Content userText(String text) {
116+
return Content.builder().role("user").parts(Part.fromText(text)).build();
117+
}
118+
119+
private static LlmRequest request(Content... contents) {
120+
return LlmRequest.builder().contents(ImmutableList.copyOf(contents)).build();
121+
}
122+
123+
private static String signatureOf(Part part) {
124+
return new String(part.thoughtSignature().get(), UTF_8);
125+
}
126+
77127
@Test
78128
public void testPartToAnthropicMessageBlock_mcpTool_legacyTextOutputKey() throws Exception {
79129
Map<String, Object> responseData =
@@ -260,4 +310,136 @@ public void functionDeclarationToAnthropicTool_preservesRefsAndDefs() throws Exc
260310
Map<String, Object> defs = defsValue.convert(new TypeReference<Map<String, Object>>() {});
261311
assertThat(defs).containsKey("Pet");
262312
}
313+
314+
@Test
315+
public void generateContent_thinkingBlocks_becomeThoughtParts() {
316+
// With display "omitted" (the Claude 5 default) a thinking block has empty text.
317+
Message reply =
318+
message(
319+
thinkingBlock("", "signature"), redactedThinkingBlock("redacted-data"), textBlock("4"));
320+
when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply);
321+
322+
LlmResponse response = claude.generateContent(request(userText("2+2?")), false).blockingFirst();
323+
324+
List<Part> parts = response.content().get().parts().get();
325+
assertThat(parts).hasSize(3);
326+
assertThat(parts.get(0).thought()).hasValue(true);
327+
assertThat(parts.get(0).text()).hasValue("");
328+
assertThat(signatureOf(parts.get(0))).isEqualTo("signature");
329+
assertThat(parts.get(1).thought()).hasValue(true);
330+
assertThat(parts.get(1).text()).isEmpty();
331+
assertThat(signatureOf(parts.get(1))).isEqualTo("redacted-data");
332+
assertThat(parts.get(2).thought()).isEmpty();
333+
assertThat(parts.get(2).text()).hasValue("4");
334+
}
335+
336+
@Test
337+
public void generateContent_toolTurn_sendsThinkingBlocksBackUnchanged() {
338+
Message toolUseReply =
339+
message(
340+
thinkingBlock("", "signature-1"),
341+
redactedThinkingBlock("redacted-data"),
342+
textBlock("Let me check."),
343+
thinkingBlock("Checking the weather.", "signature-2"),
344+
ContentBlock.ofToolUse(
345+
ToolUseBlock.builder()
346+
.id("toolu_1")
347+
.name("getWeather")
348+
.input(JsonValue.from(ImmutableMap.of("city", "Seoul")))
349+
.caller(DirectCaller.builder().build())
350+
.build()));
351+
Message finalReply = message(textBlock("It is sunny."));
352+
when(messageService.create(any(MessageCreateParams.class)))
353+
.thenReturn(toolUseReply, finalReply);
354+
Content modelTurn =
355+
claude
356+
.generateContent(request(userText("Weather in Seoul?")), false)
357+
.blockingFirst()
358+
.content()
359+
.get();
360+
Content toolResult =
361+
Content.builder()
362+
.role("user")
363+
.parts(
364+
Part.builder()
365+
.functionResponse(
366+
FunctionResponse.builder()
367+
.id("toolu_1")
368+
.name("getWeather")
369+
.response(ImmutableMap.of("result", "sunny"))
370+
.build())
371+
.build())
372+
.build();
373+
374+
claude
375+
.generateContent(request(userText("Weather in Seoul?"), modelTurn, toolResult), false)
376+
.blockingFirst();
377+
378+
ArgumentCaptor<MessageCreateParams> params = ArgumentCaptor.forClass(MessageCreateParams.class);
379+
verify(messageService, times(2)).create(params.capture());
380+
List<ContentBlockParam> blocks =
381+
params.getAllValues().get(1).messages().get(1).content().asBlockParams();
382+
assertThat(blocks).hasSize(5);
383+
assertThat(blocks.get(0).asThinking().thinking()).isEmpty();
384+
assertThat(blocks.get(0).asThinking().signature()).isEqualTo("signature-1");
385+
assertThat(blocks.get(1).asRedactedThinking().data()).isEqualTo("redacted-data");
386+
assertThat(blocks.get(2).asText().text()).isEqualTo("Let me check.");
387+
assertThat(blocks.get(3).asThinking().thinking()).isEqualTo("Checking the weather.");
388+
assertThat(blocks.get(3).asThinking().signature()).isEqualTo("signature-2");
389+
assertThat(blocks.get(4).asToolUse().id()).isEqualTo("toolu_1");
390+
}
391+
392+
@Test
393+
public void generateContent_emptyThoughtWithoutSignature_isNotSent() {
394+
Message reply = message(textBlock("ok"));
395+
when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply);
396+
Content modelTurn =
397+
Content.builder()
398+
.role("model")
399+
.parts(
400+
Part.builder().text("").thought(true).build(),
401+
Part.builder().text("").thought(true).thoughtSignature(new byte[0]).build(),
402+
Part.fromText("Hello."))
403+
.build();
404+
405+
claude
406+
.generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false)
407+
.blockingFirst();
408+
409+
ArgumentCaptor<MessageCreateParams> params = ArgumentCaptor.forClass(MessageCreateParams.class);
410+
verify(messageService).create(params.capture());
411+
// Neither thought has a signature to send back, and Anthropic rejects empty text blocks.
412+
List<ContentBlockParam> blocks = params.getValue().messages().get(1).content().asBlockParams();
413+
assertThat(blocks).hasSize(1);
414+
assertThat(blocks.get(0).asText().text()).isEqualTo("Hello.");
415+
}
416+
417+
@Test
418+
public void generateContent_thoughtWithBinarySignature_isSentAsText() {
419+
Message reply = message(textBlock("ok"));
420+
when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply);
421+
// Another model (e.g. Gemini) stores a binary signature, which Anthropic could not decode.
422+
Content modelTurn =
423+
Content.builder()
424+
.role("model")
425+
.parts(
426+
Part.builder()
427+
.text("Looking up the weather.")
428+
.thought(true)
429+
.thoughtSignature(new byte[] {(byte) 0xC3, 0x28})
430+
.build(),
431+
Part.fromText("It is sunny."))
432+
.build();
433+
434+
claude
435+
.generateContent(request(userText("Hi"), modelTurn, userText("Thanks")), false)
436+
.blockingFirst();
437+
438+
ArgumentCaptor<MessageCreateParams> params = ArgumentCaptor.forClass(MessageCreateParams.class);
439+
verify(messageService).create(params.capture());
440+
List<ContentBlockParam> blocks = params.getValue().messages().get(1).content().asBlockParams();
441+
assertThat(blocks).hasSize(2);
442+
assertThat(blocks.get(0).asText().text()).isEqualTo("Looking up the weather.");
443+
assertThat(blocks.get(1).asText().text()).isEqualTo("It is sunny.");
444+
}
263445
}

0 commit comments

Comments
 (0)