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..602aae71bb 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) { + metadata.putAll(block.getMetadata()); + } } /** @@ -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/main/java/io/agentscope/core/message/ThinkingBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ThinkingBlock.java index fc337fdb3b..c041810914 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ThinkingBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ThinkingBlock.java @@ -39,6 +39,9 @@ public final class ThinkingBlock extends ContentBlock { /** Metadata key for storing OpenRouter/Gemini reasoning details list. */ public static final String METADATA_REASONING_DETAILS = "reasoningDetails"; + /** Metadata key for storing the signature of an Anthropic extended-thinking block. */ + public static final String METADATA_ANTHROPIC_SIGNATURE = "anthropicSignature"; + private final String thinking; private final Map metadata; @@ -73,6 +76,8 @@ public String getThinking() { *
    *
  • {@link #METADATA_REASONING_DETAILS} - List of OpenAIReasoningDetail objects from * OpenRouter/Gemini + *
  • {@link #METADATA_ANTHROPIC_SIGNATURE} - Signature of an Anthropic extended-thinking + * block *
* * @return The metadata map, or null if no metadata is set 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..6dc8a750cf 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,41 @@ void testChunksWithoutUsage() { assertNull(resultUsage); } + @Test + @DisplayName("Should preserve thinking metadata across streaming chunks") + void testThinkingMetadataAccumulation() { + ChatResponse thinkingChunk = + ChatResponse.builder() + .id("msg-1") + .content(List.of(ThinkingBlock.builder().thinking("Reasoning").build())) + .build(); + ChatResponse signatureChunk = + ChatResponse.builder() + .id("msg-1") + .content( + List.of( + ThinkingBlock.builder() + .metadata( + Map.of( + ThinkingBlock + .METADATA_ANTHROPIC_SIGNATURE, + "stream-signature")) + .build())) + .build(); + + context.processChunk(thinkingChunk); + context.processChunk(signatureChunk); + + ThinkingBlock aggregated = + context.buildFinalMessage().getFirstContentBlock(ThinkingBlock.class); + assertNotNull(aggregated); + assertEquals("Reasoning", aggregated.getThinking()); + assertNotNull(aggregated.getMetadata()); + assertEquals( + "stream-signature", + aggregated.getMetadata().get(ThinkingBlock.METADATA_ANTHROPIC_SIGNATURE)); + } + @Test @DisplayName("Should handle mixed chunks with and without usage") void testMixedChunksWithAndWithoutUsage() { 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..a300a1c71c --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/accumulator/ThinkingAccumulatorTest.java @@ -0,0 +1,61 @@ +/* + * 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.assertNotNull; +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.Test; + +class ThinkingAccumulatorTest { + + @Test + void shouldHandleNullBlocksAndClearMetadataOnReset() { + ThinkingAccumulator accumulator = new ThinkingAccumulator(); + + accumulator.add(null); + assertFalse(accumulator.hasContent()); + assertNull(accumulator.buildAggregated()); + + accumulator.add(ThinkingBlock.builder().thinking("Reasoning").build()); + accumulator.add( + ThinkingBlock.builder() + .metadata( + Map.of( + ThinkingBlock.METADATA_ANTHROPIC_SIGNATURE, + "stream-signature")) + .build()); + + ThinkingBlock aggregated = (ThinkingBlock) accumulator.buildAggregated(); + assertNotNull(aggregated); + assertEquals("Reasoning", aggregated.getThinking()); + assertNotNull(aggregated.getMetadata()); + assertEquals( + "stream-signature", + aggregated.getMetadata().get(ThinkingBlock.METADATA_ANTHROPIC_SIGNATURE)); + + accumulator.reset(); + + assertFalse(accumulator.hasContent()); + assertNull(accumulator.buildAggregated()); + assertTrue(accumulator.getAccumulated().isEmpty()); + } +} 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..53080ae54e 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 @@ -21,6 +21,7 @@ import com.anthropic.models.messages.MessageParam; import com.anthropic.models.messages.MessageParam.Role; import com.anthropic.models.messages.TextBlockParam; +import com.anthropic.models.messages.ThinkingBlockParam; import com.anthropic.models.messages.ToolResultBlockParam; import com.anthropic.models.messages.ToolUseBlockParam; import io.agentscope.core.message.ContentBlock; @@ -257,12 +258,22 @@ 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())); + Object signature = + thinkingBlock.getMetadata() != null + ? thinkingBlock + .getMetadata() + .get(ThinkingBlock.METADATA_ANTHROPIC_SIGNATURE) + : null; + if (signature instanceof String signatureText && !signatureText.isBlank()) { + contentBlocks.add( + ContentBlockParam.ofThinking( + ThinkingBlockParam.builder() + .thinking(thinkingBlock.getThinking()) + .signature(signatureText) + .build())); + } else { + log.debug("Skipping unsigned ThinkingBlock when formatting Anthropic history."); + } } 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..d805c12e0b 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 @@ -83,6 +83,11 @@ public static ChatResponse parseMessage(Message message, Instant startTime) { contentBlocks.add( ThinkingBlock.builder() .thinking(thinking.thinking()) + .metadata( + Map.of( + ThinkingBlock + .METADATA_ANTHROPIC_SIGNATURE, + thinking.signature())) .build())); } @@ -150,6 +155,20 @@ private static ChatResponse parseStreamEvent(RawMessageStreamEvent event, Instan .thinking(thinkingDelta.thinking()) .build())); + deltaEvent + .delta() + .signature() + .ifPresent( + signatureDelta -> + contentBlocks.add( + ThinkingBlock.builder() + .metadata( + Map.of( + ThinkingBlock + .METADATA_ANTHROPIC_SIGNATURE, + signatureDelta.signature())) + .build())); + // Input JSON delta (tool calling) deltaEvent .delta() 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..82d60b2e0b 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 @@ -191,16 +191,53 @@ void testConvertThinkingBlock() { List.of( ThinkingBlock.builder() .thinking("Let me think...") + .metadata( + Map.of( + ThinkingBlock + .METADATA_ANTHROPIC_SIGNATURE, + "signature-123")) .build())) .build(); List result = converter.convert(List.of(msg)); + assertEquals(1, result.size()); + List blocks = result.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 testConvertUnsignedThinkingBlockSkipsIt() { + Msg msg = + Msg.builder() + .name("Assistant") + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking("Unsigned thinking") + .build(), + ThinkingBlock.builder() + .thinking("Blank signature thinking") + .metadata( + Map.of( + ThinkingBlock + .METADATA_ANTHROPIC_SIGNATURE, + " ")) + .build(), + TextBlock.builder().text("Visible answer").build())) + .build(); + + List result = converter.convert(List.of(msg)); + assertEquals(1, result.size()); List blocks = result.get(0).content().asBlockParams(); assertEquals(1, blocks.size()); assertTrue(blocks.get(0).isText()); - assertEquals("Let me think...", blocks.get(0).asText().text()); + assertEquals("Visible answer", blocks.get(0).asText().text()); } @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..578b591084 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 @@ -161,6 +161,7 @@ void testParseMessageWithThinkingBlock() { when(contentBlock.toolUse()).thenReturn(Optional.empty()); when(contentBlock.thinking()).thenReturn(Optional.of(thinkingBlock)); when(thinkingBlock.thinking()).thenReturn("Let me think about this..."); + when(thinkingBlock.signature()).thenReturn("signature-123"); Instant startTime = Instant.now(); ChatResponse response = AnthropicResponseParser.parseMessage(message, startTime); @@ -171,6 +172,10 @@ void testParseMessageWithThinkingBlock() { ThinkingBlock parsedThinking = assertInstanceOf(ThinkingBlock.class, response.getContent().get(0)); assertEquals("Let me think about this...", parsedThinking.getThinking()); + assertNotNull(parsedThinking.getMetadata()); + assertEquals( + "signature-123", + parsedThinking.getMetadata().get(ThinkingBlock.METADATA_ANTHROPIC_SIGNATURE)); } @Test @@ -337,6 +342,27 @@ void testParseStreamEventThinkingDelta() throws Exception { assertNull(response.getUsage()); } + @Test + void testParseStreamEventSignatureDelta() throws Exception { + RawContentBlockDeltaEvent deltaEvent = + RawContentBlockDeltaEvent.builder() + .index(0) + .signatureDelta("stream-signature") + .build(); + RawMessageStreamEvent event = RawMessageStreamEvent.ofContentBlockDelta(deltaEvent); + + ChatResponse response = invokeParseStreamEvent(event, Instant.now()); + + assertEquals(1, response.getContent().size()); + ThinkingBlock parsedThinking = + assertInstanceOf(ThinkingBlock.class, response.getContent().get(0)); + assertEquals("", parsedThinking.getThinking()); + assertNotNull(parsedThinking.getMetadata()); + assertEquals( + "stream-signature", + parsedThinking.getMetadata().get(ThinkingBlock.METADATA_ANTHROPIC_SIGNATURE)); + } + @Test void testParseStreamEventUnknownType() throws Exception { // Test unknown event type - should return empty response