From c825fc643d05b83b77170ff7c60a7e9720e6c6d2 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Wed, 15 Jul 2026 21:11:42 +0800 Subject: [PATCH 1/6] feat(anthropic): support extended thinking round trips --- .../accumulator/ThinkingAccumulator.java | 19 ++- .../accumulator/ReasoningContextTest.java | 49 ++++++ .../formatter/AnthropicMessageConverter.java | 18 ++- .../formatter/AnthropicResponseParser.java | 145 ++++++++++++++++-- .../formatter/AnthropicThinkingMetadata.java | 141 +++++++++++++++++ .../formatter/AnthropicToolsHelper.java | 7 + .../AnthropicMessageConverterTest.java | 76 +++++++++ .../AnthropicResponseParserTest.java | 132 +++++++++++++++- .../formatter/AnthropicToolsHelperTest.java | 27 ++++ docs/v2/en/integration/model/anthropic.md | 11 ++ docs/v2/zh/integration/model/anthropic.md | 10 ++ 11 files changed, 605 insertions(+), 30 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java index 84510ed2d0..8781ef4898 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java @@ -17,6 +17,8 @@ import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.ThinkingBlock; +import java.util.HashMap; +import java.util.Map; /** * Thinking content accumulator for accumulating streaming thinking chunks. @@ -28,15 +30,22 @@ public class ThinkingAccumulator implements ContentAccumulator { private final StringBuilder accumulated = new StringBuilder(); + private final Map metadata = new HashMap<>(); /** * @hidden */ @Override public void add(ThinkingBlock block) { - if (block != null && block.getThinking() != null) { + if (block == null) { + return; + } + if (block.getThinking() != null) { accumulated.append(block.getThinking()); } + if (block.getMetadata() != null && !block.getMetadata().isEmpty()) { + metadata.putAll(block.getMetadata()); + } } /** @@ -44,7 +53,7 @@ public void add(ThinkingBlock block) { */ @Override public boolean hasContent() { - return accumulated.length() > 0; + return accumulated.length() > 0 || !metadata.isEmpty(); } /** @@ -55,7 +64,10 @@ public ContentBlock buildAggregated() { if (!hasContent()) { return null; } - return ThinkingBlock.builder().thinking(accumulated.toString()).build(); + return ThinkingBlock.builder() + .thinking(accumulated.toString()) + .metadata(metadata.isEmpty() ? null : metadata) + .build(); } /** @@ -64,6 +76,7 @@ public ContentBlock buildAggregated() { @Override public void reset() { accumulated.setLength(0); + metadata.clear(); } /** diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java index 83b00def5e..5eb2ec4712 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ReasoningContextTest.java @@ -22,10 +22,12 @@ import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.ChatUsage; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -169,6 +171,53 @@ void testChunksWithoutUsage() { assertNull(resultUsage); } + @Test + @DisplayName("Should preserve metadata-only thinking blocks") + void testMetadataOnlyThinkingBlock() { + ThinkingBlock thinkingBlock = + ThinkingBlock.builder() + .metadata(Map.of("signature", "encrypted-signature")) + .build(); + ChatResponse chunk = + ChatResponse.builder().id("msg-1").content(List.of(thinkingBlock)).build(); + + context.processChunk(chunk); + + Msg msg = context.buildFinalMessage(); + assertNotNull(msg); + ThinkingBlock result = msg.getFirstContentBlock(ThinkingBlock.class); + assertNotNull(result); + assertEquals("", result.getThinking()); + assertEquals("encrypted-signature", result.getMetadata().get("signature")); + } + + @Test + @DisplayName("Should merge thinking text and metadata across chunks") + void testThinkingTextAndMetadataAccumulation() { + ChatResponse textChunk = + ChatResponse.builder() + .id("msg-1") + .content(List.of(ThinkingBlock.builder().thinking("Reasoning").build())) + .build(); + ChatResponse metadataChunk = + ChatResponse.builder() + .id("msg-1") + .content( + List.of( + ThinkingBlock.builder() + .metadata(Map.of("signature", "signature-123")) + .build())) + .build(); + + context.processChunk(textChunk); + context.processChunk(metadataChunk); + + ThinkingBlock result = + context.buildFinalMessage().getFirstContentBlock(ThinkingBlock.class); + assertEquals("Reasoning", result.getThinking()); + assertEquals("signature-123", result.getMetadata().get("signature")); + } + @Test @DisplayName("Should handle mixed chunks with and without usage") void testMixedChunksWithAndWithoutUsage() { diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java index 60ec2f1035..cb0d68f340 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java @@ -257,12 +257,18 @@ private MessageParam convertMessageContent( ContentBlockParam.ofText( TextBlockParam.builder().text(hb.getHint()).build())); } else if (block instanceof ThinkingBlock thinkingBlock) { - // Anthropic supports thinking blocks natively - contentBlocks.add( - ContentBlockParam.ofText( - TextBlockParam.builder() - .text(thinkingBlock.getThinking()) - .build())); + List nativeThinkingBlocks = + AnthropicThinkingMetadata.toContentBlockParams(thinkingBlock); + if (!nativeThinkingBlocks.isEmpty()) { + contentBlocks.addAll(nativeThinkingBlocks); + } else { + // Preserve compatibility with thinking blocks produced by other providers. + contentBlocks.add( + ContentBlockParam.ofText( + TextBlockParam.builder() + .text(thinkingBlock.getThinking()) + .build())); + } } else if (block instanceof ImageBlock ib) { try { ImageBlockParam imageParam = mediaConverter.convertImageBlock(ib); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java index e223d0d647..5163e72906 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java @@ -29,6 +29,7 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -50,7 +51,9 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { List contentBlocks = new ArrayList<>(); // Process content blocks - for (var block : message.content()) { + for (int index = 0; index < message.content().size(); index++) { + int contentIndex = index; + var block = message.content().get(index); // Text block block.text() .ifPresent( @@ -83,6 +86,25 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { contentBlocks.add( ThinkingBlock.builder() .thinking(thinking.thinking()) + .metadata( + AnthropicThinkingMetadata.thinking( + contentIndex, + thinking.thinking(), + thinking.signature())) + .build())); + + // Redacted thinking block + block.redactedThinking() + .ifPresent( + redactedThinking -> + contentBlocks.add( + ThinkingBlock.builder() + .metadata( + AnthropicThinkingMetadata + .redactedThinking( + contentIndex, + redactedThinking + .data())) .build())); } @@ -102,23 +124,38 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { */ public static Flux parseStreamEvents( Flux eventFlux, Instant startTime) { - return eventFlux - .flatMap( - event -> { - try { - return Flux.just(parseStreamEvent(event, startTime)); - } catch (Exception e) { - log.warn("Error parsing stream event: {}", e.getMessage()); - return Flux.empty(); - } - }) - .filter(response -> response != null && !response.getContent().isEmpty()); + return Flux.defer( + () -> { + StreamState streamState = new StreamState(); + return eventFlux + .concatMap( + event -> { + try { + return Flux.just( + parseStreamEvent( + event, startTime, streamState)); + } catch (Exception e) { + log.warn( + "Error parsing stream event: {}", + e.getMessage()); + return Flux.empty(); + } + }) + .filter( + response -> + response != null && !response.getContent().isEmpty()); + }); } /** * Parse single stream event. */ private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instant startTime) { + return parseStreamEvent(event, startTime, new StreamState()); + } + + private static ChatResponse parseStreamEvent( + RawMessageStreamEvent event, Instant startTime, StreamState streamState) { List contentBlocks = new ArrayList<>(); ChatUsage usage = null; String messageId = null; @@ -144,10 +181,28 @@ private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instan .delta() .thinking() .ifPresent( - thinkingDelta -> + thinkingDelta -> { + streamState.appendThinking( + deltaEvent.index(), thinkingDelta.thinking()); + contentBlocks.add( + ThinkingBlock.builder() + .thinking(thinkingDelta.thinking()) + .build()); + }); + + deltaEvent + .delta() + .signature() + .ifPresent( + signatureDelta -> contentBlocks.add( ThinkingBlock.builder() - .thinking(thinkingDelta.thinking()) + .metadata( + AnthropicThinkingMetadata.thinking( + deltaEvent.index(), + streamState.getThinking( + deltaEvent.index()), + signatureDelta.signature())) .build())); // Input JSON delta (tool calling) @@ -171,6 +226,42 @@ private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instan if (event.isContentBlockStart()) { var startEvent = event.asContentBlockStart(); + startEvent + .contentBlock() + .thinking() + .ifPresent( + thinking -> { + streamState.appendThinking(startEvent.index(), thinking.thinking()); + if (!thinking.thinking().isEmpty() + || !thinking.signature().isEmpty()) { + ThinkingBlock.Builder thinkingBuilder = + ThinkingBlock.builder().thinking(thinking.thinking()); + if (!thinking.signature().isEmpty()) { + thinkingBuilder.metadata( + AnthropicThinkingMetadata.thinking( + startEvent.index(), + thinking.thinking(), + thinking.signature())); + } + contentBlocks.add(thinkingBuilder.build()); + } + }); + + startEvent + .contentBlock() + .redactedThinking() + .ifPresent( + redactedThinking -> + contentBlocks.add( + ThinkingBlock.builder() + .metadata( + AnthropicThinkingMetadata + .redactedThinking( + startEvent.index(), + redactedThinking + .data())) + .build())); + startEvent .contentBlock() .toolUse() @@ -186,6 +277,10 @@ private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instan }); } + if (event.isContentBlockStop()) { + streamState.remove(event.asContentBlockStop().index()); + } + // Message delta - usage information if (event.isMessageDelta()) { var messageDelta = event.asMessageDelta(); @@ -199,6 +294,28 @@ private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instan return ChatResponse.builder().id(messageId).content(contentBlocks).usage(usage).build(); } + private static final class StreamState { + + private final Map thinkingByIndex = new HashMap<>(); + + private void appendThinking(long index, String thinking) { + if (thinking != null && !thinking.isEmpty()) { + thinkingByIndex + .computeIfAbsent(index, ignored -> new StringBuilder()) + .append(thinking); + } + } + + private String getThinking(long index) { + StringBuilder thinking = thinkingByIndex.get(index); + return thinking != null ? thinking.toString() : ""; + } + + private void remove(long index) { + thinkingByIndex.remove(index); + } + } + /** * Parse JsonValue to Map for tool input. */ diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java new file mode 100644 index 0000000000..822ab36a3e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java @@ -0,0 +1,141 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.anthropic.formatter; + +import com.anthropic.models.messages.ContentBlockParam; +import com.anthropic.models.messages.RedactedThinkingBlockParam; +import com.anthropic.models.messages.ThinkingBlockParam; +import io.agentscope.core.message.ThinkingBlock; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** Stores Anthropic thinking blocks in provider-specific {@link ThinkingBlock} metadata. */ +final class AnthropicThinkingMetadata { + + private static final String KEY_PREFIX = "anthropicThinkingBlock:"; + private static final String TYPE = "type"; + private static final String THINKING = "thinking"; + private static final String SIGNATURE = "signature"; + private static final String DATA = "data"; + private static final String TYPE_THINKING = "thinking"; + private static final String TYPE_REDACTED_THINKING = "redacted_thinking"; + + private AnthropicThinkingMetadata() {} + + static Map thinking(long index, String thinking, String signature) { + Map value = new LinkedHashMap<>(); + value.put(TYPE, TYPE_THINKING); + value.put(THINKING, thinking != null ? thinking : ""); + value.put(SIGNATURE, signature); + return Map.of(key(index), value); + } + + static Map redactedThinking(long index, String data) { + Map value = new LinkedHashMap<>(); + value.put(TYPE, TYPE_REDACTED_THINKING); + value.put(DATA, data); + return Map.of(key(index), value); + } + + static List toContentBlockParams(ThinkingBlock block) { + if (block == null || block.getMetadata() == null || block.getMetadata().isEmpty()) { + return List.of(); + } + + List> storedBlocks = + block.getMetadata().entrySet().stream() + .filter( + entry -> + entry.getKey() != null + && entry.getValue() != null + && entry.getKey().startsWith(KEY_PREFIX)) + .map( + entry -> + parseIndex(entry.getKey()) + .map(index -> Map.entry(index, entry.getValue())) + .orElse(null)) + .filter(entry -> entry != null) + .sorted(Comparator.comparingLong(Map.Entry::getKey)) + .toList(); + + if (storedBlocks.isEmpty()) { + return List.of(); + } + + List result = new ArrayList<>(storedBlocks.size()); + for (Map.Entry storedBlock : storedBlocks) { + Optional converted = convert(storedBlock.getValue()); + if (converted.isEmpty()) { + return List.of(); + } + result.add(converted.get()); + } + return result; + } + + private static Optional convert(Object value) { + if (!(value instanceof Map map)) { + return Optional.empty(); + } + + String type = stringValue(map.get(TYPE)); + if (TYPE_THINKING.equals(type)) { + String thinking = stringValue(map.get(THINKING)); + String signature = stringValue(map.get(SIGNATURE)); + if (thinking == null || signature == null || signature.isEmpty()) { + return Optional.empty(); + } + return Optional.of( + ContentBlockParam.ofThinking( + ThinkingBlockParam.builder() + .thinking(thinking) + .signature(signature) + .build())); + } + + if (TYPE_REDACTED_THINKING.equals(type)) { + String data = stringValue(map.get(DATA)); + if (data == null || data.isEmpty()) { + return Optional.empty(); + } + return Optional.of( + ContentBlockParam.ofRedactedThinking( + RedactedThinkingBlockParam.builder().data(data).build())); + } + + return Optional.empty(); + } + + private static String key(long index) { + return KEY_PREFIX + index; + } + + private static Optional parseIndex(String key) { + try { + return Optional.of(Long.parseLong(key.substring(KEY_PREFIX.length()))); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + private static String stringValue(Object value) { + return value instanceof String string ? string : null; + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java index e335b088f1..83561436c6 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java @@ -144,6 +144,13 @@ public static void applyOptions( builder.maxTokens(maxTokens); } + // Extended thinking + Integer thinkingBudget = + getOption(options, defaultOptions, GenerateOptions::getThinkingBudget); + if (thinkingBudget != null) { + builder.enabledThinking(thinkingBudget); + } + // Apply additional parameters (merge defaultOptions first, then options to override) // Apply additional headers applyAdditionalHeaders(builder, defaultOptions); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java index 527c7ba555..da52859366 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java @@ -33,6 +33,7 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -203,6 +204,81 @@ void testConvertThinkingBlock() { assertEquals("Let me think...", blocks.get(0).asText().text()); } + @Test + void testConvertAnthropicThinkingBlockWithSignature() { + ThinkingBlock thinkingBlock = + ThinkingBlock.builder() + .thinking("Let me think...") + .metadata( + AnthropicThinkingMetadata.thinking( + 0, "Let me think...", "signature-123")) + .build(); + Msg msg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content(List.of(thinkingBlock)) + .build(); + + List blocks = + converter.convert(List.of(msg)).get(0).content().asBlockParams(); + + assertEquals(1, blocks.size()); + assertTrue(blocks.get(0).isThinking()); + assertEquals("Let me think...", blocks.get(0).asThinking().thinking()); + assertEquals("signature-123", blocks.get(0).asThinking().signature()); + } + + @Test + void testConvertAnthropicThinkingBlocksInOriginalOrder() { + Map metadata = new HashMap<>(); + metadata.putAll(AnthropicThinkingMetadata.redactedThinking(2, "encrypted-data")); + metadata.putAll(AnthropicThinkingMetadata.thinking(0, "Reasoning", "signature-123")); + ThinkingBlock thinkingBlock = + ThinkingBlock.builder().thinking("Reasoning").metadata(metadata).build(); + Msg msg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content(List.of(thinkingBlock)) + .build(); + + List blocks = + converter.convert(List.of(msg)).get(0).content().asBlockParams(); + + assertEquals(2, blocks.size()); + assertTrue(blocks.get(0).isThinking()); + assertEquals("signature-123", blocks.get(0).asThinking().signature()); + assertTrue(blocks.get(1).isRedactedThinking()); + assertEquals("encrypted-data", blocks.get(1).asRedactedThinking().data()); + } + + @Test + void testConvertAnthropicThinkingBlocksAfterJsonRoundTrip() { + Map metadata = new HashMap<>(); + metadata.putAll(AnthropicThinkingMetadata.thinking(0, "Reasoning", "signature-123")); + metadata.putAll(AnthropicThinkingMetadata.redactedThinking(1, "encrypted-data")); + Msg original = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking("Reasoning") + .metadata(metadata) + .build())) + .build(); + Msg restored = jsonCodec.fromJson(jsonCodec.toJson(original), Msg.class); + + List blocks = + converter.convert(List.of(restored)).get(0).content().asBlockParams(); + + assertEquals(2, blocks.size()); + assertEquals("signature-123", blocks.get(0).asThinking().signature()); + assertEquals("encrypted-data", blocks.get(1).asRedactedThinking().data()); + } + @Test void testConvertToolUseBlock() { Map input = Map.of("query", "test"); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java index d2f7317cbd..c6506d70be 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java @@ -24,12 +24,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.anthropic.core.ObjectMappers; import com.anthropic.models.messages.ContentBlock; import com.anthropic.models.messages.Message; import com.anthropic.models.messages.RawContentBlockDeltaEvent; +import com.anthropic.models.messages.RawContentBlockStartEvent; import com.anthropic.models.messages.RawMessageStartEvent; import com.anthropic.models.messages.RawMessageStreamEvent; import com.anthropic.models.messages.Usage; +import io.agentscope.core.agent.accumulator.ReasoningContext; +import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ThinkingBlock; import io.agentscope.core.message.ToolUseBlock; @@ -51,13 +55,24 @@ private static com.anthropic.models.messages.TextBlock mockTextBlock() { } private static com.anthropic.models.messages.ThinkingBlock mockThinkingBlock() { - return mock(com.anthropic.models.messages.ThinkingBlock.class); + var thinkingBlock = mock(com.anthropic.models.messages.ThinkingBlock.class); + when(thinkingBlock.signature()).thenReturn("signature-123"); + return thinkingBlock; } private static com.anthropic.models.messages.ToolUseBlock mockToolUseBlock() { return mock(com.anthropic.models.messages.ToolUseBlock.class); } + private static ContentBlock mockContentBlock() { + ContentBlock contentBlock = mock(ContentBlock.class); + when(contentBlock.text()).thenReturn(Optional.empty()); + when(contentBlock.toolUse()).thenReturn(Optional.empty()); + when(contentBlock.thinking()).thenReturn(Optional.empty()); + when(contentBlock.redactedThinking()).thenReturn(Optional.empty()); + return contentBlock; + } + /** * Use reflection to call private parseStreamEvent method for unit testing individual event * types. @@ -76,7 +91,7 @@ void testParseMessageWithTextBlock() { // Create mock Message with text content Message message = mock(Message.class); Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); + ContentBlock contentBlock = mockContentBlock(); var textBlock = mockTextBlock(); when(message.id()).thenReturn("msg_123"); @@ -111,7 +126,7 @@ void testParseMessageWithToolUseBlock() { // Note: We use null input to avoid Kotlin reflection issues with JsonValue mocking Message message = mock(Message.class); Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); + ContentBlock contentBlock = mockContentBlock(); var toolUseBlock = mockToolUseBlock(); when(message.id()).thenReturn("msg_456"); @@ -148,7 +163,7 @@ void testParseMessageWithThinkingBlock() { // Create mock Message with thinking content Message message = mock(Message.class); Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); + ContentBlock contentBlock = mockContentBlock(); var thinkingBlock = mockThinkingBlock(); when(message.id()).thenReturn("msg_789"); @@ -171,6 +186,53 @@ void testParseMessageWithThinkingBlock() { ThinkingBlock parsedThinking = assertInstanceOf(ThinkingBlock.class, response.getContent().get(0)); assertEquals("Let me think about this...", parsedThinking.getThinking()); + assertEquals( + "signature-123", + AnthropicThinkingMetadata.toContentBlockParams(parsedThinking) + .get(0) + .asThinking() + .signature()); + } + + @Test + void testParseMessagePreservesThinkingAndRedactedBlocks() throws Exception { + String json = + """ + { + "id": "msg_reasoning", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "Reasoning", + "signature": "signature-123" + }, + { + "type": "redacted_thinking", + "data": "encrypted-data" + } + ], + "model": "claude-sonnet-4-5-20250929", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 10, "output_tokens": 20} + } + """; + Message message = ObjectMappers.jsonMapper().readValue(json, Message.class); + + ChatResponse response = AnthropicResponseParser.parseMessage(message, Instant.now()); + + assertEquals(2, response.getContent().size()); + ThinkingBlock thinking = + assertInstanceOf(ThinkingBlock.class, response.getContent().get(0)); + ThinkingBlock redacted = + assertInstanceOf(ThinkingBlock.class, response.getContent().get(1)); + assertTrue(AnthropicThinkingMetadata.toContentBlockParams(thinking).get(0).isThinking()); + assertTrue( + AnthropicThinkingMetadata.toContentBlockParams(redacted) + .get(0) + .isRedactedThinking()); } @Test @@ -179,10 +241,10 @@ void testParseMessageWithMixedContent() { Message message = mock(Message.class); Usage usage = mock(Usage.class); - ContentBlock textContentBlock = mock(ContentBlock.class); + ContentBlock textContentBlock = mockContentBlock(); var textBlock = mockTextBlock(); - ContentBlock toolContentBlock = mock(ContentBlock.class); + ContentBlock toolContentBlock = mockContentBlock(); var toolUseBlock = mockToolUseBlock(); when(message.id()).thenReturn("msg_mixed"); @@ -241,7 +303,7 @@ void testParseMessageWithNullToolInput() { // Create mock Message with null tool input Message message = mock(Message.class); Usage usage = mock(Usage.class); - ContentBlock contentBlock = mock(ContentBlock.class); + ContentBlock contentBlock = mockContentBlock(); var toolUseBlock = mockToolUseBlock(); when(message.id()).thenReturn("msg_null_input"); @@ -337,6 +399,62 @@ void testParseStreamEventThinkingDelta() throws Exception { assertNull(response.getUsage()); } + @Test + void testParseStreamPreservesSignatureAndRedactedThinking() { + RawMessageStreamEvent firstThinking = + RawMessageStreamEvent.ofContentBlockDelta( + RawContentBlockDeltaEvent.builder() + .index(0) + .thinkingDelta("Let me ") + .build()); + RawMessageStreamEvent secondThinking = + RawMessageStreamEvent.ofContentBlockDelta( + RawContentBlockDeltaEvent.builder() + .index(0) + .thinkingDelta("reason") + .build()); + RawMessageStreamEvent signature = + RawMessageStreamEvent.ofContentBlockDelta( + RawContentBlockDeltaEvent.builder() + .index(0) + .signatureDelta("signature-123") + .build()); + RawMessageStreamEvent redacted = + RawMessageStreamEvent.ofContentBlockStart( + RawContentBlockStartEvent.builder() + .index(1) + .redactedThinkingContentBlock("encrypted-data") + .build()); + + Flux responses = + AnthropicResponseParser.parseStreamEvents( + Flux.just(firstThinking, secondThinking, signature, redacted), + Instant.now()); + + StepVerifier.create(responses.collectList()) + .assertNext( + chunks -> { + ReasoningContext context = new ReasoningContext("Assistant"); + chunks.forEach(context::processChunk); + Msg message = context.buildFinalMessage(); + ThinkingBlock thinking = + message.getFirstContentBlock(ThinkingBlock.class); + + assertEquals("Let me reason", thinking.getThinking()); + var nativeBlocks = + AnthropicThinkingMetadata.toContentBlockParams(thinking); + assertEquals(2, nativeBlocks.size()); + assertTrue(nativeBlocks.get(0).isThinking()); + assertEquals( + "signature-123", nativeBlocks.get(0).asThinking().signature()); + assertTrue(nativeBlocks.get(1).isRedactedThinking()); + assertEquals( + "encrypted-data", + nativeBlocks.get(1).asRedactedThinking().data()); + }) + .verifyComplete(); + } + @Test void testParseStreamEventUnknownType() throws Exception { // Test unknown event type - should return empty response diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java index c0b7f90e85..e357bde51b 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java @@ -240,6 +240,33 @@ void testApplyOptionsWithMaxTokens() { assertEquals(2048, params.maxTokens()); } + @Test + void testApplyOptionsWithThinkingBudget() { + MessageCreateParams.Builder builder = createBuilder(); + + GenerateOptions options = GenerateOptions.builder().thinkingBudget(2048).build(); + + AnthropicToolsHelper.applyOptions(builder, options, null); + + MessageCreateParams params = builder.build(); + assertTrue(params.thinking().isPresent()); + assertTrue(params.thinking().get().isEnabled()); + assertEquals(2048L, params.thinking().get().asEnabled().budgetTokens()); + } + + @Test + void testApplyOptionsThinkingBudgetOverridesDefault() { + MessageCreateParams.Builder builder = createBuilder(); + + GenerateOptions options = GenerateOptions.builder().thinkingBudget(2048).build(); + GenerateOptions defaultOptions = GenerateOptions.builder().thinkingBudget(1024).build(); + + AnthropicToolsHelper.applyOptions(builder, options, defaultOptions); + + MessageCreateParams params = builder.build(); + assertEquals(2048L, params.thinking().orElseThrow().asEnabled().budgetTokens()); + } + @Test void testApplyOptionsWithAllParameters() { MessageCreateParams.Builder builder = createBuilder(); diff --git a/docs/v2/en/integration/model/anthropic.md b/docs/v2/en/integration/model/anthropic.md index 1a008d2bf9..a65a081e56 100644 --- a/docs/v2/en/integration/model/anthropic.md +++ b/docs/v2/en/integration/model/anthropic.md @@ -28,15 +28,26 @@ ReActAgent agent = ReActAgent.builder() Use the builder when you need a custom endpoint, formatter, transport, prompt caching, thinking, or generation options: ```java +import io.agentscope.core.model.GenerateOptions; import io.agentscope.extensions.model.anthropic.AnthropicChatModel; AnthropicChatModel model = AnthropicChatModel.builder() .apiKey(System.getenv("ANTHROPIC_API_KEY")) .modelName("claude-sonnet-4.5") .stream(true) + .defaultOptions(GenerateOptions.builder() + .maxTokens(4096) + .thinkingBudget(2048) + .build()) .build(); ``` +Setting `thinkingBudget` enables Anthropic extended thinking with the corresponding +`budget_tokens` value. For models that support manual thinking budgets, the budget must be lower +than `maxTokens`. Anthropic extended thinking is incompatible with custom `temperature`, `topK`, +and forced tool choice settings; consult the Anthropic model documentation for model-specific +requirements. + ## Spring Boot Spring Boot applications can use the Anthropic starter: diff --git a/docs/v2/zh/integration/model/anthropic.md b/docs/v2/zh/integration/model/anthropic.md index 5c880e1d71..58d6d9b0ee 100644 --- a/docs/v2/zh/integration/model/anthropic.md +++ b/docs/v2/zh/integration/model/anthropic.md @@ -28,15 +28,25 @@ ReActAgent agent = ReActAgent.builder() 需要自定义 endpoint、formatter、transport、prompt caching、thinking 或生成参数时,使用 builder: ```java +import io.agentscope.core.model.GenerateOptions; import io.agentscope.extensions.model.anthropic.AnthropicChatModel; AnthropicChatModel model = AnthropicChatModel.builder() .apiKey(System.getenv("ANTHROPIC_API_KEY")) .modelName("claude-sonnet-4.5") .stream(true) + .defaultOptions(GenerateOptions.builder() + .maxTokens(4096) + .thinkingBudget(2048) + .build()) .build(); ``` +设置 `thinkingBudget` 会启用 Anthropic extended thinking,并映射为对应的 +`budget_tokens`。对于支持手动 thinking budget 的模型,该值必须小于 `maxTokens`。 +Anthropic extended thinking 不兼容自定义 `temperature`、`topK` 和强制 tool choice;不同 +模型的具体要求请参考 Anthropic 模型文档。 + ## Spring Boot Spring Boot 应用可以使用 Anthropic starter: From 705b4fe5517ca9d90bf4759f63505da02d92698c Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Wed, 15 Jul 2026 21:36:15 +0800 Subject: [PATCH 2/6] fix(anthropic): preserve thinking with tool calls --- .../formatter/AnthropicMessageConverter.java | 56 ++++++++++---- .../formatter/AnthropicToolsHelper.java | 5 +- .../AnthropicMessageConverterTest.java | 74 +++++++++++++++++++ .../formatter/AnthropicToolsHelperTest.java | 27 ++++++- 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java index cb0d68f340..6885d4269a 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java @@ -85,7 +85,11 @@ public List convert(List messages) { && msg.getContentBlocks(ToolUseBlock.class).size() > 1) { SplitToolResultSequence splitResults = collectSplitToolResults(messages, i + 1); if (shouldSplitParallelToolCalls(msg, splitResults)) { - result.addAll(convertParallelToolCalls(msg, splitResults)); + if (hasNativeThinkingBlocks(msg)) { + result.addAll(convertParallelToolCallsWithThinking(msg, splitResults)); + } else { + result.addAll(convertParallelToolCalls(msg, splitResults)); + } i += splitResults.consumedMessages(); continue; } @@ -198,6 +202,24 @@ private boolean hasNonToolResultContent(Msg msg) { return msg.getContent().stream().anyMatch(block -> !(block instanceof ToolResultBlock)); } + private boolean hasNativeThinkingBlocks(Msg msg) { + return msg.getContentBlocks(ThinkingBlock.class).stream() + .anyMatch( + block -> !AnthropicThinkingMetadata.toContentBlockParams(block).isEmpty()); + } + + /** Preserve the original signed assistant message and group its parallel tool results. */ + private List convertParallelToolCallsWithThinking( + Msg assistantMsg, SplitToolResultSequence splitResults) { + MessageParam assistantParam = + convertMessageContent(assistantMsg, assistantMsg.getContent(), false); + List toolResults = + assistantMsg.getContentBlocks(ToolUseBlock.class).stream() + .map(toolUse -> splitResults.resultsById().get(toolUse.getId())) + .toList(); + return List.of(assistantParam, convertToolResults(toolResults)); + } + /** * Expand one assistant message with parallel tool uses into alternating Anthropic messages. * @@ -329,6 +351,22 @@ private MessageParam convertMessageContent( * Convert tool result to separate user message. */ private MessageParam convertToolResult(ToolResultBlock toolResult) { + return convertToolResults(List.of(toolResult)); + } + + private MessageParam convertToolResults(List toolResults) { + List toolResultParams = + toolResults.stream() + .map(this::convertToolResultBlock) + .map(ContentBlockParam::ofToolResult) + .toList(); + return MessageParam.builder() + .role(Role.USER) + .content(MessageParam.Content.ofBlockParams(toolResultParams)) + .build(); + } + + private ToolResultBlockParam convertToolResultBlock(ToolResultBlock toolResult) { // Convert output to content blocks List blocks = new ArrayList<>(); @@ -376,19 +414,9 @@ private MessageParam convertToolResult(ToolResultBlock toolResult) { TextBlockParam.builder().text(outputStr).build())); } - // Create tool result block - ToolResultBlockParam toolResultParam = - ToolResultBlockParam.builder() - .toolUseId(toolResult.getId()) - .content(ToolResultBlockParam.Content.ofBlocks(blocks)) - .build(); - - // Wrap in user message - return MessageParam.builder() - .role(Role.USER) - .content( - MessageParam.Content.ofBlockParams( - List.of(ContentBlockParam.ofToolResult(toolResultParam)))) + return ToolResultBlockParam.builder() + .toolUseId(toolResult.getId()) + .content(ToolResultBlockParam.Content.ofBlocks(blocks)) .build(); } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java index 83561436c6..e4909df6c8 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java @@ -17,6 +17,7 @@ import static com.anthropic.models.messages.ToolChoice.ofAny; import static com.anthropic.models.messages.ToolChoice.ofAuto; +import static com.anthropic.models.messages.ToolChoice.ofNone; import static com.anthropic.models.messages.ToolChoice.ofTool; import com.anthropic.core.JsonValue; @@ -25,6 +26,7 @@ import com.anthropic.models.messages.Tool; import com.anthropic.models.messages.ToolChoiceAny; import com.anthropic.models.messages.ToolChoiceAuto; +import com.anthropic.models.messages.ToolChoiceNone; import com.anthropic.models.messages.ToolChoiceTool; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.ToolChoice; @@ -93,8 +95,7 @@ private static void applyToolChoice( if (toolChoice instanceof ToolChoice.Auto) { builder.toolChoice(ofAuto(ToolChoiceAuto.builder().build())); } else if (toolChoice instanceof ToolChoice.None) { - // Anthropic doesn't have None, use Any instead - builder.toolChoice(ofAny(ToolChoiceAny.builder().build())); + builder.toolChoice(ofNone(ToolChoiceNone.builder().build())); } else if (toolChoice instanceof ToolChoice.Required) { // Anthropic doesn't have a direct "required" option, use "any" which forces tool // use diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java index da52859366..8bb6682bf8 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java @@ -492,6 +492,80 @@ void testConvertParallelToolCallsToAlternatingMessages() { result.get(4).content().asBlockParams().get(0).asToolResult().toolUseId()); } + @Test + void testPreserveSignedThinkingWithParallelToolCalls() { + Msg userMsg = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(TextBlock.builder().text("Check two cities.").build()) + .build(); + Msg assistantMsg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking("I should check both cities.") + .metadata( + AnthropicThinkingMetadata.thinking( + 0, + "I should check both cities.", + "signature-123")) + .build(), + ToolUseBlock.builder() + .id("call_1") + .name("get_weather") + .input(Map.of("city", "Beijing")) + .build(), + ToolUseBlock.builder() + .id("call_2") + .name("get_weather") + .input(Map.of("city", "Hangzhou")) + .build())) + .build(); + Msg firstResult = + Msg.builder() + .name("Tool") + .role(MsgRole.TOOL) + .content( + ToolResultBlock.builder() + .id("call_1") + .name("get_weather") + .output(TextBlock.builder().text("Sunny").build()) + .build()) + .build(); + Msg secondResult = + Msg.builder() + .name("Tool") + .role(MsgRole.TOOL) + .content( + ToolResultBlock.builder() + .id("call_2") + .name("get_weather") + .output(TextBlock.builder().text("Cloudy").build()) + .build()) + .build(); + + List result = + converter.convert(List.of(userMsg, assistantMsg, firstResult, secondResult)); + + assertEquals(3, result.size()); + List assistantBlocks = result.get(1).content().asBlockParams(); + assertEquals(3, assistantBlocks.size()); + assertTrue(assistantBlocks.get(0).isThinking()); + assertEquals("signature-123", assistantBlocks.get(0).asThinking().signature()); + assertEquals("call_1", assistantBlocks.get(1).asToolUse().id()); + assertEquals("call_2", assistantBlocks.get(2).asToolUse().id()); + + assertEquals(MessageParam.Role.USER, result.get(2).role()); + List resultBlocks = result.get(2).content().asBlockParams(); + assertEquals(2, resultBlocks.size()); + assertEquals("call_1", resultBlocks.get(0).asToolResult().toolUseId()); + assertEquals("call_2", resultBlocks.get(1).asToolResult().toolUseId()); + } + @Test void testConvertToolResultBlockNullOutput() { // Builder without output() call will have null output, which becomes empty list diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java index e357bde51b..19d7113c9d 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java @@ -156,8 +156,31 @@ void testApplyToolChoiceNone() { MessageCreateParams params = builder.build(); assertTrue(params.toolChoice().isPresent()); - // None maps to "any" in Anthropic - assertTrue(params.toolChoice().get().isAny()); + assertTrue(params.toolChoice().get().isNone()); + } + + @Test + void testApplyThinkingWithToolChoiceNone() { + MessageCreateParams.Builder builder = createBuilder(); + ToolSchema schema = + ToolSchema.builder() + .name("search") + .description("Search") + .parameters(Map.of("type", "object")) + .build(); + GenerateOptions options = + GenerateOptions.builder() + .maxTokens(2048) + .thinkingBudget(1024) + .toolChoice(new ToolChoice.None()) + .build(); + + AnthropicToolsHelper.applyOptions(builder, options, null); + AnthropicToolsHelper.applyTools(builder, List.of(schema), options); + + MessageCreateParams params = builder.build(); + assertTrue(params.thinking().orElseThrow().isEnabled()); + assertTrue(params.toolChoice().orElseThrow().isNone()); } @Test From 69702c96a73223c64edc4838d3b32e4465226c3a Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Wed, 15 Jul 2026 22:11:54 +0800 Subject: [PATCH 3/6] fix(anthropic): harden thinking round trips --- .../accumulator/ThinkingAccumulatorTest.java | 76 +++++++++ .../model/anthropic/AnthropicChatModel.java | 6 +- .../formatter/AnthropicBaseFormatter.java | 20 +-- .../formatter/AnthropicThinkingMetadata.java | 53 ++++-- .../formatter/AnthropicChatFormatterTest.java | 79 +++++++++ .../AnthropicMessageConverterTest.java | 23 +++ .../AnthropicResponseParserTest.java | 160 ++++++++++++++++++ .../AnthropicThinkingMetadataTest.java | 147 ++++++++++++++++ 8 files changed, 531 insertions(+), 33 deletions(-) create mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java new file mode 100644 index 0000000000..91d141e4d0 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agent.accumulator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.message.ThinkingBlock; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link ThinkingAccumulator}. */ +class ThinkingAccumulatorTest { + + private ThinkingAccumulator accumulator; + + @BeforeEach + void setUp() { + accumulator = new ThinkingAccumulator(); + } + + @Test + void testEmptyAccumulator() { + accumulator.add(null); + + assertFalse(accumulator.hasContent()); + assertNull(accumulator.buildAggregated()); + assertEquals("", accumulator.getAccumulated()); + } + + @Test + void testAccumulatesTextAndMetadata() { + accumulator.add(ThinkingBlock.builder().thinking("first ").build()); + accumulator.add( + ThinkingBlock.builder() + .thinking("second") + .metadata(Map.of("signature", "signature-123")) + .build()); + + ThinkingBlock result = (ThinkingBlock) accumulator.buildAggregated(); + + assertTrue(accumulator.hasContent()); + assertEquals("first second", result.getThinking()); + assertEquals("signature-123", result.getMetadata().get("signature")); + } + + @Test + void testMetadataOnlyContentAndReset() { + accumulator.add( + ThinkingBlock.builder().metadata(Map.of("signature", "signature-123")).build()); + + ThinkingBlock result = (ThinkingBlock) accumulator.buildAggregated(); + accumulator.reset(); + + assertEquals("", result.getThinking()); + assertEquals("signature-123", result.getMetadata().get("signature")); + assertFalse(accumulator.hasContent()); + assertEquals("", accumulator.getAccumulated()); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java index cc90a9e60c..efd59f429f 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java @@ -172,10 +172,8 @@ protected Flux doStream( // Apply generation options via formatter formatter.applyOptions(paramsBuilder, options, defaultOptions); - // Add tools if provided - if (tools != null && !tools.isEmpty()) { - formatter.applyTools(paramsBuilder, tools); - } + // Add tools and release formatter request state + formatter.applyTools(paramsBuilder, tools); // Create the request MessageCreateParams params = paramsBuilder.build(); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java index 5d384b3f41..11fb89a500 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java @@ -58,11 +58,11 @@ public void applyOptions( MessageCreateParams.Builder paramsBuilder, GenerateOptions options, GenerateOptions defaultOptions) { - // Save options for applyTools - currentOptions.set(options); - // Apply other options AnthropicToolsHelper.applyOptions(paramsBuilder, options, defaultOptions); + + // Save effective options for applyTools only after option application succeeds + currentOptions.set(GenerateOptions.mergeOptions(options, defaultOptions)); } /** @@ -74,17 +74,13 @@ public void applyOptions( */ @Override public void applyTools(MessageCreateParams.Builder paramsBuilder, List tools) { - if (tools == null || tools.isEmpty()) { + try { + // Use saved options to apply tools with tool choice + AnthropicToolsHelper.applyTools(paramsBuilder, tools, currentOptions.get()); + } finally { + // Clean up even when tool conversion fails currentOptions.remove(); - return; } - - // Use saved options to apply tools with tool choice - GenerateOptions options = currentOptions.get(); - AnthropicToolsHelper.applyTools(paramsBuilder, tools, options); - - // Clean up thread-local storage - currentOptions.remove(); } /** diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java index 822ab36a3e..fbb7aa634d 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java @@ -59,33 +59,39 @@ static List toContentBlockParams(ThinkingBlock block) { return List.of(); } - List> storedBlocks = + List> metadataEntries = block.getMetadata().entrySet().stream() .filter( entry -> entry.getKey() != null - && entry.getValue() != null && entry.getKey().startsWith(KEY_PREFIX)) - .map( - entry -> - parseIndex(entry.getKey()) - .map(index -> Map.entry(index, entry.getValue())) - .orElse(null)) - .filter(entry -> entry != null) - .sorted(Comparator.comparingLong(Map.Entry::getKey)) .toList(); - if (storedBlocks.isEmpty()) { + if (metadataEntries.isEmpty()) { return List.of(); } + List> storedBlocks = new ArrayList<>(metadataEntries.size()); + for (Map.Entry entry : metadataEntries) { + long index = parseIndex(entry.getKey()); + if (entry.getValue() == null) { + throw invalidMetadata(index); + } + storedBlocks.add(Map.entry(index, entry.getValue())); + } + storedBlocks.sort(Comparator.comparingLong(Map.Entry::getKey)); + List result = new ArrayList<>(storedBlocks.size()); + Long previousIndex = null; for (Map.Entry storedBlock : storedBlocks) { - Optional converted = convert(storedBlock.getValue()); - if (converted.isEmpty()) { - return List.of(); + long index = storedBlock.getKey(); + if (previousIndex != null && previousIndex == index) { + throw invalidMetadata(index); } - result.add(converted.get()); + ContentBlockParam converted = + convert(storedBlock.getValue()).orElseThrow(() -> invalidMetadata(index)); + result.add(converted); + previousIndex = index; } return result; } @@ -127,14 +133,27 @@ private static String key(long index) { return KEY_PREFIX + index; } - private static Optional parseIndex(String key) { + private static long parseIndex(String key) { try { - return Optional.of(Long.parseLong(key.substring(KEY_PREFIX.length()))); + long index = Long.parseLong(key.substring(KEY_PREFIX.length())); + if (index < 0 || !key.equals(key(index))) { + throw invalidMetadataKey(key); + } + return index; } catch (NumberFormatException e) { - return Optional.empty(); + throw invalidMetadataKey(key); } } + private static IllegalArgumentException invalidMetadata(long index) { + return new IllegalArgumentException( + "Invalid Anthropic thinking metadata at content index " + index); + } + + private static IllegalArgumentException invalidMetadataKey(String key) { + return new IllegalArgumentException("Invalid Anthropic thinking metadata key: " + key); + } + private static String stringValue(Object value) { return value instanceof String string ? string : null; } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java index f1175a077e..cf6f5676e2 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java @@ -59,6 +59,17 @@ void setUp() { formatter = new AnthropicChatFormatter(); } + private MessageCreateParams.Builder createParamsBuilder() { + return MessageCreateParams.builder() + .model("claude-sonnet-4-5-20250929") + .maxTokens(1024) + .addMessage( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content("test") + .build()); + } + @Test void testFormatSimpleUserMessage() { Msg msg = @@ -360,6 +371,74 @@ void testApplyTools() { assertNotNull(params); } + @Test + void testApplyToolsUsesDefaultToolChoice() { + MessageCreateParams.Builder paramsBuilder = createParamsBuilder(); + ToolSchema searchTool = + ToolSchema.builder() + .name("search") + .description("Search the web") + .parameters(Map.of("type", "object")) + .build(); + GenerateOptions defaultOptions = + GenerateOptions.builder() + .thinkingBudget(1024) + .toolChoice(new ToolChoice.None()) + .build(); + + formatter.applyOptions(paramsBuilder, null, defaultOptions); + formatter.applyTools(paramsBuilder, List.of(searchTool)); + + MessageCreateParams params = paramsBuilder.build(); + assertTrue(params.thinking().orElseThrow().isEnabled()); + assertTrue(params.toolChoice().orElseThrow().isNone()); + } + + @Test + void testApplyToolsRequestToolChoiceOverridesDefault() { + MessageCreateParams.Builder paramsBuilder = createParamsBuilder(); + ToolSchema searchTool = + ToolSchema.builder() + .name("search") + .description("Search the web") + .parameters(Map.of("type", "object")) + .build(); + GenerateOptions options = + GenerateOptions.builder().toolChoice(new ToolChoice.Auto()).build(); + GenerateOptions defaultOptions = + GenerateOptions.builder().toolChoice(new ToolChoice.None()).build(); + + formatter.applyOptions(paramsBuilder, options, defaultOptions); + formatter.applyTools(paramsBuilder, List.of(searchTool)); + + assertTrue(paramsBuilder.build().toolChoice().orElseThrow().isAuto()); + } + + @Test + void testApplyToolsClearsOptionsAfterFailure() { + formatter.applyOptions( + createParamsBuilder(), + GenerateOptions.builder().toolChoice(new ToolChoice.None()).build(), + null); + + assertThrows( + NullPointerException.class, + () -> + formatter.applyTools( + createParamsBuilder(), java.util.Collections.singletonList(null))); + + ToolSchema searchTool = + ToolSchema.builder() + .name("search") + .description("Search the web") + .parameters(Map.of("type", "object")) + .build(); + MessageCreateParams.Builder nextRequest = createParamsBuilder(); + formatter.applyTools(nextRequest, List.of(searchTool)); + + assertTrue(nextRequest.build().toolChoice().isEmpty()); + } + @Test void testApplyToolsWithEmptyList() { MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java index 8bb6682bf8..a5ba7e6482 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.anthropic.models.messages.ContentBlockParam; @@ -279,6 +280,28 @@ void testConvertAnthropicThinkingBlocksAfterJsonRoundTrip() { assertEquals("encrypted-data", blocks.get(1).asRedactedThinking().data()); } + @Test + void testRejectsMalformedAnthropicThinkingMetadata() { + ThinkingBlock thinkingBlock = + ThinkingBlock.builder() + .thinking("Reasoning") + .metadata( + Map.of( + "anthropicThinkingBlock:0", + Map.of( + "type", "thinking", + "thinking", "Reasoning"))) + .build(); + Msg msg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content(List.of(thinkingBlock)) + .build(); + + assertThrows(IllegalArgumentException.class, () -> converter.convert(List.of(msg))); + } + @Test void testConvertToolUseBlock() { Map input = Map.of("query", "test"); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java index c6506d70be..d1f88108b4 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java @@ -24,13 +24,19 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.anthropic.core.JsonValue; import com.anthropic.core.ObjectMappers; import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.ContentBlockParam; import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageDeltaUsage; import com.anthropic.models.messages.RawContentBlockDeltaEvent; import com.anthropic.models.messages.RawContentBlockStartEvent; +import com.anthropic.models.messages.RawContentBlockStopEvent; +import com.anthropic.models.messages.RawMessageDeltaEvent; import com.anthropic.models.messages.RawMessageStartEvent; import com.anthropic.models.messages.RawMessageStreamEvent; +import com.anthropic.models.messages.StopReason; import com.anthropic.models.messages.Usage; import io.agentscope.core.agent.accumulator.ReasoningContext; import io.agentscope.core.message.Msg; @@ -42,6 +48,7 @@ import java.lang.reflect.Method; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -399,6 +406,83 @@ void testParseStreamEventThinkingDelta() throws Exception { assertNull(response.getUsage()); } + @Test + void testParseStreamEventTextDelta() throws Exception { + RawMessageStreamEvent event = + RawMessageStreamEvent.ofContentBlockDelta( + RawContentBlockDeltaEvent.builder().index(0).textDelta("answer").build()); + + ChatResponse response = invokeParseStreamEvent(event, Instant.now()); + + TextBlock text = assertInstanceOf(TextBlock.class, response.getContent().get(0)); + assertEquals("answer", text.getText()); + } + + @Test + void testParseStreamEventInputJsonDelta() throws Exception { + RawMessageStreamEvent event = + RawMessageStreamEvent.ofContentBlockDelta( + RawContentBlockDeltaEvent.builder() + .index(0) + .inputJsonDelta("{\"city\":") + .build()); + + ChatResponse response = invokeParseStreamEvent(event, Instant.now()); + + ToolUseBlock fragment = assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); + assertEquals("", fragment.getId()); + assertEquals("__fragment__", fragment.getName()); + assertEquals("{\"city\":", fragment.getContent()); + } + + @Test + void testParseStreamEventToolUseStart() throws Exception { + RawMessageStreamEvent event = + RawMessageStreamEvent.ofContentBlockStart( + RawContentBlockStartEvent.builder() + .index(0) + .contentBlock( + com.anthropic.models.messages.ToolUseBlock.builder() + .id("call-123") + .name("weather") + .input(JsonValue.from(Map.of("city", "Hangzhou"))) + .build()) + .build()); + + ChatResponse response = invokeParseStreamEvent(event, Instant.now()); + + ToolUseBlock toolUse = assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); + assertEquals("call-123", toolUse.getId()); + assertEquals("weather", toolUse.getName()); + assertTrue(toolUse.getInput().isEmpty()); + } + + @Test + void testParseStreamEventMessageDeltaUsage() throws Exception { + RawMessageStreamEvent event = + RawMessageStreamEvent.ofMessageDelta( + RawMessageDeltaEvent.builder() + .delta( + RawMessageDeltaEvent.Delta.builder() + .stopReason(StopReason.END_TURN) + .stopSequence(Optional.empty()) + .build()) + .usage( + MessageDeltaUsage.builder() + .cacheCreationInputTokens(Optional.empty()) + .cacheReadInputTokens(Optional.empty()) + .inputTokens(Optional.empty()) + .outputTokens(7) + .serverToolUse(Optional.empty()) + .build()) + .build()); + + ChatResponse response = invokeParseStreamEvent(event, Instant.now()); + + assertNotNull(response.getUsage()); + assertEquals(7, response.getUsage().getOutputTokens()); + } + @Test void testParseStreamPreservesSignatureAndRedactedThinking() { RawMessageStreamEvent firstThinking = @@ -455,6 +539,82 @@ void testParseStreamPreservesSignatureAndRedactedThinking() { .verifyComplete(); } + @Test + void testParseStreamPreservesOmittedThinkingSignature() { + RawMessageStreamEvent start = + RawMessageStreamEvent.ofContentBlockStart( + RawContentBlockStartEvent.builder() + .index(0) + .contentBlock( + com.anthropic.models.messages.ThinkingBlock.builder() + .thinking("") + .signature("") + .build()) + .build()); + RawMessageStreamEvent signature = + RawMessageStreamEvent.ofContentBlockDelta( + RawContentBlockDeltaEvent.builder() + .index(0) + .signatureDelta("signature-omitted") + .build()); + RawMessageStreamEvent stop = + RawMessageStreamEvent.ofContentBlockStop( + RawContentBlockStopEvent.builder().index(0).build()); + + StepVerifier.create( + AnthropicResponseParser.parseStreamEvents( + Flux.just(start, signature, stop), Instant.now()) + .collectList()) + .assertNext( + chunks -> { + ReasoningContext context = new ReasoningContext("Assistant"); + chunks.forEach(context::processChunk); + ThinkingBlock thinking = + context.buildFinalMessage() + .getFirstContentBlock(ThinkingBlock.class); + List nativeBlocks = + AnthropicThinkingMetadata.toContentBlockParams(thinking); + + assertEquals("", thinking.getThinking()); + assertEquals(1, nativeBlocks.size()); + assertEquals( + "signature-omitted", + nativeBlocks.get(0).asThinking().signature()); + }) + .verifyComplete(); + } + + @Test + void testParseStreamPreservesThinkingFromContentBlockStart() { + RawMessageStreamEvent start = + RawMessageStreamEvent.ofContentBlockStart( + RawContentBlockStartEvent.builder() + .index(2) + .contentBlock( + com.anthropic.models.messages.ThinkingBlock.builder() + .thinking("initial reasoning") + .signature("signature-start") + .build()) + .build()); + + StepVerifier.create( + AnthropicResponseParser.parseStreamEvents(Flux.just(start), Instant.now())) + .assertNext( + response -> { + ThinkingBlock thinking = + assertInstanceOf( + ThinkingBlock.class, response.getContent().get(0)); + List nativeBlocks = + AnthropicThinkingMetadata.toContentBlockParams(thinking); + + assertEquals("initial reasoning", thinking.getThinking()); + assertEquals( + "signature-start", + nativeBlocks.get(0).asThinking().signature()); + }) + .verifyComplete(); + } + @Test void testParseStreamEventUnknownType() throws Exception { // Test unknown event type - should return empty response diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java new file mode 100644 index 0000000000..ac38197449 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.anthropic.formatter; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.anthropic.models.messages.ContentBlockParam; +import io.agentscope.core.message.ThinkingBlock; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Unit tests for Anthropic thinking metadata validation and conversion. */ +class AnthropicThinkingMetadataTest { + + private static final String KEY_PREFIX = "anthropicThinkingBlock:"; + + @Test + void testReturnsEmptyWithoutAnthropicMetadata() { + assertAll( + () -> assertTrue(AnthropicThinkingMetadata.toContentBlockParams(null).isEmpty()), + () -> + assertTrue( + AnthropicThinkingMetadata.toContentBlockParams( + ThinkingBlock.builder().build()) + .isEmpty()), + () -> + assertTrue( + AnthropicThinkingMetadata.toContentBlockParams( + block(Map.of("provider", "other"))) + .isEmpty())); + } + + @Test + void testConvertsNullThinkingToEmptyText() { + ThinkingBlock block = block(AnthropicThinkingMetadata.thinking(0, null, "signature-123")); + + List result = AnthropicThinkingMetadata.toContentBlockParams(block); + + assertEquals(1, result.size()); + assertEquals("", result.get(0).asThinking().thinking()); + assertEquals("signature-123", result.get(0).asThinking().signature()); + } + + @Test + void testRejectsMalformedMetadataKeys() { + assertAll( + () -> assertInvalid(Map.of(KEY_PREFIX + "invalid", Map.of())), + () -> assertInvalid(Map.of(KEY_PREFIX + "-1", Map.of())), + () -> assertInvalid(Map.of(KEY_PREFIX, Map.of())), + () -> + assertInvalid( + Map.of( + KEY_PREFIX + "1", + validThinking("one"), + KEY_PREFIX + "01", + validThinking("duplicate")))); + } + + @Test + void testRejectsMalformedThinkingBlocks() { + Map nullValue = new HashMap<>(); + nullValue.put(KEY_PREFIX + "0", null); + + assertAll( + () -> assertInvalid(nullValue), + () -> assertInvalid(Map.of(KEY_PREFIX + "0", "not-a-map")), + () -> assertInvalid(storedBlock(Map.of("type", "unknown"))), + () -> + assertInvalid( + storedBlock( + Map.of( + "type", "thinking", + "signature", "signature-123"))), + () -> + assertInvalid( + storedBlock( + Map.of( + "type", "thinking", + "thinking", "reasoning"))), + () -> + assertInvalid( + storedBlock( + Map.of( + "type", "thinking", + "thinking", "reasoning", + "signature", ""))), + () -> + assertInvalid( + storedBlock( + Map.of( + "type", "thinking", + "thinking", 1, + "signature", "signature-123")))); + } + + @Test + void testRejectsMalformedRedactedThinkingBlocks() { + assertAll( + () -> assertInvalid(storedBlock(Map.of("type", "redacted_thinking"))), + () -> + assertInvalid( + storedBlock( + Map.of( + "type", "redacted_thinking", + "data", ""))), + () -> assertInvalid(storedBlock(Map.of("type", "redacted_thinking", "data", 1)))); + } + + private static Map storedBlock(Object value) { + return Map.of(KEY_PREFIX + "0", value); + } + + private static Map validThinking(String thinking) { + return Map.of( + "type", "thinking", + "thinking", thinking, + "signature", "signature-123"); + } + + private static ThinkingBlock block(Map metadata) { + return ThinkingBlock.builder().thinking("reasoning").metadata(metadata).build(); + } + + private static void assertInvalid(Map metadata) { + assertThrows( + IllegalArgumentException.class, + () -> AnthropicThinkingMetadata.toContentBlockParams(block(metadata))); + } +} From d7f6dea4baf1aff14b53d04898122fbc9db9f12e Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Wed, 15 Jul 2026 22:34:11 +0800 Subject: [PATCH 4/6] refactor(anthropic): focus extended thinking support --- .../accumulator/ThinkingAccumulatorTest.java | 76 --------- .../model/anthropic/AnthropicChatModel.java | 6 +- .../formatter/AnthropicBaseFormatter.java | 20 ++- .../formatter/AnthropicMessageConverter.java | 56 ++---- .../formatter/AnthropicResponseParser.java | 102 ++++------- .../formatter/AnthropicThinkingMetadata.java | 122 ++++--------- .../formatter/AnthropicToolsHelper.java | 5 +- .../formatter/AnthropicChatFormatterTest.java | 79 --------- .../AnthropicMessageConverterTest.java | 149 +--------------- .../AnthropicResponseParserTest.java | 160 ------------------ .../AnthropicThinkingMetadataTest.java | 147 ---------------- .../formatter/AnthropicToolsHelperTest.java | 27 +-- 12 files changed, 102 insertions(+), 847 deletions(-) delete mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java delete mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java deleted file mode 100644 index 91d141e4d0..0000000000 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.core.agent.accumulator; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.agentscope.core.message.ThinkingBlock; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** Unit tests for {@link ThinkingAccumulator}. */ -class ThinkingAccumulatorTest { - - private ThinkingAccumulator accumulator; - - @BeforeEach - void setUp() { - accumulator = new ThinkingAccumulator(); - } - - @Test - void testEmptyAccumulator() { - accumulator.add(null); - - assertFalse(accumulator.hasContent()); - assertNull(accumulator.buildAggregated()); - assertEquals("", accumulator.getAccumulated()); - } - - @Test - void testAccumulatesTextAndMetadata() { - accumulator.add(ThinkingBlock.builder().thinking("first ").build()); - accumulator.add( - ThinkingBlock.builder() - .thinking("second") - .metadata(Map.of("signature", "signature-123")) - .build()); - - ThinkingBlock result = (ThinkingBlock) accumulator.buildAggregated(); - - assertTrue(accumulator.hasContent()); - assertEquals("first second", result.getThinking()); - assertEquals("signature-123", result.getMetadata().get("signature")); - } - - @Test - void testMetadataOnlyContentAndReset() { - accumulator.add( - ThinkingBlock.builder().metadata(Map.of("signature", "signature-123")).build()); - - ThinkingBlock result = (ThinkingBlock) accumulator.buildAggregated(); - accumulator.reset(); - - assertEquals("", result.getThinking()); - assertEquals("signature-123", result.getMetadata().get("signature")); - assertFalse(accumulator.hasContent()); - assertEquals("", accumulator.getAccumulated()); - } -} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java index efd59f429f..cc90a9e60c 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java @@ -172,8 +172,10 @@ protected Flux doStream( // Apply generation options via formatter formatter.applyOptions(paramsBuilder, options, defaultOptions); - // Add tools and release formatter request state - formatter.applyTools(paramsBuilder, tools); + // Add tools if provided + if (tools != null && !tools.isEmpty()) { + formatter.applyTools(paramsBuilder, tools); + } // Create the request MessageCreateParams params = paramsBuilder.build(); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java index 11fb89a500..5d384b3f41 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicBaseFormatter.java @@ -58,11 +58,11 @@ public void applyOptions( MessageCreateParams.Builder paramsBuilder, GenerateOptions options, GenerateOptions defaultOptions) { + // Save options for applyTools + currentOptions.set(options); + // Apply other options AnthropicToolsHelper.applyOptions(paramsBuilder, options, defaultOptions); - - // Save effective options for applyTools only after option application succeeds - currentOptions.set(GenerateOptions.mergeOptions(options, defaultOptions)); } /** @@ -74,13 +74,17 @@ public void applyOptions( */ @Override public void applyTools(MessageCreateParams.Builder paramsBuilder, List tools) { - try { - // Use saved options to apply tools with tool choice - AnthropicToolsHelper.applyTools(paramsBuilder, tools, currentOptions.get()); - } finally { - // Clean up even when tool conversion fails + if (tools == null || tools.isEmpty()) { currentOptions.remove(); + return; } + + // Use saved options to apply tools with tool choice + GenerateOptions options = currentOptions.get(); + AnthropicToolsHelper.applyTools(paramsBuilder, tools, options); + + // Clean up thread-local storage + currentOptions.remove(); } /** diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java index 6885d4269a..cb0d68f340 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverter.java @@ -85,11 +85,7 @@ public List convert(List messages) { && msg.getContentBlocks(ToolUseBlock.class).size() > 1) { SplitToolResultSequence splitResults = collectSplitToolResults(messages, i + 1); if (shouldSplitParallelToolCalls(msg, splitResults)) { - if (hasNativeThinkingBlocks(msg)) { - result.addAll(convertParallelToolCallsWithThinking(msg, splitResults)); - } else { - result.addAll(convertParallelToolCalls(msg, splitResults)); - } + result.addAll(convertParallelToolCalls(msg, splitResults)); i += splitResults.consumedMessages(); continue; } @@ -202,24 +198,6 @@ private boolean hasNonToolResultContent(Msg msg) { return msg.getContent().stream().anyMatch(block -> !(block instanceof ToolResultBlock)); } - private boolean hasNativeThinkingBlocks(Msg msg) { - return msg.getContentBlocks(ThinkingBlock.class).stream() - .anyMatch( - block -> !AnthropicThinkingMetadata.toContentBlockParams(block).isEmpty()); - } - - /** Preserve the original signed assistant message and group its parallel tool results. */ - private List convertParallelToolCallsWithThinking( - Msg assistantMsg, SplitToolResultSequence splitResults) { - MessageParam assistantParam = - convertMessageContent(assistantMsg, assistantMsg.getContent(), false); - List toolResults = - assistantMsg.getContentBlocks(ToolUseBlock.class).stream() - .map(toolUse -> splitResults.resultsById().get(toolUse.getId())) - .toList(); - return List.of(assistantParam, convertToolResults(toolResults)); - } - /** * Expand one assistant message with parallel tool uses into alternating Anthropic messages. * @@ -351,22 +329,6 @@ private MessageParam convertMessageContent( * Convert tool result to separate user message. */ private MessageParam convertToolResult(ToolResultBlock toolResult) { - return convertToolResults(List.of(toolResult)); - } - - private MessageParam convertToolResults(List toolResults) { - List toolResultParams = - toolResults.stream() - .map(this::convertToolResultBlock) - .map(ContentBlockParam::ofToolResult) - .toList(); - return MessageParam.builder() - .role(Role.USER) - .content(MessageParam.Content.ofBlockParams(toolResultParams)) - .build(); - } - - private ToolResultBlockParam convertToolResultBlock(ToolResultBlock toolResult) { // Convert output to content blocks List blocks = new ArrayList<>(); @@ -414,9 +376,19 @@ private ToolResultBlockParam convertToolResultBlock(ToolResultBlock toolResult) TextBlockParam.builder().text(outputStr).build())); } - return ToolResultBlockParam.builder() - .toolUseId(toolResult.getId()) - .content(ToolResultBlockParam.Content.ofBlocks(blocks)) + // Create tool result block + ToolResultBlockParam toolResultParam = + ToolResultBlockParam.builder() + .toolUseId(toolResult.getId()) + .content(ToolResultBlockParam.Content.ofBlocks(blocks)) + .build(); + + // Wrap in user message + return MessageParam.builder() + .role(Role.USER) + .content( + MessageParam.Content.ofBlockParams( + List.of(ContentBlockParam.ofToolResult(toolResultParam)))) .build(); } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java index 5163e72906..4ebd3f32e1 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java @@ -52,7 +52,6 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { // Process content blocks for (int index = 0; index < message.content().size(); index++) { - int contentIndex = index; var block = message.content().get(index); // Text block block.text() @@ -80,32 +79,27 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { }); // Thinking block (extended thinking) - block.thinking() - .ifPresent( - thinking -> - contentBlocks.add( - ThinkingBlock.builder() - .thinking(thinking.thinking()) - .metadata( - AnthropicThinkingMetadata.thinking( - contentIndex, - thinking.thinking(), - thinking.signature())) - .build())); + if (block.thinking().isPresent()) { + var thinking = block.thinking().get(); + contentBlocks.add( + ThinkingBlock.builder() + .thinking(thinking.thinking()) + .metadata( + AnthropicThinkingMetadata.thinking( + index, thinking.thinking(), thinking.signature())) + .build()); + } // Redacted thinking block - block.redactedThinking() - .ifPresent( - redactedThinking -> - contentBlocks.add( - ThinkingBlock.builder() - .metadata( - AnthropicThinkingMetadata - .redactedThinking( - contentIndex, - redactedThinking - .data())) - .build())); + if (block.redactedThinking().isPresent()) { + var redactedThinking = block.redactedThinking().get(); + contentBlocks.add( + ThinkingBlock.builder() + .metadata( + AnthropicThinkingMetadata.redactedThinking( + index, redactedThinking.data())) + .build()); + } } // Parse usage @@ -127,23 +121,18 @@ public static Flux parseStreamEvents( return Flux.defer( () -> { StreamState streamState = new StreamState(); - return eventFlux - .concatMap( - event -> { - try { - return Flux.just( - parseStreamEvent( - event, startTime, streamState)); - } catch (Exception e) { - log.warn( - "Error parsing stream event: {}", - e.getMessage()); - return Flux.empty(); - } - }) - .filter( - response -> - response != null && !response.getContent().isEmpty()); + return eventFlux.handle( + (event, sink) -> { + try { + ChatResponse response = + parseStreamEvent(event, startTime, streamState); + if (response != null && !response.getContent().isEmpty()) { + sink.next(response); + } + } catch (Exception e) { + log.warn("Error parsing stream event: {}", e.getMessage()); + } + }); }); } @@ -226,27 +215,6 @@ private static ChatResponse parseStreamEvent( if (event.isContentBlockStart()) { var startEvent = event.asContentBlockStart(); - startEvent - .contentBlock() - .thinking() - .ifPresent( - thinking -> { - streamState.appendThinking(startEvent.index(), thinking.thinking()); - if (!thinking.thinking().isEmpty() - || !thinking.signature().isEmpty()) { - ThinkingBlock.Builder thinkingBuilder = - ThinkingBlock.builder().thinking(thinking.thinking()); - if (!thinking.signature().isEmpty()) { - thinkingBuilder.metadata( - AnthropicThinkingMetadata.thinking( - startEvent.index(), - thinking.thinking(), - thinking.signature())); - } - contentBlocks.add(thinkingBuilder.build()); - } - }); - startEvent .contentBlock() .redactedThinking() @@ -277,10 +245,6 @@ private static ChatResponse parseStreamEvent( }); } - if (event.isContentBlockStop()) { - streamState.remove(event.asContentBlockStop().index()); - } - // Message delta - usage information if (event.isMessageDelta()) { var messageDelta = event.asMessageDelta(); @@ -310,10 +274,6 @@ private String getThinking(long index) { StringBuilder thinking = thinkingByIndex.get(index); return thinking != null ? thinking.toString() : ""; } - - private void remove(long index) { - thinkingByIndex.remove(index); - } } /** diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java index fbb7aa634d..56b37393cc 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java @@ -21,37 +21,30 @@ import io.agentscope.core.message.ThinkingBlock; import java.util.ArrayList; import java.util.Comparator; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; /** Stores Anthropic thinking blocks in provider-specific {@link ThinkingBlock} metadata. */ final class AnthropicThinkingMetadata { private static final String KEY_PREFIX = "anthropicThinkingBlock:"; - private static final String TYPE = "type"; - private static final String THINKING = "thinking"; - private static final String SIGNATURE = "signature"; - private static final String DATA = "data"; - private static final String TYPE_THINKING = "thinking"; - private static final String TYPE_REDACTED_THINKING = "redacted_thinking"; private AnthropicThinkingMetadata() {} static Map thinking(long index, String thinking, String signature) { - Map value = new LinkedHashMap<>(); - value.put(TYPE, TYPE_THINKING); - value.put(THINKING, thinking != null ? thinking : ""); - value.put(SIGNATURE, signature); - return Map.of(key(index), value); + return Map.of( + key(index), + Map.of( + "type", + "thinking", + "thinking", + thinking != null ? thinking : "", + "signature", + signature)); } static Map redactedThinking(long index, String data) { - Map value = new LinkedHashMap<>(); - value.put(TYPE, TYPE_REDACTED_THINKING); - value.put(DATA, data); - return Map.of(key(index), value); + return Map.of(key(index), Map.of("type", "redacted_thinking", "data", data)); } static List toContentBlockParams(ThinkingBlock block) { @@ -59,102 +52,55 @@ static List toContentBlockParams(ThinkingBlock block) { return List.of(); } - List> metadataEntries = + List> storedBlocks = block.getMetadata().entrySet().stream() .filter( entry -> entry.getKey() != null && entry.getKey().startsWith(KEY_PREFIX)) + .map( + entry -> + Map.entry( + Long.parseLong( + entry.getKey() + .substring(KEY_PREFIX.length())), + entry.getValue())) + .sorted(Comparator.comparingLong(Map.Entry::getKey)) .toList(); - if (metadataEntries.isEmpty()) { - return List.of(); - } - - List> storedBlocks = new ArrayList<>(metadataEntries.size()); - for (Map.Entry entry : metadataEntries) { - long index = parseIndex(entry.getKey()); - if (entry.getValue() == null) { - throw invalidMetadata(index); - } - storedBlocks.add(Map.entry(index, entry.getValue())); - } - storedBlocks.sort(Comparator.comparingLong(Map.Entry::getKey)); - List result = new ArrayList<>(storedBlocks.size()); - Long previousIndex = null; for (Map.Entry storedBlock : storedBlocks) { - long index = storedBlock.getKey(); - if (previousIndex != null && previousIndex == index) { - throw invalidMetadata(index); + ContentBlockParam converted = convert(storedBlock.getValue()); + if (converted == null) { + return List.of(); } - ContentBlockParam converted = - convert(storedBlock.getValue()).orElseThrow(() -> invalidMetadata(index)); result.add(converted); - previousIndex = index; } return result; } - private static Optional convert(Object value) { + private static ContentBlockParam convert(Object value) { if (!(value instanceof Map map)) { - return Optional.empty(); + return null; } - String type = stringValue(map.get(TYPE)); - if (TYPE_THINKING.equals(type)) { - String thinking = stringValue(map.get(THINKING)); - String signature = stringValue(map.get(SIGNATURE)); - if (thinking == null || signature == null || signature.isEmpty()) { - return Optional.empty(); - } - return Optional.of( - ContentBlockParam.ofThinking( - ThinkingBlockParam.builder() - .thinking(thinking) - .signature(signature) - .build())); + if ("thinking".equals(map.get("type"))) { + return ContentBlockParam.ofThinking( + ThinkingBlockParam.builder() + .thinking((String) map.get("thinking")) + .signature((String) map.get("signature")) + .build()); } - if (TYPE_REDACTED_THINKING.equals(type)) { - String data = stringValue(map.get(DATA)); - if (data == null || data.isEmpty()) { - return Optional.empty(); - } - return Optional.of( - ContentBlockParam.ofRedactedThinking( - RedactedThinkingBlockParam.builder().data(data).build())); + if ("redacted_thinking".equals(map.get("type"))) { + return ContentBlockParam.ofRedactedThinking( + RedactedThinkingBlockParam.builder().data((String) map.get("data")).build()); } - return Optional.empty(); + return null; } private static String key(long index) { return KEY_PREFIX + index; } - - private static long parseIndex(String key) { - try { - long index = Long.parseLong(key.substring(KEY_PREFIX.length())); - if (index < 0 || !key.equals(key(index))) { - throw invalidMetadataKey(key); - } - return index; - } catch (NumberFormatException e) { - throw invalidMetadataKey(key); - } - } - - private static IllegalArgumentException invalidMetadata(long index) { - return new IllegalArgumentException( - "Invalid Anthropic thinking metadata at content index " + index); - } - - private static IllegalArgumentException invalidMetadataKey(String key) { - return new IllegalArgumentException("Invalid Anthropic thinking metadata key: " + key); - } - - private static String stringValue(Object value) { - return value instanceof String string ? string : null; - } } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java index e4909df6c8..83561436c6 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelper.java @@ -17,7 +17,6 @@ import static com.anthropic.models.messages.ToolChoice.ofAny; import static com.anthropic.models.messages.ToolChoice.ofAuto; -import static com.anthropic.models.messages.ToolChoice.ofNone; import static com.anthropic.models.messages.ToolChoice.ofTool; import com.anthropic.core.JsonValue; @@ -26,7 +25,6 @@ import com.anthropic.models.messages.Tool; import com.anthropic.models.messages.ToolChoiceAny; import com.anthropic.models.messages.ToolChoiceAuto; -import com.anthropic.models.messages.ToolChoiceNone; import com.anthropic.models.messages.ToolChoiceTool; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.ToolChoice; @@ -95,7 +93,8 @@ private static void applyToolChoice( if (toolChoice instanceof ToolChoice.Auto) { builder.toolChoice(ofAuto(ToolChoiceAuto.builder().build())); } else if (toolChoice instanceof ToolChoice.None) { - builder.toolChoice(ofNone(ToolChoiceNone.builder().build())); + // Anthropic doesn't have None, use Any instead + builder.toolChoice(ofAny(ToolChoiceAny.builder().build())); } else if (toolChoice instanceof ToolChoice.Required) { // Anthropic doesn't have a direct "required" option, use "any" which forces tool // use diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java index cf6f5676e2..f1175a077e 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicChatFormatterTest.java @@ -59,17 +59,6 @@ void setUp() { formatter = new AnthropicChatFormatter(); } - private MessageCreateParams.Builder createParamsBuilder() { - return MessageCreateParams.builder() - .model("claude-sonnet-4-5-20250929") - .maxTokens(1024) - .addMessage( - MessageParam.builder() - .role(MessageParam.Role.USER) - .content("test") - .build()); - } - @Test void testFormatSimpleUserMessage() { Msg msg = @@ -371,74 +360,6 @@ void testApplyTools() { assertNotNull(params); } - @Test - void testApplyToolsUsesDefaultToolChoice() { - MessageCreateParams.Builder paramsBuilder = createParamsBuilder(); - ToolSchema searchTool = - ToolSchema.builder() - .name("search") - .description("Search the web") - .parameters(Map.of("type", "object")) - .build(); - GenerateOptions defaultOptions = - GenerateOptions.builder() - .thinkingBudget(1024) - .toolChoice(new ToolChoice.None()) - .build(); - - formatter.applyOptions(paramsBuilder, null, defaultOptions); - formatter.applyTools(paramsBuilder, List.of(searchTool)); - - MessageCreateParams params = paramsBuilder.build(); - assertTrue(params.thinking().orElseThrow().isEnabled()); - assertTrue(params.toolChoice().orElseThrow().isNone()); - } - - @Test - void testApplyToolsRequestToolChoiceOverridesDefault() { - MessageCreateParams.Builder paramsBuilder = createParamsBuilder(); - ToolSchema searchTool = - ToolSchema.builder() - .name("search") - .description("Search the web") - .parameters(Map.of("type", "object")) - .build(); - GenerateOptions options = - GenerateOptions.builder().toolChoice(new ToolChoice.Auto()).build(); - GenerateOptions defaultOptions = - GenerateOptions.builder().toolChoice(new ToolChoice.None()).build(); - - formatter.applyOptions(paramsBuilder, options, defaultOptions); - formatter.applyTools(paramsBuilder, List.of(searchTool)); - - assertTrue(paramsBuilder.build().toolChoice().orElseThrow().isAuto()); - } - - @Test - void testApplyToolsClearsOptionsAfterFailure() { - formatter.applyOptions( - createParamsBuilder(), - GenerateOptions.builder().toolChoice(new ToolChoice.None()).build(), - null); - - assertThrows( - NullPointerException.class, - () -> - formatter.applyTools( - createParamsBuilder(), java.util.Collections.singletonList(null))); - - ToolSchema searchTool = - ToolSchema.builder() - .name("search") - .description("Search the web") - .parameters(Map.of("type", "object")) - .build(); - MessageCreateParams.Builder nextRequest = createParamsBuilder(); - formatter.applyTools(nextRequest, List.of(searchTool)); - - assertTrue(nextRequest.build().toolChoice().isEmpty()); - } - @Test void testApplyToolsWithEmptyList() { MessageCreateParams.Builder paramsBuilder = MessageCreateParams.builder(); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java index a5ba7e6482..4380d05729 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java @@ -17,7 +17,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.anthropic.models.messages.ContentBlockParam; @@ -205,55 +204,6 @@ void testConvertThinkingBlock() { assertEquals("Let me think...", blocks.get(0).asText().text()); } - @Test - void testConvertAnthropicThinkingBlockWithSignature() { - ThinkingBlock thinkingBlock = - ThinkingBlock.builder() - .thinking("Let me think...") - .metadata( - AnthropicThinkingMetadata.thinking( - 0, "Let me think...", "signature-123")) - .build(); - Msg msg = - Msg.builder() - .name("Assistant") - .role(MsgRole.ASSISTANT) - .content(List.of(thinkingBlock)) - .build(); - - List blocks = - converter.convert(List.of(msg)).get(0).content().asBlockParams(); - - assertEquals(1, blocks.size()); - assertTrue(blocks.get(0).isThinking()); - assertEquals("Let me think...", blocks.get(0).asThinking().thinking()); - assertEquals("signature-123", blocks.get(0).asThinking().signature()); - } - - @Test - void testConvertAnthropicThinkingBlocksInOriginalOrder() { - Map metadata = new HashMap<>(); - metadata.putAll(AnthropicThinkingMetadata.redactedThinking(2, "encrypted-data")); - metadata.putAll(AnthropicThinkingMetadata.thinking(0, "Reasoning", "signature-123")); - ThinkingBlock thinkingBlock = - ThinkingBlock.builder().thinking("Reasoning").metadata(metadata).build(); - Msg msg = - Msg.builder() - .name("Assistant") - .role(MsgRole.ASSISTANT) - .content(List.of(thinkingBlock)) - .build(); - - List blocks = - converter.convert(List.of(msg)).get(0).content().asBlockParams(); - - assertEquals(2, blocks.size()); - assertTrue(blocks.get(0).isThinking()); - assertEquals("signature-123", blocks.get(0).asThinking().signature()); - assertTrue(blocks.get(1).isRedactedThinking()); - assertEquals("encrypted-data", blocks.get(1).asRedactedThinking().data()); - } - @Test void testConvertAnthropicThinkingBlocksAfterJsonRoundTrip() { Map metadata = new HashMap<>(); @@ -276,32 +226,13 @@ void testConvertAnthropicThinkingBlocksAfterJsonRoundTrip() { converter.convert(List.of(restored)).get(0).content().asBlockParams(); assertEquals(2, blocks.size()); + assertTrue(blocks.get(0).isThinking()); + assertEquals("Reasoning", blocks.get(0).asThinking().thinking()); assertEquals("signature-123", blocks.get(0).asThinking().signature()); + assertTrue(blocks.get(1).isRedactedThinking()); assertEquals("encrypted-data", blocks.get(1).asRedactedThinking().data()); } - @Test - void testRejectsMalformedAnthropicThinkingMetadata() { - ThinkingBlock thinkingBlock = - ThinkingBlock.builder() - .thinking("Reasoning") - .metadata( - Map.of( - "anthropicThinkingBlock:0", - Map.of( - "type", "thinking", - "thinking", "Reasoning"))) - .build(); - Msg msg = - Msg.builder() - .name("Assistant") - .role(MsgRole.ASSISTANT) - .content(List.of(thinkingBlock)) - .build(); - - assertThrows(IllegalArgumentException.class, () -> converter.convert(List.of(msg))); - } - @Test void testConvertToolUseBlock() { Map input = Map.of("query", "test"); @@ -515,80 +446,6 @@ void testConvertParallelToolCallsToAlternatingMessages() { result.get(4).content().asBlockParams().get(0).asToolResult().toolUseId()); } - @Test - void testPreserveSignedThinkingWithParallelToolCalls() { - Msg userMsg = - Msg.builder() - .name("User") - .role(MsgRole.USER) - .content(TextBlock.builder().text("Check two cities.").build()) - .build(); - Msg assistantMsg = - Msg.builder() - .name("Assistant") - .role(MsgRole.ASSISTANT) - .content( - List.of( - ThinkingBlock.builder() - .thinking("I should check both cities.") - .metadata( - AnthropicThinkingMetadata.thinking( - 0, - "I should check both cities.", - "signature-123")) - .build(), - ToolUseBlock.builder() - .id("call_1") - .name("get_weather") - .input(Map.of("city", "Beijing")) - .build(), - ToolUseBlock.builder() - .id("call_2") - .name("get_weather") - .input(Map.of("city", "Hangzhou")) - .build())) - .build(); - Msg firstResult = - Msg.builder() - .name("Tool") - .role(MsgRole.TOOL) - .content( - ToolResultBlock.builder() - .id("call_1") - .name("get_weather") - .output(TextBlock.builder().text("Sunny").build()) - .build()) - .build(); - Msg secondResult = - Msg.builder() - .name("Tool") - .role(MsgRole.TOOL) - .content( - ToolResultBlock.builder() - .id("call_2") - .name("get_weather") - .output(TextBlock.builder().text("Cloudy").build()) - .build()) - .build(); - - List result = - converter.convert(List.of(userMsg, assistantMsg, firstResult, secondResult)); - - assertEquals(3, result.size()); - List assistantBlocks = result.get(1).content().asBlockParams(); - assertEquals(3, assistantBlocks.size()); - assertTrue(assistantBlocks.get(0).isThinking()); - assertEquals("signature-123", assistantBlocks.get(0).asThinking().signature()); - assertEquals("call_1", assistantBlocks.get(1).asToolUse().id()); - assertEquals("call_2", assistantBlocks.get(2).asToolUse().id()); - - assertEquals(MessageParam.Role.USER, result.get(2).role()); - List resultBlocks = result.get(2).content().asBlockParams(); - assertEquals(2, resultBlocks.size()); - assertEquals("call_1", resultBlocks.get(0).asToolResult().toolUseId()); - assertEquals("call_2", resultBlocks.get(1).asToolResult().toolUseId()); - } - @Test void testConvertToolResultBlockNullOutput() { // Builder without output() call will have null output, which becomes empty list diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java index d1f88108b4..c6506d70be 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParserTest.java @@ -24,19 +24,13 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.anthropic.core.JsonValue; import com.anthropic.core.ObjectMappers; import com.anthropic.models.messages.ContentBlock; -import com.anthropic.models.messages.ContentBlockParam; import com.anthropic.models.messages.Message; -import com.anthropic.models.messages.MessageDeltaUsage; import com.anthropic.models.messages.RawContentBlockDeltaEvent; import com.anthropic.models.messages.RawContentBlockStartEvent; -import com.anthropic.models.messages.RawContentBlockStopEvent; -import com.anthropic.models.messages.RawMessageDeltaEvent; import com.anthropic.models.messages.RawMessageStartEvent; import com.anthropic.models.messages.RawMessageStreamEvent; -import com.anthropic.models.messages.StopReason; import com.anthropic.models.messages.Usage; import io.agentscope.core.agent.accumulator.ReasoningContext; import io.agentscope.core.message.Msg; @@ -48,7 +42,6 @@ import java.lang.reflect.Method; import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -406,83 +399,6 @@ void testParseStreamEventThinkingDelta() throws Exception { assertNull(response.getUsage()); } - @Test - void testParseStreamEventTextDelta() throws Exception { - RawMessageStreamEvent event = - RawMessageStreamEvent.ofContentBlockDelta( - RawContentBlockDeltaEvent.builder().index(0).textDelta("answer").build()); - - ChatResponse response = invokeParseStreamEvent(event, Instant.now()); - - TextBlock text = assertInstanceOf(TextBlock.class, response.getContent().get(0)); - assertEquals("answer", text.getText()); - } - - @Test - void testParseStreamEventInputJsonDelta() throws Exception { - RawMessageStreamEvent event = - RawMessageStreamEvent.ofContentBlockDelta( - RawContentBlockDeltaEvent.builder() - .index(0) - .inputJsonDelta("{\"city\":") - .build()); - - ChatResponse response = invokeParseStreamEvent(event, Instant.now()); - - ToolUseBlock fragment = assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); - assertEquals("", fragment.getId()); - assertEquals("__fragment__", fragment.getName()); - assertEquals("{\"city\":", fragment.getContent()); - } - - @Test - void testParseStreamEventToolUseStart() throws Exception { - RawMessageStreamEvent event = - RawMessageStreamEvent.ofContentBlockStart( - RawContentBlockStartEvent.builder() - .index(0) - .contentBlock( - com.anthropic.models.messages.ToolUseBlock.builder() - .id("call-123") - .name("weather") - .input(JsonValue.from(Map.of("city", "Hangzhou"))) - .build()) - .build()); - - ChatResponse response = invokeParseStreamEvent(event, Instant.now()); - - ToolUseBlock toolUse = assertInstanceOf(ToolUseBlock.class, response.getContent().get(0)); - assertEquals("call-123", toolUse.getId()); - assertEquals("weather", toolUse.getName()); - assertTrue(toolUse.getInput().isEmpty()); - } - - @Test - void testParseStreamEventMessageDeltaUsage() throws Exception { - RawMessageStreamEvent event = - RawMessageStreamEvent.ofMessageDelta( - RawMessageDeltaEvent.builder() - .delta( - RawMessageDeltaEvent.Delta.builder() - .stopReason(StopReason.END_TURN) - .stopSequence(Optional.empty()) - .build()) - .usage( - MessageDeltaUsage.builder() - .cacheCreationInputTokens(Optional.empty()) - .cacheReadInputTokens(Optional.empty()) - .inputTokens(Optional.empty()) - .outputTokens(7) - .serverToolUse(Optional.empty()) - .build()) - .build()); - - ChatResponse response = invokeParseStreamEvent(event, Instant.now()); - - assertNotNull(response.getUsage()); - assertEquals(7, response.getUsage().getOutputTokens()); - } - @Test void testParseStreamPreservesSignatureAndRedactedThinking() { RawMessageStreamEvent firstThinking = @@ -539,82 +455,6 @@ void testParseStreamPreservesSignatureAndRedactedThinking() { .verifyComplete(); } - @Test - void testParseStreamPreservesOmittedThinkingSignature() { - RawMessageStreamEvent start = - RawMessageStreamEvent.ofContentBlockStart( - RawContentBlockStartEvent.builder() - .index(0) - .contentBlock( - com.anthropic.models.messages.ThinkingBlock.builder() - .thinking("") - .signature("") - .build()) - .build()); - RawMessageStreamEvent signature = - RawMessageStreamEvent.ofContentBlockDelta( - RawContentBlockDeltaEvent.builder() - .index(0) - .signatureDelta("signature-omitted") - .build()); - RawMessageStreamEvent stop = - RawMessageStreamEvent.ofContentBlockStop( - RawContentBlockStopEvent.builder().index(0).build()); - - StepVerifier.create( - AnthropicResponseParser.parseStreamEvents( - Flux.just(start, signature, stop), Instant.now()) - .collectList()) - .assertNext( - chunks -> { - ReasoningContext context = new ReasoningContext("Assistant"); - chunks.forEach(context::processChunk); - ThinkingBlock thinking = - context.buildFinalMessage() - .getFirstContentBlock(ThinkingBlock.class); - List nativeBlocks = - AnthropicThinkingMetadata.toContentBlockParams(thinking); - - assertEquals("", thinking.getThinking()); - assertEquals(1, nativeBlocks.size()); - assertEquals( - "signature-omitted", - nativeBlocks.get(0).asThinking().signature()); - }) - .verifyComplete(); - } - - @Test - void testParseStreamPreservesThinkingFromContentBlockStart() { - RawMessageStreamEvent start = - RawMessageStreamEvent.ofContentBlockStart( - RawContentBlockStartEvent.builder() - .index(2) - .contentBlock( - com.anthropic.models.messages.ThinkingBlock.builder() - .thinking("initial reasoning") - .signature("signature-start") - .build()) - .build()); - - StepVerifier.create( - AnthropicResponseParser.parseStreamEvents(Flux.just(start), Instant.now())) - .assertNext( - response -> { - ThinkingBlock thinking = - assertInstanceOf( - ThinkingBlock.class, response.getContent().get(0)); - List nativeBlocks = - AnthropicThinkingMetadata.toContentBlockParams(thinking); - - assertEquals("initial reasoning", thinking.getThinking()); - assertEquals( - "signature-start", - nativeBlocks.get(0).asThinking().signature()); - }) - .verifyComplete(); - } - @Test void testParseStreamEventUnknownType() throws Exception { // Test unknown event type - should return empty response diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java deleted file mode 100644 index ac38197449..0000000000 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadataTest.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.extensions.model.anthropic.formatter; - -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.anthropic.models.messages.ContentBlockParam; -import io.agentscope.core.message.ThinkingBlock; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** Unit tests for Anthropic thinking metadata validation and conversion. */ -class AnthropicThinkingMetadataTest { - - private static final String KEY_PREFIX = "anthropicThinkingBlock:"; - - @Test - void testReturnsEmptyWithoutAnthropicMetadata() { - assertAll( - () -> assertTrue(AnthropicThinkingMetadata.toContentBlockParams(null).isEmpty()), - () -> - assertTrue( - AnthropicThinkingMetadata.toContentBlockParams( - ThinkingBlock.builder().build()) - .isEmpty()), - () -> - assertTrue( - AnthropicThinkingMetadata.toContentBlockParams( - block(Map.of("provider", "other"))) - .isEmpty())); - } - - @Test - void testConvertsNullThinkingToEmptyText() { - ThinkingBlock block = block(AnthropicThinkingMetadata.thinking(0, null, "signature-123")); - - List result = AnthropicThinkingMetadata.toContentBlockParams(block); - - assertEquals(1, result.size()); - assertEquals("", result.get(0).asThinking().thinking()); - assertEquals("signature-123", result.get(0).asThinking().signature()); - } - - @Test - void testRejectsMalformedMetadataKeys() { - assertAll( - () -> assertInvalid(Map.of(KEY_PREFIX + "invalid", Map.of())), - () -> assertInvalid(Map.of(KEY_PREFIX + "-1", Map.of())), - () -> assertInvalid(Map.of(KEY_PREFIX, Map.of())), - () -> - assertInvalid( - Map.of( - KEY_PREFIX + "1", - validThinking("one"), - KEY_PREFIX + "01", - validThinking("duplicate")))); - } - - @Test - void testRejectsMalformedThinkingBlocks() { - Map nullValue = new HashMap<>(); - nullValue.put(KEY_PREFIX + "0", null); - - assertAll( - () -> assertInvalid(nullValue), - () -> assertInvalid(Map.of(KEY_PREFIX + "0", "not-a-map")), - () -> assertInvalid(storedBlock(Map.of("type", "unknown"))), - () -> - assertInvalid( - storedBlock( - Map.of( - "type", "thinking", - "signature", "signature-123"))), - () -> - assertInvalid( - storedBlock( - Map.of( - "type", "thinking", - "thinking", "reasoning"))), - () -> - assertInvalid( - storedBlock( - Map.of( - "type", "thinking", - "thinking", "reasoning", - "signature", ""))), - () -> - assertInvalid( - storedBlock( - Map.of( - "type", "thinking", - "thinking", 1, - "signature", "signature-123")))); - } - - @Test - void testRejectsMalformedRedactedThinkingBlocks() { - assertAll( - () -> assertInvalid(storedBlock(Map.of("type", "redacted_thinking"))), - () -> - assertInvalid( - storedBlock( - Map.of( - "type", "redacted_thinking", - "data", ""))), - () -> assertInvalid(storedBlock(Map.of("type", "redacted_thinking", "data", 1)))); - } - - private static Map storedBlock(Object value) { - return Map.of(KEY_PREFIX + "0", value); - } - - private static Map validThinking(String thinking) { - return Map.of( - "type", "thinking", - "thinking", thinking, - "signature", "signature-123"); - } - - private static ThinkingBlock block(Map metadata) { - return ThinkingBlock.builder().thinking("reasoning").metadata(metadata).build(); - } - - private static void assertInvalid(Map metadata) { - assertThrows( - IllegalArgumentException.class, - () -> AnthropicThinkingMetadata.toContentBlockParams(block(metadata))); - } -} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java index 19d7113c9d..e357bde51b 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicToolsHelperTest.java @@ -156,31 +156,8 @@ void testApplyToolChoiceNone() { MessageCreateParams params = builder.build(); assertTrue(params.toolChoice().isPresent()); - assertTrue(params.toolChoice().get().isNone()); - } - - @Test - void testApplyThinkingWithToolChoiceNone() { - MessageCreateParams.Builder builder = createBuilder(); - ToolSchema schema = - ToolSchema.builder() - .name("search") - .description("Search") - .parameters(Map.of("type", "object")) - .build(); - GenerateOptions options = - GenerateOptions.builder() - .maxTokens(2048) - .thinkingBudget(1024) - .toolChoice(new ToolChoice.None()) - .build(); - - AnthropicToolsHelper.applyOptions(builder, options, null); - AnthropicToolsHelper.applyTools(builder, List.of(schema), options); - - MessageCreateParams params = builder.build(); - assertTrue(params.thinking().orElseThrow().isEnabled()); - assertTrue(params.toolChoice().orElseThrow().isNone()); + // None maps to "any" in Anthropic + assertTrue(params.toolChoice().get().isAny()); } @Test From 33632b4e8a15761aa94fddb2b54d93ed32fa2af4 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Sat, 18 Jul 2026 15:39:36 +0800 Subject: [PATCH 5/6] fix(anthropic): validate thinking metadata --- .../formatter/AnthropicThinkingMetadata.java | 47 +++++++++-------- .../AnthropicMessageConverterTest.java | 50 +++++++++++++++++++ docs/v2/en/integration/model/anthropic.md | 8 +-- docs/v2/zh/integration/model/anthropic.md | 6 +-- 4 files changed, 84 insertions(+), 27 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java index 56b37393cc..682f83c0dc 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicThinkingMetadata.java @@ -52,21 +52,24 @@ static List toContentBlockParams(ThinkingBlock block) { return List.of(); } - List> storedBlocks = - block.getMetadata().entrySet().stream() - .filter( - entry -> - entry.getKey() != null - && entry.getKey().startsWith(KEY_PREFIX)) - .map( - entry -> - Map.entry( - Long.parseLong( - entry.getKey() - .substring(KEY_PREFIX.length())), - entry.getValue())) - .sorted(Comparator.comparingLong(Map.Entry::getKey)) - .toList(); + List> storedBlocks = new ArrayList<>(); + for (Map.Entry entry : block.getMetadata().entrySet()) { + String key = entry.getKey(); + if (key == null || !key.startsWith(KEY_PREFIX)) { + continue; + } + + try { + long index = Long.parseLong(key.substring(KEY_PREFIX.length())); + if (entry.getValue() == null) { + return List.of(); + } + storedBlocks.add(Map.entry(index, entry.getValue())); + } catch (NumberFormatException e) { + return List.of(); + } + } + storedBlocks.sort(Comparator.comparingLong(Map.Entry::getKey)); List result = new ArrayList<>(storedBlocks.size()); for (Map.Entry storedBlock : storedBlocks) { @@ -85,16 +88,20 @@ private static ContentBlockParam convert(Object value) { } if ("thinking".equals(map.get("type"))) { + if (!(map.get("thinking") instanceof String thinking) + || !(map.get("signature") instanceof String signature)) { + return null; + } return ContentBlockParam.ofThinking( - ThinkingBlockParam.builder() - .thinking((String) map.get("thinking")) - .signature((String) map.get("signature")) - .build()); + ThinkingBlockParam.builder().thinking(thinking).signature(signature).build()); } if ("redacted_thinking".equals(map.get("type"))) { + if (!(map.get("data") instanceof String data)) { + return null; + } return ContentBlockParam.ofRedactedThinking( - RedactedThinkingBlockParam.builder().data((String) map.get("data")).build()); + RedactedThinkingBlockParam.builder().data(data).build()); } return null; diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java index 4380d05729..6dd2658b21 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicMessageConverterTest.java @@ -233,6 +233,56 @@ void testConvertAnthropicThinkingBlocksAfterJsonRoundTrip() { assertEquals("encrypted-data", blocks.get(1).asRedactedThinking().data()); } + @Test + void testConvertMalformedAnthropicThinkingMetadataFallsBackToText() { + List> malformedMetadata = + List.of( + Map.of( + "anthropicThinkingBlock:invalid", + Map.of( + "type", + "thinking", + "thinking", + "Reasoning", + "signature", + "signature-123")), + Map.of( + "anthropicThinkingBlock:0", + Map.of( + "type", + "thinking", + "thinking", + 42, + "signature", + "signature-123")), + Map.of( + "anthropicThinkingBlock:0", + Map.of("type", "thinking", "thinking", "Reasoning")), + Map.of( + "anthropicThinkingBlock:0", + Map.of("type", "redacted_thinking", "data", 42))); + + for (Map metadata : malformedMetadata) { + Msg msg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content( + ThinkingBlock.builder() + .thinking("Fallback reasoning") + .metadata(metadata) + .build()) + .build(); + + List blocks = + converter.convert(List.of(msg)).get(0).content().asBlockParams(); + + assertEquals(1, blocks.size()); + assertTrue(blocks.get(0).isText()); + assertEquals("Fallback reasoning", blocks.get(0).asText().text()); + } + } + @Test void testConvertToolUseBlock() { Map input = Map.of("query", "test"); diff --git a/docs/v2/en/integration/model/anthropic.md b/docs/v2/en/integration/model/anthropic.md index a65a081e56..52c5038af3 100644 --- a/docs/v2/en/integration/model/anthropic.md +++ b/docs/v2/en/integration/model/anthropic.md @@ -43,10 +43,10 @@ AnthropicChatModel model = AnthropicChatModel.builder() ``` Setting `thinkingBudget` enables Anthropic extended thinking with the corresponding -`budget_tokens` value. For models that support manual thinking budgets, the budget must be lower -than `maxTokens`. Anthropic extended thinking is incompatible with custom `temperature`, `topK`, -and forced tool choice settings; consult the Anthropic model documentation for model-specific -requirements. +`budget_tokens` value. For models that support manual thinking budgets, the budget normally must be +lower than `maxTokens`; interleaved thinking with tools is an exception. Anthropic extended thinking +is incompatible with custom `temperature`, `topK`, and forced tool choice settings; consult the +Anthropic model documentation for model-specific requirements. ## Spring Boot diff --git a/docs/v2/zh/integration/model/anthropic.md b/docs/v2/zh/integration/model/anthropic.md index 58d6d9b0ee..5f32adeb03 100644 --- a/docs/v2/zh/integration/model/anthropic.md +++ b/docs/v2/zh/integration/model/anthropic.md @@ -43,9 +43,9 @@ AnthropicChatModel model = AnthropicChatModel.builder() ``` 设置 `thinkingBudget` 会启用 Anthropic extended thinking,并映射为对应的 -`budget_tokens`。对于支持手动 thinking budget 的模型,该值必须小于 `maxTokens`。 -Anthropic extended thinking 不兼容自定义 `temperature`、`topK` 和强制 tool choice;不同 -模型的具体要求请参考 Anthropic 模型文档。 +`budget_tokens`。对于支持手动 thinking budget 的模型,该值通常必须小于 `maxTokens`; +使用工具的 interleaved thinking 是例外。Anthropic extended thinking 不兼容自定义 +`temperature`、`topK` 和强制 tool choice;不同模型的具体要求请参考 Anthropic 模型文档。 ## Spring Boot From 1bcd3d7c771eb53c7a8cfab0edcd09d20c27b9c3 Mon Sep 17 00:00:00 2001 From: fuhui <15357295287@163.com> Date: Sat, 18 Jul 2026 16:28:39 +0800 Subject: [PATCH 6/6] refactor(anthropic): preserve parser style --- .../formatter/AnthropicResponseParser.java | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java index 4ebd3f32e1..e9aedfaf0c 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/formatter/AnthropicResponseParser.java @@ -52,6 +52,7 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { // Process content blocks for (int index = 0; index < message.content().size(); index++) { + final int blockIndex = index; var block = message.content().get(index); // Text block block.text() @@ -79,27 +80,32 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { }); // Thinking block (extended thinking) - if (block.thinking().isPresent()) { - var thinking = block.thinking().get(); - contentBlocks.add( - ThinkingBlock.builder() - .thinking(thinking.thinking()) - .metadata( - AnthropicThinkingMetadata.thinking( - index, thinking.thinking(), thinking.signature())) - .build()); - } + block.thinking() + .ifPresent( + thinking -> + contentBlocks.add( + ThinkingBlock.builder() + .thinking(thinking.thinking()) + .metadata( + AnthropicThinkingMetadata.thinking( + blockIndex, + thinking.thinking(), + thinking.signature())) + .build())); // Redacted thinking block - if (block.redactedThinking().isPresent()) { - var redactedThinking = block.redactedThinking().get(); - contentBlocks.add( - ThinkingBlock.builder() - .metadata( - AnthropicThinkingMetadata.redactedThinking( - index, redactedThinking.data())) - .build()); - } + block.redactedThinking() + .ifPresent( + redactedThinking -> + contentBlocks.add( + ThinkingBlock.builder() + .metadata( + AnthropicThinkingMetadata + .redactedThinking( + blockIndex, + redactedThinking + .data())) + .build())); } // Parse usage