Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,23 +30,30 @@
public class ThinkingAccumulator implements ContentAccumulator<ThinkingBlock> {

private final StringBuilder accumulated = new StringBuilder();
private final Map<String, Object> 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());
}
}

/**
* @hidden
*/
@Override
public boolean hasContent() {
return accumulated.length() > 0;
return accumulated.length() > 0 || !metadata.isEmpty();
}

/**
Expand All @@ -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();
}

/**
Expand All @@ -64,6 +76,7 @@ public ContentBlock buildAggregated() {
@Override
public void reset() {
accumulated.setLength(0);
metadata.clear();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContentBlockParam> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,7 +51,9 @@ public static ChatResponse parseMessage(Message message, Instant startTime) {
List<ContentBlock> 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(
Expand Down Expand Up @@ -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()));
}

Expand All @@ -102,23 +124,33 @@ public static ChatResponse parseMessage(Message message, Instant startTime) {
*/
public static Flux<ChatResponse> parseStreamEvents(
Flux<RawMessageStreamEvent> 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.<ChatResponse>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<ContentBlock> contentBlocks = new ArrayList<>();
ChatUsage usage = null;
String messageId = null;
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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<Long, StringBuilder> 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.
*/
Expand Down
Loading
Loading