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..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 @@ -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++) { + final int blockIndex = 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( + blockIndex, + thinking.thinking(), + thinking.signature())) + .build())); + + // Redacted thinking block + block.redactedThinking() + .ifPresent( + redactedThinking -> + contentBlocks.add( + ThinkingBlock.builder() + .metadata( + AnthropicThinkingMetadata + .redactedThinking( + blockIndex, + redactedThinking + .data())) .build())); } @@ -102,23 +124,33 @@ 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.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()); + } + }); + }); } /** * 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 +176,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 +221,21 @@ private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instan if (event.isContentBlockStart()) { var startEvent = event.asContentBlockStart(); + startEvent + .contentBlock() + .redactedThinking() + .ifPresent( + redactedThinking -> + contentBlocks.add( + ThinkingBlock.builder() + .metadata( + AnthropicThinkingMetadata + .redactedThinking( + startEvent.index(), + redactedThinking + .data())) + .build())); + startEvent .contentBlock() .toolUse() @@ -199,6 +264,24 @@ 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() : ""; + } + } + /** * 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..682f83c0dc --- /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,113 @@ +/* + * 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.List; +import java.util.Map; + +/** Stores Anthropic thinking blocks in provider-specific {@link ThinkingBlock} metadata. */ +final class AnthropicThinkingMetadata { + + private static final String KEY_PREFIX = "anthropicThinkingBlock:"; + + private AnthropicThinkingMetadata() {} + + static Map thinking(long index, String thinking, String signature) { + return Map.of( + key(index), + Map.of( + "type", + "thinking", + "thinking", + thinking != null ? thinking : "", + "signature", + signature)); + } + + static Map redactedThinking(long index, String data) { + return Map.of(key(index), Map.of("type", "redacted_thinking", "data", data)); + } + + static List toContentBlockParams(ThinkingBlock block) { + if (block == null || block.getMetadata() == null || block.getMetadata().isEmpty()) { + return List.of(); + } + + 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) { + ContentBlockParam converted = convert(storedBlock.getValue()); + if (converted == null) { + return List.of(); + } + result.add(converted); + } + return result; + } + + private static ContentBlockParam convert(Object value) { + if (!(value instanceof Map map)) { + return null; + } + + 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(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(data).build()); + } + + return null; + } + + private static String key(long index) { + return KEY_PREFIX + index; + } +} 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..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 @@ -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,85 @@ void testConvertThinkingBlock() { assertEquals("Let me think...", blocks.get(0).asText().text()); } + @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()); + 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 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/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..52c5038af3 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 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 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..5f32adeb03 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`; +使用工具的 interleaved thinking 是例外。Anthropic extended thinking 不兼容自定义 +`temperature`、`topK` 和强制 tool choice;不同模型的具体要求请参考 Anthropic 模型文档。 + ## Spring Boot Spring Boot 应用可以使用 Anthropic starter: