diff --git a/core/src/main/java/com/google/adk/models/Claude.java b/core/src/main/java/com/google/adk/models/Claude.java index fe8a4f2e5..72b7c8f3f 100644 --- a/core/src/main/java/com/google/adk/models/Claude.java +++ b/core/src/main/java/com/google/adk/models/Claude.java @@ -16,6 +16,8 @@ package com.google.adk.models; +import static java.nio.charset.StandardCharsets.UTF_8; + import com.anthropic.client.AnthropicClient; import com.anthropic.models.messages.ContentBlock; import com.anthropic.models.messages.ContentBlockParam; @@ -23,7 +25,9 @@ import com.anthropic.models.messages.MessageCreateParams; import com.anthropic.models.messages.MessageParam; import com.anthropic.models.messages.MessageParam.Role; +import com.anthropic.models.messages.RedactedThinkingBlockParam; import com.anthropic.models.messages.TextBlockParam; +import com.anthropic.models.messages.ThinkingBlockParam; import com.anthropic.models.messages.Tool; import com.anthropic.models.messages.ToolChoice; import com.anthropic.models.messages.ToolChoiceAuto; @@ -32,6 +36,7 @@ import com.anthropic.models.messages.ToolUseBlockParam; import com.fasterxml.jackson.core.type.TypeReference; import com.google.adk.JsonBaseModel; +import com.google.common.base.Utf8; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.genai.types.*; @@ -44,6 +49,7 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -104,6 +110,8 @@ public Flowable generateContent(LlmRequest llmRequest, boolean stre List messages = llmRequest.contents().stream() .map(this::contentToAnthropicMessageParam) + // Anthropic rejects empty messages, e.g. a turn whose parts were all dropped. + .filter(message -> !message.content().asBlockParams().isEmpty()) .collect(Collectors.toList()); List tools = ImmutableList.of(); @@ -173,10 +181,53 @@ private MessageParam contentToAnthropicMessageParam(Content content) { .build(); } - private ContentBlockParam partToAnthropicMessageBlock(Part part) { - if (part.text().isPresent()) { - return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build()); - } else if (part.functionCall().isPresent()) { + /** Whether a non-thought part carries a signature and nothing to send, as in ADK Python. */ + private static boolean isContentFreeSignature(Part part) { + return part.thoughtSignature().map(signature -> signature.length > 0).orElse(false) + && !part.thought().orElse(false) + && part.text().map(String::isEmpty).orElse(true) + && part.functionCall().isEmpty() + && part.functionResponse().isEmpty() + && part.inlineData().isEmpty() + && part.fileData().isEmpty() + && part.executableCode().isEmpty() + && part.codeExecutionResult().isEmpty() + && part.toolCall().isEmpty() + && part.toolResponse().isEmpty() + && part.audioTranscription().isEmpty(); + } + + private @Nullable ContentBlockParam partToAnthropicMessageBlock(Part part) { + if (isContentFreeSignature(part)) { + logger.warn("Dropping a thought signature from another model."); + return null; + } + if (part.thought().orElse(false) + && part.functionCall().isEmpty() + && part.functionResponse().isEmpty()) { + // Signatures this class stores are UTF-8; a binary one (e.g. Gemini's) is not Claude's. + Optional signature = + part.thoughtSignature() + .filter(bytes -> bytes.length > 0 && Utf8.isWellFormed(bytes)) + .map(bytes -> new String(bytes, UTF_8)); + if (signature.isPresent()) { + // Unlike ADK Python, empty text (display "omitted") stays thinking, not redacted_thinking. + return part.text().isPresent() + ? ContentBlockParam.ofThinking( + ThinkingBlockParam.builder() + .thinking(part.text().get()) + .signature(signature.get()) + .build()) + : ContentBlockParam.ofRedactedThinking( + RedactedThinkingBlockParam.builder().data(signature.get()).build()); + } + // Other thoughts go out as text (ADK Python sends thinking), or are dropped without text. + if (part.text().map(String::isEmpty).orElse(true)) { + return null; + } + } + // Unlike ADK Python, a call or response is always sent, even on a thought or beside text. + if (part.functionCall().isPresent()) { return ContentBlockParam.ofToolUse( ToolUseBlockParam.builder() .id(part.functionCall().get().id().orElse("")) @@ -205,6 +256,8 @@ private ContentBlockParam partToAnthropicMessageBlock(Part part) { .content(content) .isError(false) .build()); + } else if (part.text().isPresent()) { + return ContentBlockParam.ofText(TextBlockParam.builder().text(part.text().get()).build()); } throw new UnsupportedOperationException("Not supported yet."); } @@ -387,6 +440,18 @@ private Part anthropicContentBlockToPart(ContentBlock block) { .convert(new TypeReference>() {})) .build()) .build(); + } else if (block.isThinking()) { + return Part.builder() + .text(block.asThinking().thinking()) + .thought(true) + .thoughtSignature(block.asThinking().signature().getBytes(UTF_8)) + .build(); + } else if (block.isRedactedThinking()) { + // Keep the encrypted data in thoughtSignature so the block can be sent back unchanged. + return Part.builder() + .thought(true) + .thoughtSignature(block.asRedactedThinking().data().getBytes(UTF_8)) + .build(); } throw new UnsupportedOperationException("Not supported yet."); } diff --git a/core/src/test/java/com/google/adk/models/ClaudeTest.java b/core/src/test/java/com/google/adk/models/ClaudeTest.java index b2dd059fd..42241de11 100644 --- a/core/src/test/java/com/google/adk/models/ClaudeTest.java +++ b/core/src/test/java/com/google/adk/models/ClaudeTest.java @@ -17,44 +17,64 @@ package com.google.adk.models; import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.anthropic.client.AnthropicClient; import com.anthropic.core.JsonValue; +import com.anthropic.models.messages.ContentBlock; import com.anthropic.models.messages.ContentBlockParam; +import com.anthropic.models.messages.DirectCaller; import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.MessageParam; +import com.anthropic.models.messages.RedactedThinkingBlock; +import com.anthropic.models.messages.TextBlock; +import com.anthropic.models.messages.ThinkingBlock; import com.anthropic.models.messages.Tool; import com.anthropic.models.messages.ToolResultBlockParam; +import com.anthropic.models.messages.ToolUseBlock; import com.anthropic.models.messages.Usage; +import com.anthropic.services.blocking.MessageService; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.genai.types.Content; +import com.google.genai.types.FunctionCall; import com.google.genai.types.FunctionDeclaration; import com.google.genai.types.FunctionResponse; import com.google.genai.types.Part; import com.google.genai.types.Schema; import java.lang.reflect.Method; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; @RunWith(JUnit4.class) public final class ClaudeTest { private Claude claude; + private MessageService messageService; private Method partToAnthropicMessageBlockMethod; private Method functionDeclarationToAnthropicToolMethod; @Before public void setUp() throws Exception { AnthropicClient mockClient = Mockito.mock(AnthropicClient.class); + messageService = Mockito.mock(MessageService.class); + when(mockClient.messages()).thenReturn(messageService); claude = new Claude("claude-3-opus", mockClient); // Access private method for testing the extraction logic @@ -74,6 +94,54 @@ private static Map inputSchemaProperties(Tool tool) { return properties.convert(new TypeReference>() {}); } + private static Message message(ContentBlock... blocks) { + Message message = mock(Message.class); + when(message.content()).thenReturn(ImmutableList.copyOf(blocks)); + return message; + } + + private static ContentBlock thinkingBlock(String thinking, String signature) { + return ContentBlock.ofThinking( + ThinkingBlock.builder().thinking(thinking).signature(signature).build()); + } + + private static ContentBlock redactedThinkingBlock(String data) { + return ContentBlock.ofRedactedThinking(RedactedThinkingBlock.builder().data(data).build()); + } + + private static ContentBlock textBlock(String text) { + return ContentBlock.ofText( + TextBlock.builder().text(text).citations(ImmutableList.of()).build()); + } + + private static Content userText(String text) { + return Content.builder().role("user").parts(Part.fromText(text)).build(); + } + + private static LlmRequest request(Content... contents) { + return LlmRequest.builder().contents(ImmutableList.copyOf(contents)).build(); + } + + private static String signatureOf(Part part) { + return new String(part.thoughtSignature().get(), UTF_8); + } + + private static FunctionCall weatherCall(String id, String city) { + return FunctionCall.builder() + .id(id) + .name("getWeather") + .args(ImmutableMap.of("city", city)) + .build(); + } + + private static FunctionResponse weatherResult(String id, String weather) { + return FunctionResponse.builder() + .id(id) + .name("getWeather") + .response(ImmutableMap.of("result", weather)) + .build(); + } + @Test public void testPartToAnthropicMessageBlock_mcpTool_legacyTextOutputKey() throws Exception { Map responseData = @@ -260,4 +328,359 @@ public void functionDeclarationToAnthropicTool_preservesRefsAndDefs() throws Exc Map defs = defsValue.convert(new TypeReference>() {}); assertThat(defs).containsKey("Pet"); } + + @Test + public void generateContent_thinkingBlocks_becomeThoughtParts() { + // With display "omitted" (the Claude 5 default) a thinking block has empty text. + Message reply = + message( + thinkingBlock("", "signature"), redactedThinkingBlock("redacted-data"), textBlock("4")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + + LlmResponse response = claude.generateContent(request(userText("2+2?")), false).blockingFirst(); + + List parts = response.content().get().parts().get(); + assertThat(parts).hasSize(3); + assertThat(parts.get(0).thought()).hasValue(true); + assertThat(parts.get(0).text()).hasValue(""); + assertThat(signatureOf(parts.get(0))).isEqualTo("signature"); + assertThat(parts.get(1).thought()).hasValue(true); + assertThat(parts.get(1).text()).isEmpty(); + assertThat(signatureOf(parts.get(1))).isEqualTo("redacted-data"); + assertThat(parts.get(2).thought()).isEmpty(); + assertThat(parts.get(2).text()).hasValue("4"); + } + + @Test + public void generateContent_toolTurn_sendsThinkingBlocksBackUnchanged() { + Message toolUseReply = + message( + thinkingBlock("", "signature-1"), + redactedThinkingBlock("redacted-data"), + textBlock("Let me check."), + thinkingBlock("Checking the weather.", "signature-2"), + ContentBlock.ofToolUse( + ToolUseBlock.builder() + .id("toolu_1") + .name("getWeather") + .input(JsonValue.from(ImmutableMap.of("city", "Seoul"))) + .caller(DirectCaller.builder().build()) + .build())); + Message finalReply = message(textBlock("It is sunny.")); + when(messageService.create(any(MessageCreateParams.class))) + .thenReturn(toolUseReply, finalReply); + Content modelTurn = + claude + .generateContent(request(userText("Weather in Seoul?")), false) + .blockingFirst() + .content() + .get(); + Content toolResult = + Content.builder() + .role("user") + .parts( + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("toolu_1") + .name("getWeather") + .response(ImmutableMap.of("result", "sunny")) + .build()) + .build()) + .build(); + + claude + .generateContent(request(userText("Weather in Seoul?"), modelTurn, toolResult), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService, times(2)).create(params.capture()); + List blocks = + params.getAllValues().get(1).messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(5); + assertThat(blocks.get(0).asThinking().thinking()).isEmpty(); + assertThat(blocks.get(0).asThinking().signature()).isEqualTo("signature-1"); + assertThat(blocks.get(1).asRedactedThinking().data()).isEqualTo("redacted-data"); + assertThat(blocks.get(2).asText().text()).isEqualTo("Let me check."); + assertThat(blocks.get(3).asThinking().thinking()).isEqualTo("Checking the weather."); + assertThat(blocks.get(3).asThinking().signature()).isEqualTo("signature-2"); + assertThat(blocks.get(4).asToolUse().id()).isEqualTo("toolu_1"); + } + + @Test + public void generateContent_emptyThoughtWithoutSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().text("").thought(true).build(), + Part.builder().text("").thought(true).thoughtSignature(new byte[0]).build(), + Part.fromText("Hello.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + // Neither thought has a signature to send back, and Anthropic rejects empty text blocks. + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(1); + assertThat(blocks.get(0).asText().text()).isEqualTo("Hello."); + } + + @Test + public void generateContent_thoughtWithBinarySignature_isSentAsText() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + // Another model (e.g. Gemini) stores a binary signature, which Anthropic could not decode. + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder() + .text("Looking up the weather.") + .thought(true) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build(), + Part.fromText("It is sunny.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Thanks")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(2); + assertThat(blocks.get(0).asText().text()).isEqualTo("Looking up the weather."); + assertThat(blocks.get(1).asText().text()).isEqualTo("It is sunny."); + } + + @Test + public void generateContent_thoughtWithoutTextOrUsableSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().thought(true).build(), + Part.builder() + .thought(true) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build(), + Part.fromText("Hello.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(1); + assertThat(blocks.get(0).asText().text()).isEqualTo("Hello."); + } + + @Test + public void generateContent_thoughtFlaggedCallsAndResponses_areSentAsToolUseAndToolResult() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + byte[] signature = "signature".getBytes(UTF_8); + // A call or response flagged as a thought is still sent, whatever text or signature it has. + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder() + .thought(true) + .thoughtSignature(signature) + .functionCall(weatherCall("toolu_1", "Seoul")) + .build(), + Part.builder() + .text("") + .thought(true) + .functionCall(weatherCall("toolu_2", "Busan")) + .build(), + Part.builder() + .text("Checking Jeju.") + .thought(true) + .thoughtSignature(signature) + .functionCall(weatherCall("toolu_3", "Jeju")) + .build()) + .build(); + Content toolResults = + Content.builder() + .role("user") + .parts( + Part.builder() + .thought(true) + .thoughtSignature(signature) + .functionResponse(weatherResult("toolu_1", "sunny")) + .build(), + Part.builder() + .text("") + .thought(true) + .functionResponse(weatherResult("toolu_2", "rainy")) + .build(), + Part.builder() + .text("Got Jeju.") + .thought(true) + .thoughtSignature(signature) + .functionResponse(weatherResult("toolu_3", "windy")) + .build()) + .build(); + + claude + .generateContent(request(userText("Weather?"), modelTurn, toolResults), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List calls = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(calls).hasSize(3); + assertThat(calls.get(0).asToolUse().id()).isEqualTo("toolu_1"); + assertThat(calls.get(0).asToolUse().name()).isEqualTo("getWeather"); + assertThat(calls.get(0).asToolUse()._input()) + .isEqualTo(JsonValue.from(ImmutableMap.of("city", "Seoul"))); + assertThat(calls.get(1).asToolUse().id()).isEqualTo("toolu_2"); + assertThat(calls.get(1).asToolUse().name()).isEqualTo("getWeather"); + assertThat(calls.get(1).asToolUse()._input()) + .isEqualTo(JsonValue.from(ImmutableMap.of("city", "Busan"))); + assertThat(calls.get(2).asToolUse().id()).isEqualTo("toolu_3"); + assertThat(calls.get(2).asToolUse().name()).isEqualTo("getWeather"); + assertThat(calls.get(2).asToolUse()._input()) + .isEqualTo(JsonValue.from(ImmutableMap.of("city", "Jeju"))); + List results = params.getValue().messages().get(2).content().asBlockParams(); + assertThat(results).hasSize(3); + assertThat(results.get(0).asToolResult().toolUseId()).isEqualTo("toolu_1"); + assertThat(results.get(0).asToolResult().content().get().asString()).isEqualTo("sunny"); + assertThat(results.get(1).asToolResult().toolUseId()).isEqualTo("toolu_2"); + assertThat(results.get(1).asToolResult().content().get().asString()).isEqualTo("rainy"); + assertThat(results.get(2).asToolResult().toolUseId()).isEqualTo("toolu_3"); + assertThat(results.get(2).asToolResult().content().get().asString()).isEqualTo("windy"); + } + + @Test + public void generateContent_contentFreeSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + // Another model (e.g. Gemini) leaves signatures on parts that carry nothing else. + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().text("").thoughtSignature(new byte[] {(byte) 0xC3, 0x28}).build(), + Part.builder().thoughtSignature("signature".getBytes(UTF_8)).build(), + Part.fromText("Hello.")) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(1); + assertThat(blocks.get(0).asText().text()).isEqualTo("Hello."); + } + + @Test + public void generateContent_signatureBesideContent_isKept() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder() + .functionCall(weatherCall("toolu_1", "Seoul")) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build(), + Part.builder() + .text("Checking the weather.") + .thought(true) + .thoughtSignature("signature-1".getBytes(UTF_8)) + .build(), + Part.builder().thought(true).thoughtSignature("redacted-1".getBytes(UTF_8)).build(), + // Metadata is not content, so this part is still dropped. + Part.builder() + .thoughtSignature("signature-2".getBytes(UTF_8)) + .partMetadata(ImmutableMap.of("source", "test")) + .build()) + .build(); + Content toolResult = + Content.builder() + .role("user") + .parts(Part.builder().functionResponse(weatherResult("toolu_1", "sunny")).build()) + .build(); + + claude + .generateContent(request(userText("Weather?"), modelTurn, toolResult), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List blocks = params.getValue().messages().get(1).content().asBlockParams(); + assertThat(blocks).hasSize(3); + assertThat(blocks.get(0).asToolUse().id()).isEqualTo("toolu_1"); + assertThat(blocks.get(1).asThinking().signature()).isEqualTo("signature-1"); + assertThat(blocks.get(2).asRedactedThinking().data()).isEqualTo("redacted-1"); + } + + @Test + public void generateContent_turnOfOnlyDroppedThoughts_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts( + Part.builder().thought(true).build(), + Part.builder() + .thought(true) + .thoughtSignature(new byte[] {(byte) 0xC3, 0x28}) + .build()) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List messages = params.getValue().messages(); + assertThat(messages).hasSize(2); + assertThat(messages.get(0).content().asBlockParams().get(0).asText().text()).isEqualTo("Hi"); + assertThat(messages.get(1).content().asBlockParams().get(0).asText().text()).isEqualTo("Bye"); + } + + @Test + public void generateContent_turnOfOnlyContentFreeSignature_isNotSent() { + Message reply = message(textBlock("ok")); + when(messageService.create(any(MessageCreateParams.class))).thenReturn(reply); + Content modelTurn = + Content.builder() + .role("model") + .parts(Part.builder().thoughtSignature(new byte[] {(byte) 0xC3, 0x28}).build()) + .build(); + + claude + .generateContent(request(userText("Hi"), modelTurn, userText("Bye")), false) + .blockingFirst(); + + ArgumentCaptor params = ArgumentCaptor.forClass(MessageCreateParams.class); + verify(messageService).create(params.capture()); + List messages = params.getValue().messages(); + assertThat(messages).hasSize(2); + assertThat(messages.get(0).content().asBlockParams().get(0).asText().text()).isEqualTo("Hi"); + assertThat(messages.get(1).content().asBlockParams().get(0).asText().text()).isEqualTo("Bye"); + } }