diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java index 8870e2689e..bb640045e4 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java @@ -15,8 +15,12 @@ */ package io.agentscope.core.agui.adapter; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.model.ToolMergeMode; import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; /** * Configuration for the AG-UI agent adapter. @@ -30,16 +34,23 @@ public class AguiAdapterConfig { private final boolean emitStateEvents; private final boolean emitToolCallArgs; private final boolean enableReasoning; + private final boolean enableActingChunk; private final Duration runTimeout; private final String defaultAgentId; + private final Map, BlockEventConverter> customConverters; private AguiAdapterConfig(Builder builder) { this.toolMergeMode = builder.toolMergeMode; this.emitStateEvents = builder.emitStateEvents; this.emitToolCallArgs = builder.emitToolCallArgs; this.enableReasoning = builder.enableReasoning; + this.enableActingChunk = builder.enableActingChunk; this.runTimeout = builder.runTimeout; this.defaultAgentId = builder.defaultAgentId; + this.customConverters = + builder.customConverters != null + ? Map.copyOf(builder.customConverters) + : Collections.emptyMap(); } /** @@ -82,6 +93,18 @@ public boolean isEnableReasoning() { return enableReasoning; } + /** + * Check if intermediate acting chunk emissions should be included. + * + *

When enabled, intermediate tool execution outputs (such as progress) + * will be streamed to the frontend. + * + * @return true if acting chunks should be included + */ + public boolean isEnableActingChunk() { + return enableActingChunk; + } + /** * Get the run timeout duration. * @@ -100,6 +123,15 @@ public String getDefaultAgentId() { return defaultAgentId; } + /** + * Get the custom block event converters. + * + * @return Immutable map of custom converters + */ + public Map, BlockEventConverter> getCustomConverters() { + return customConverters; + } + /** * Creates a new builder for AguiAdapterConfig. * @@ -127,8 +159,10 @@ public static class Builder { private boolean emitStateEvents = true; private boolean emitToolCallArgs = true; private boolean enableReasoning = false; + private boolean enableActingChunk = true; private Duration runTimeout = Duration.ofMinutes(10); private String defaultAgentId; + private final Map, BlockEventConverter> customConverters = new HashMap<>(); /** * Set the tool merge mode. @@ -178,6 +212,20 @@ public Builder enableReasoning(boolean enableReasoning) { return this; } + /** + * Set whether to enable acting chunk emissions. + * + *

When enabled, tools can emit intermediate chunks (e.g., Custom events for progress + * or real-time logs) during execution. Default is true. + * + * @param enableActingChunk true to enable acting chunks + * @return This builder + */ + public Builder enableActingChunk(boolean enableActingChunk) { + this.enableActingChunk = enableActingChunk; + return this; + } + /** * Set the run timeout duration. * @@ -200,6 +248,22 @@ public Builder defaultAgentId(String defaultAgentId) { return this; } + /** + * Register a custom block event converter. + * + *

This allows users to override the default conversion strategies for specific + * ContentBlock types, enabling deep customization of AG-UI event generation. + * + * @param converter The custom converter strategy + * @return This builder + */ + public Builder registerConverter(BlockEventConverter converter) { + if (converter != null && converter.supportedBlockType() != null) { + this.customConverters.put(converter.supportedBlockType(), converter); + } + return this; + } + /** * Build the configuration. * diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java index 2314e72b9d..20e6d51f02 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java @@ -19,24 +19,22 @@ import io.agentscope.core.agent.Event; import io.agentscope.core.agent.EventType; import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; +import io.agentscope.core.agui.adapter.strategy.TextBlockConverter; +import io.agentscope.core.agui.adapter.strategy.ThinkingBlockConverter; +import io.agentscope.core.agui.adapter.strategy.ToolResultBlockConverter; +import io.agentscope.core.agui.adapter.strategy.ToolUseBlockConverter; import io.agentscope.core.agui.converter.AguiMessageConverter; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; -import io.agentscope.core.message.TextBlock; -import io.agentscope.core.message.ThinkingBlock; -import io.agentscope.core.message.ToolResultBlock; -import io.agentscope.core.message.ToolUseBlock; -import io.agentscope.core.util.JsonException; -import io.agentscope.core.util.JsonUtils; import java.util.ArrayList; -import java.util.LinkedHashSet; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; -import java.util.UUID; import reactor.core.publisher.Flux; /** @@ -48,15 +46,14 @@ *

Event Mapping: *

* *

Reasoning Support: *

@@ -66,17 +63,41 @@ public class AguiAgentAdapter { private final Agent agent; private final AguiAdapterConfig config; private final AguiMessageConverter messageConverter; + private final Map, BlockEventConverter> converters; /** - * Creates a new AguiAgentAdapter. + * Creates a new AguiAgentAdapter and registers all block conversion strategies. * - * @param agent The agent to adapt + * @param agent The agent to adapt * @param config The adapter configuration */ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { this.agent = Objects.requireNonNull(agent, "agent cannot be null"); this.config = Objects.requireNonNull(config, "config cannot be null"); this.messageConverter = new AguiMessageConverter(); + + Map, BlockEventConverter> tempConverters = new HashMap<>(); + + // Register default block conversion strategies + registerDefault(tempConverters, new TextBlockConverter()); + registerDefault(tempConverters, new ThinkingBlockConverter()); + registerDefault(tempConverters, new ToolUseBlockConverter()); + registerDefault(tempConverters, new ToolResultBlockConverter()); + + // Override with custom converters if provided by the user + if (!config.getCustomConverters().isEmpty()) { + tempConverters.putAll(config.getCustomConverters()); + } + + this.converters = Collections.unmodifiableMap(tempConverters); + } + + /** + * Helper method to safely register a default converter using its own declared supported type. + */ + private void registerDefault( + Map, BlockEventConverter> map, BlockEventConverter converter) { + map.put(converter.supportedBlockType(), converter); } /** @@ -102,386 +123,79 @@ public Flux run(RunAgentInput input) { StreamOptions.builder() .eventTypes(EventType.ALL) .incremental(true) + .includeActingChunk(config.isEnableActingChunk()) .build(); - // Track state for event conversion - EventConversionState state = new EventConversionState(threadId, runId); + StreamContext ctx = new StreamContext(threadId, runId, config); return Flux.concat( // Emit RUN_STARTED Flux.just(new AguiEvent.RunStarted(threadId, runId)), + // Stream agent events and convert to AG-UI events // Use concatMapIterable to preserve strict event ordering agent.stream(msgs, options) - .concatMapIterable(event -> convertEvent(event, state)), - // Emit any pending end events and RUN_FINISHED - Flux.defer(() -> finishRun(state))) - .onErrorResume( - error -> { - // On error, emit RawEvent with error info followed by - // RunFinished - String errorMessage = - error.getMessage() != null - ? error.getMessage() - : error.getClass().getSimpleName(); - return Flux.just( - new AguiEvent.Raw( - threadId, - runId, - Map.of("error", errorMessage)), - new AguiEvent.RunFinished(threadId, runId)); - }); - }); - } + .concatMapIterable(event -> processEvent(event, ctx)), - /** - * Convert an AgentScope event to AG-UI events. - * - * @param event The AgentScope event - * @param state The conversion state - * @return List of AG-UI events - */ - private List convertEvent(Event event, EventConversionState state) { - List events = new ArrayList<>(); - Msg msg = event.getMessage(); - EventType type = event.getType(); - - if (type == EventType.REASONING || type == EventType.SUMMARY) { - // Handle reasoning/summary events - convert to text messages and tool calls - for (ContentBlock block : msg.getContent()) { - if (block instanceof TextBlock textBlock) { - String text = textBlock.getText(); - if (text != null && !text.isEmpty()) { - String messageId = msg.getId(); - - // Start message if not started - if (!state.hasStartedMessage(messageId)) { - events.add( - new AguiEvent.TextMessageStart( - state.threadId, state.runId, messageId, "assistant")); - state.startMessage(messageId); - } - - if (!event.isLast()) { - // In incremental mode, text is already the delta - events.add( - new AguiEvent.TextMessageContent( - state.threadId, state.runId, messageId, text)); - } else { - // End message if this is the last event - if (!state.hasEndedMessage(messageId)) { - events.add( - new AguiEvent.TextMessageEnd( - state.threadId, state.runId, messageId)); - state.endMessage(messageId); - } - } - } - } else if (block instanceof ThinkingBlock thinkingBlock) { - // Handle thinking blocks - convert to REASONING_* events (only if enabled) - // According to AG-UI Reasoning draft: https://docs.ag-ui.com/drafts/reasoning - if (config.isEnableReasoning()) { - String thinking = thinkingBlock.getThinking(); - if (thinking != null && !thinking.isEmpty()) { - String messageId = msg.getId(); + // Emit any pending end events + Flux.defer( + () -> + Flux.fromIterable( + ctx.flushAllRemainingDeferred())), - // Start reasoning message if not started - if (!state.hasStartedReasoningMessage(messageId)) { - events.add( - new AguiEvent.ReasoningMessageStart( - state.threadId, - state.runId, - messageId, - "reasoning")); - state.startReasoningMessage(messageId); - } - - if (!event.isLast()) { - // In incremental mode, thinking is already the delta - events.add( - new AguiEvent.ReasoningMessageContent( - state.threadId, state.runId, messageId, thinking)); - } else { - // End reasoning message if this is the last event - events.add( - new AguiEvent.ReasoningMessageEnd( - state.threadId, state.runId, messageId)); - state.endReasoningMessage(messageId); - } - } - } - // If reasoning is disabled, ThinkingBlock content is ignored (backward - // compatibility) - } else if (block instanceof ToolUseBlock toolUse) { - // End any active text message before starting tool call - if (state.hasActiveTextMessage()) { - String activeMessageId = state.getCurrentTextMessageId(); - events.add( - new AguiEvent.TextMessageEnd( - state.threadId, state.runId, activeMessageId)); - state.endMessage(activeMessageId); - } - - // End any active reasoning message before starting tool call - if (state.hasActiveReasoningMessage()) { - String activeReasoningMessageId = state.getCurrentReasoningMessageId(); - events.add( - new AguiEvent.ReasoningMessageEnd( - state.threadId, state.runId, activeReasoningMessageId)); - state.endReasoningMessage(activeReasoningMessageId); - } - - // Emit tool call start - String toolCallId = toolUse.getId(); - if (toolCallId == null) { - toolCallId = UUID.randomUUID().toString(); - } - - if (!state.hasStartedToolCall(toolCallId)) { - events.add( - new AguiEvent.ToolCallStart( - state.threadId, - state.runId, - toolCallId, - toolUse.getName())); - state.startToolCall(toolCallId); - } - - // Emit tool call args if enabled - if (config.isEmitToolCallArgs() && !event.isLast()) { - String args = toolUse.getContent(); - if (args != null && !args.isEmpty()) { - events.add( - new AguiEvent.ToolCallArgs( - state.threadId, state.runId, toolCallId, args)); - } - } - } - } - } else if (type == EventType.TOOL_RESULT && event.isLast()) { - // Handle tool results - for (ContentBlock block : msg.getContent()) { - if (block instanceof ToolResultBlock toolResult) { - String toolCallId = toolResult.getId(); - if (toolCallId == null) { - toolCallId = UUID.randomUUID().toString(); - } - - String result = extractToolResultText(toolResult); - - boolean hasStarted = state.hasStartedToolCall(toolCallId); - if (!hasStarted) { - String toolName = toolResult.getName(); - if (toolName == null || toolName.isBlank()) { - toolName = "unknown"; - } - events.add( - new AguiEvent.ToolCallStart( - state.threadId, state.runId, toolCallId, toolName)); - state.startToolCall(toolCallId); - } - - // Ensure ToolCallEnd is emitted to close arguments phase - events.add(new AguiEvent.ToolCallEnd(state.threadId, state.runId, toolCallId)); - - events.add( - new AguiEvent.ToolCallResult( - state.threadId, - state.runId, - toolCallId, - result, - "tool", - msg.getId())); - state.endToolCall(toolCallId); - } - } - } - - return events; + // Emit RUN_FINISHED + Flux.just(new AguiEvent.RunFinished(threadId, runId))) + .onErrorResume(error -> handleError(threadId, runId, ctx, error)); + }); } /** - * Finish the run by emitting any pending end events and RUN_FINISHED. + * Dispatches the incoming event to the appropriate converter strategies based on block types. * - * @param state The conversion state - * @return Flux of final events + * @param event The incoming agent event + * @param ctx The current stream context + * @return A list of AG-UI events generated during this processing cycle */ - private Flux finishRun(EventConversionState state) { - List events = new ArrayList<>(); - - // End any messages that weren't properly ended - for (String messageId : state.getStartedMessages()) { - if (!state.hasEndedMessage(messageId)) { - events.add(new AguiEvent.TextMessageEnd(state.threadId, state.runId, messageId)); - } - } - - // End any tool calls that weren't properly ended - for (String toolCallId : state.getStartedToolCalls()) { - if (!state.hasEndedToolCall(toolCallId)) { - events.add(new AguiEvent.ToolCallEnd(state.threadId, state.runId, toolCallId)); - } - } - - // End any reasoning messages that weren't properly ended - for (String messageId : state.getStartedReasoningMessages()) { - if (!state.hasEndedReasoningMessage(messageId)) { - events.add( - new AguiEvent.ReasoningMessageEnd(state.threadId, state.runId, messageId)); - } + @SuppressWarnings("unchecked") + private List processEvent(Event event, StreamContext ctx) { + if (event == null || event.getMessage() == null) { + return ctx.getAndClearEmittedEvents(); } - // Emit RUN_FINISHED - events.add(new AguiEvent.RunFinished(state.threadId, state.runId)); - - return Flux.fromIterable(events); - } - - /** - * Extract text content from a tool result block. - * - * @param toolResult The tool result block - * @return The text content, or null if not present - */ - private String extractToolResultText(ToolResultBlock toolResult) { - if (toolResult.getOutput() == null || toolResult.getOutput().isEmpty()) { - return null; - } + // Dispatch each content block to its corresponding converter + for (ContentBlock block : event.getMessage().getContent()) { + BlockEventConverter converter = + (BlockEventConverter) converters.get(block.getClass()); - StringBuilder sb = new StringBuilder(); - for (ContentBlock output : toolResult.getOutput()) { - if (output instanceof TextBlock textBlock) { - if (!sb.isEmpty()) { - sb.append("\n"); - } - sb.append(textBlock.getText()); + if (converter != null && converter.isApplicable(event)) { + converter.convert(block, event, ctx); } } - return !sb.isEmpty() ? sb.toString() : null; + return ctx.getAndClearEmittedEvents(); } /** - * Serialize tool arguments to JSON string. + * Handles errors that occur during the stream pipeline. + * Guarantees that all deferred end events are flushed before the error event is emitted. * - * @param input The tool input map - * @return JSON string representation - */ - private String serializeToolArgs(Map input) { - if (input == null || input.isEmpty()) { - return "{}"; - } - try { - return JsonUtils.getJsonCodec().toJson(input); - } catch (JsonException e) { - return "{}"; - } - } - - /** - * State tracker for event conversion. - * Uses LinkedHashSet to preserve insertion order for proper event sequencing. + * @param threadId The thread ID + * @param runId The run ID + * @param ctx The current stream context + * @param error The thrown exception + * @return A Flux containing the fallback closure events */ - private static class EventConversionState { - final String threadId; - final String runId; - private final Set startedMessages = new LinkedHashSet<>(); - private final Set endedMessages = new LinkedHashSet<>(); - private final Set startedToolCalls = new LinkedHashSet<>(); - private final Set endedToolCalls = new LinkedHashSet<>(); - private final Set startedReasoningMessages = new LinkedHashSet<>(); - private final Set endedReasoningMessages = new LinkedHashSet<>(); - private String currentTextMessageId = null; - private String currentReasoningMessageId = null; - - EventConversionState(String threadId, String runId) { - this.threadId = threadId; - this.runId = runId; - } - - boolean hasStartedMessage(String messageId) { - return startedMessages.contains(messageId); - } - - void startMessage(String messageId) { - startedMessages.add(messageId); - currentTextMessageId = messageId; - } - - void endMessage(String messageId) { - endedMessages.add(messageId); - if (Objects.equals(messageId, currentTextMessageId)) { - currentTextMessageId = null; - } - } - - boolean hasEndedMessage(String messageId) { - return endedMessages.contains(messageId); - } - - String getCurrentTextMessageId() { - return currentTextMessageId; - } - - boolean hasActiveTextMessage() { - return currentTextMessageId != null && !hasEndedMessage(currentTextMessageId); - } - - Set getStartedMessages() { - return startedMessages; - } - - boolean hasStartedToolCall(String toolCallId) { - return startedToolCalls.contains(toolCallId); - } - - void startToolCall(String toolCallId) { - startedToolCalls.add(toolCallId); - } - - void endToolCall(String toolCallId) { - endedToolCalls.add(toolCallId); - } - - boolean hasEndedToolCall(String toolCallId) { - return endedToolCalls.contains(toolCallId); - } - - Set getStartedToolCalls() { - return startedToolCalls; - } - - boolean hasStartedReasoningMessage(String messageId) { - return startedReasoningMessages.contains(messageId); - } - - void startReasoningMessage(String messageId) { - startedReasoningMessages.add(messageId); - currentReasoningMessageId = messageId; - } - - void endReasoningMessage(String messageId) { - endedReasoningMessages.add(messageId); - if (Objects.equals(messageId, currentReasoningMessageId)) { - currentReasoningMessageId = null; - } - } - - boolean hasEndedReasoningMessage(String messageId) { - return endedReasoningMessages.contains(messageId); - } - - String getCurrentReasoningMessageId() { - return currentReasoningMessageId; - } + private Flux handleError( + String threadId, String runId, StreamContext ctx, Throwable error) { + List events = new ArrayList<>(); + events.addAll(ctx.getAndClearEmittedEvents()); + events.addAll(ctx.flushAllRemainingDeferred()); - boolean hasActiveReasoningMessage() { - return currentReasoningMessageId != null - && !hasEndedReasoningMessage(currentReasoningMessageId); - } + String msg = + error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); + events.add(new AguiEvent.Raw(threadId, runId, Map.of("error", msg))); + events.add(new AguiEvent.RunFinished(threadId, runId)); - Set getStartedReasoningMessages() { - return startedReasoningMessages; - } + return Flux.fromIterable(events); } } diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/StreamContext.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/StreamContext.java new file mode 100644 index 0000000000..b2cb9a558a --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/StreamContext.java @@ -0,0 +1,227 @@ +/* + * 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.agui.adapter; + +import io.agentscope.core.agui.event.AguiEvent; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Context holder for the AG-UI stream pipeline. + * Manages the emission of immediate events and the queuing of deferred end events + * to ensure strict adherence to the AG-UI lifecycle protocol. + */ +public class StreamContext { + private final String threadId; + private final String runId; + private final AguiAdapterConfig config; + + // Events ready to be emitted in the current processing cycle + private final List emittedEvents = new ArrayList<>(); + + // Deferred event queue for storing pending end events (Key: prefix + id) + private final Map deferredEndEvents = new LinkedHashMap<>(); + + // Prefixes used to prevent key collisions in the deferred events map, + // as different blocks (e.g., Text and Reasoning) may share the same message ID. + public static final String PREFIX_TEXT = "text:"; + public static final String PREFIX_REASONING = "reasoning:"; + public static final String PREFIX_TOOL = "tool:"; + + private final Set activeTextIds = new LinkedHashSet<>(); + private final Set activeReasoningIds = new LinkedHashSet<>(); + private final Set activeToolIds = new LinkedHashSet<>(); + + private final Set finishedTextIds = new HashSet<>(); + private final Set finishedReasoningIds = new HashSet<>(); + + /** + * Initializes a new StreamContext. + * + * @param threadId The thread ID + * @param runId The run ID + * @param config The adapter configuration + */ + public StreamContext(String threadId, String runId, AguiAdapterConfig config) { + this.threadId = threadId; + this.runId = runId; + this.config = config; + } + + /** + * Returns the thread identifier associated with this stream context. + * + * @return the thread ID + */ + public String getThreadId() { + return threadId; + } + + /** + * Returns the run identifier associated with this stream context. + * + * @return the run ID + */ + public String getRunId() { + return runId; + } + + /** + * Returns the adapter configuration used by this stream context. + * + * @return the adapter configuration + */ + public AguiAdapterConfig getConfig() { + return config; + } + + // --- Event Emission and Deferred Management API --- + + /** + * Adds an event to the emission queue for the current processing cycle. + * + * @param event The AG-UI event to emit + */ + public void emit(AguiEvent event) { + emittedEvents.add(event); + } + + /** + * Registers an end event to be emitted later. + * This decouples the start logic from the termination logic. + * + * @param id The prefixed identifier for the component + * @param endEvent The end event to defer + */ + public void deferEndEvent(String id, AguiEvent endEvent) { + deferredEndEvents.put(id, endEvent); + } + + /** + * Retrieves and clears all events accumulated in the current cycle. + * + * @return A list of events to be dispatched downstream + */ + public List getAndClearEmittedEvents() { + List result = new ArrayList<>(emittedEvents); + emittedEvents.clear(); + return result; + } + + /** + * Flushes a specific deferred end event into the emission queue. + * + * @param id The prefixed identifier of the event to flush + */ + public void flushEndEvent(String id) { + AguiEvent endEvent = deferredEndEvents.remove(id); + if (endEvent != null) { + emit(endEvent); + } + } + + /** + * Flushes all remaining deferred end events. + * Commonly used when the stream completes or fails to ensure all pending + * component end events are emitted before the run is finished. + * + * @return A list of all remaining deferred end events + */ + public List flushAllRemainingDeferred() { + List remaining = new ArrayList<>(deferredEndEvents.values()); + deferredEndEvents.clear(); + return remaining; + } + + /** + * Flushes all active text end events. + * Commonly used when an interruption occurs (e.g., a tool call starts). + */ + public void flushAllActiveTexts() { + for (String id : new ArrayList<>(activeTextIds)) { + flushEndEvent(PREFIX_TEXT + id); + removeActiveText(id); + } + } + + /** + * Flushes all active reasoning end events. + * Commonly used when an interruption occurs (e.g., a tool call starts). + */ + public void flushAllActiveReasonings() { + for (String id : new ArrayList<>(activeReasoningIds)) { + flushEndEvent(PREFIX_REASONING + id); + removeActiveReasoning(id); + } + } + + // --- Text State Management --- + + public boolean isTextActive(String id) { + return activeTextIds.contains(id); + } + + public void addActiveText(String id) { + activeTextIds.add(id); + } + + public void removeActiveText(String id) { + activeTextIds.remove(id); + finishedTextIds.add(id); + } + + public boolean isTextFinished(String id) { + return finishedTextIds.contains(id); + } + + // --- Reasoning State Management --- + + public boolean isReasoningActive(String id) { + return activeReasoningIds.contains(id); + } + + public void addActiveReasoning(String id) { + activeReasoningIds.add(id); + } + + public void removeActiveReasoning(String id) { + activeReasoningIds.remove(id); + finishedReasoningIds.add(id); + } + + public boolean isReasoningFinished(String id) { + return finishedReasoningIds.contains(id); + } + + // --- Tool State Management --- + + public boolean isToolActive(String id) { + return activeToolIds.contains(id); + } + + public void addActiveTool(String id) { + activeToolIds.add(id); + } + + public void removeActiveTool(String id) { + activeToolIds.remove(id); + } +} diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/BlockEventConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/BlockEventConverter.java new file mode 100644 index 0000000000..9cba8c50a5 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/BlockEventConverter.java @@ -0,0 +1,52 @@ +/* + * 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.agui.adapter.strategy; + +import io.agentscope.core.agent.Event; +import io.agentscope.core.agui.adapter.StreamContext; +import io.agentscope.core.message.ContentBlock; + +/** + * Strategy interface for converting different types of ContentBlock into AG-UI events. + * + * @param The specific type of ContentBlock this converter handles. + */ +public interface BlockEventConverter { + + /** + * Retrieves the specific type of ContentBlock that this converter can process. + * + * @return The class type of the ContentBlock handled by this converter. + */ + Class supportedBlockType(); + + /** + * Determines whether the current Event is applicable for this converter. + * + * @param event The agent stream event to evaluate. + * @return true if the event can be processed by this converter, false otherwise. + */ + boolean isApplicable(Event event); + + /** + * Executes the conversion logic, generating and appending AG-UI events to the stream context. + * + * @param block The content block to convert. + * @param event The original agent event. + * @param context The stream context holding state and emitted events. + */ + void convert(T block, Event event, StreamContext context); +} diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockConverter.java new file mode 100644 index 0000000000..f1efad4635 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockConverter.java @@ -0,0 +1,78 @@ +/* + * 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.agui.adapter.strategy; + +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agui.adapter.StreamContext; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.message.TextBlock; + +/** + * Converter for handling TextBlock events, transforming them into AG-UI TextMessage events. + */ +public class TextBlockConverter implements BlockEventConverter { + + @Override + public Class supportedBlockType() { + return TextBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return event.getType() == EventType.REASONING || event.getType() == EventType.SUMMARY; + } + + @Override + public void convert(TextBlock block, Event event, StreamContext ctx) { + String msgId = event.getMessage().getId(); + // The tool call event will terminate all open text events (assuming text event msgId=1) + // After a round of reasoning (including reasonings, texts, and tool calls event), + // the event.last=true packet will carry the complete accumulation of the text event with + // msgId=1, which needs to be blocked + if (ctx.isTextFinished(msgId) && event.isLast()) { + return; + } + + String text = block.getText(); + if (text != null && !text.isEmpty()) { + if (!ctx.isTextActive(msgId)) { + ctx.flushAllActiveTexts(); + // When the text start, it signifies that the reasoning should end + ctx.flushAllActiveReasonings(); + + ctx.emit( + new AguiEvent.TextMessageStart( + ctx.getThreadId(), ctx.getRunId(), msgId, "assistant")); + ctx.deferEndEvent( + StreamContext.PREFIX_TEXT + msgId, + new AguiEvent.TextMessageEnd(ctx.getThreadId(), ctx.getRunId(), msgId)); + ctx.addActiveText(msgId); + } + + if (!event.isLast()) { + ctx.emit( + new AguiEvent.TextMessageContent( + ctx.getThreadId(), ctx.getRunId(), msgId, text)); + } + } + + if (event.isLast() && ctx.isTextActive(msgId)) { + ctx.flushEndEvent(StreamContext.PREFIX_TEXT + msgId); + ctx.removeActiveText(msgId); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ThinkingBlockConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ThinkingBlockConverter.java new file mode 100644 index 0000000000..cbabb052d8 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ThinkingBlockConverter.java @@ -0,0 +1,83 @@ +/* + * 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.agui.adapter.strategy; + +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agui.adapter.StreamContext; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.message.ThinkingBlock; + +/** + * Converter for handling ThinkingBlock events, transforming them into AG-UI ReasoningMessage events. + */ +public class ThinkingBlockConverter implements BlockEventConverter { + + @Override + public Class supportedBlockType() { + return ThinkingBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return event.getType() == EventType.REASONING || event.getType() == EventType.SUMMARY; + } + + @Override + public void convert(ThinkingBlock block, Event event, StreamContext ctx) { + // ignore if reasoning output is disabled + if (!ctx.getConfig().isEnableReasoning()) { + return; + } + + String msgId = event.getMessage().getId(); + // The tool call event will terminate all open reasoning events (assuming reasoning event + // msgId=2). After a round of reasoning (including reasonings, texts, and tool calls event), + // the event.last=true packet will carry the complete accumulation of the reasoning event + // with msgId=2, which needs to be blocked + if (ctx.isReasoningFinished(msgId) && event.isLast()) { + return; + } + + String thinking = block.getThinking(); + if (thinking != null && !thinking.isEmpty()) { + if (!ctx.isReasoningActive(msgId)) { + ctx.flushAllActiveReasonings(); + ctx.flushAllActiveTexts(); + + ctx.emit( + new AguiEvent.ReasoningMessageStart( + ctx.getThreadId(), ctx.getRunId(), msgId, "reasoning")); + ctx.deferEndEvent( + StreamContext.PREFIX_REASONING + msgId, + new AguiEvent.ReasoningMessageEnd( + ctx.getThreadId(), ctx.getRunId(), msgId)); + ctx.addActiveReasoning(msgId); + } + + if (!event.isLast()) { + ctx.emit( + new AguiEvent.ReasoningMessageContent( + ctx.getThreadId(), ctx.getRunId(), msgId, thinking)); + } + } + + if (event.isLast() && ctx.isReasoningActive(msgId)) { + ctx.flushEndEvent(StreamContext.PREFIX_REASONING + msgId); + ctx.removeActiveReasoning(msgId); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultBlockConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultBlockConverter.java new file mode 100644 index 0000000000..d46605ffb0 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultBlockConverter.java @@ -0,0 +1,104 @@ +/* + * 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.agui.adapter.strategy; + +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agui.adapter.StreamContext; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import java.util.UUID; + +/** + * Converter for handling ToolResultBlock events, transforming them into AG-UI ToolCallResult events. + */ +public class ToolResultBlockConverter implements BlockEventConverter { + + @Override + public Class supportedBlockType() { + return ToolResultBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return event.getType() == EventType.TOOL_RESULT && event.isLast(); + } + + @Override + public void convert(ToolResultBlock block, Event event, StreamContext ctx) { + String toolCallId = + block.getId() != null && !block.getId().isBlank() + ? block.getId() + : UUID.randomUUID().toString(); + + String result = extractToolResultText(block); + + // Closing Start/End Phase + if (ctx.isToolActive(toolCallId)) { + ctx.flushEndEvent(StreamContext.PREFIX_TOOL + toolCallId); + } else { + // Fall-back: The previous process did not proceed to Start for some reason + // (e.g., recovery directly from the context) + String toolName = + block.getName() != null && !block.getName().isBlank() + ? block.getName() + : "unknown"; + ctx.emit( + new AguiEvent.ToolCallStart( + ctx.getThreadId(), ctx.getRunId(), toolCallId, toolName)); + ctx.emit(new AguiEvent.ToolCallEnd(ctx.getThreadId(), ctx.getRunId(), toolCallId)); + } + + ctx.emit( + new AguiEvent.ToolCallResult( + ctx.getThreadId(), + ctx.getRunId(), + toolCallId, + result, + "tool", + event.getMessage().getId())); + + if (ctx.isToolActive(toolCallId)) { + ctx.removeActiveTool(toolCallId); + } + } + + /** + * Extract text content from a tool result block. + * + * @param toolResult The tool result block + * @return The text content, or null if not present + */ + protected String extractToolResultText(ToolResultBlock toolResult) { + if (toolResult.getOutput() == null || toolResult.getOutput().isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (ContentBlock output : toolResult.getOutput()) { + if (output instanceof TextBlock textBlock) { + if (!sb.isEmpty()) { + sb.append("\n"); + } + sb.append(textBlock.getText()); + } + } + + return !sb.isEmpty() ? sb.toString() : null; + } +} diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolUseBlockConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolUseBlockConverter.java new file mode 100644 index 0000000000..9da9098e97 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolUseBlockConverter.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.agui.adapter.strategy; + +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agui.adapter.StreamContext; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.message.ToolUseBlock; +import java.util.UUID; + +/** + * Converter for handling ToolUseBlock events, transforming them into AG-UI ToolCallStart and ToolCallArgs events. + */ +public class ToolUseBlockConverter implements BlockEventConverter { + + @Override + public Class supportedBlockType() { + return ToolUseBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return event.getType() == EventType.REASONING || event.getType() == EventType.SUMMARY; + } + + @Override + public void convert(ToolUseBlock block, Event event, StreamContext ctx) { + String toolCallId = + block.getId() != null && !block.getId().isBlank() + ? block.getId() + : UUID.randomUUID().toString(); + + if (!ctx.isToolActive(toolCallId)) { + // End any active Text/Reasoning message before starting tool call + ctx.flushAllActiveTexts(); + ctx.flushAllActiveReasonings(); + + String toolName = + block.getName() != null && !block.getName().isBlank() + ? block.getName() + : "unknown"; + + ctx.emit( + new AguiEvent.ToolCallStart( + ctx.getThreadId(), ctx.getRunId(), toolCallId, toolName)); + ctx.deferEndEvent( + StreamContext.PREFIX_TOOL + toolCallId, + new AguiEvent.ToolCallEnd(ctx.getThreadId(), ctx.getRunId(), toolCallId)); + ctx.addActiveTool(toolCallId); + } + + // Emit tool call args if enabled + if (ctx.getConfig().isEmitToolCallArgs() && !event.isLast()) { + String args = block.getContent(); + if (args != null && !args.isEmpty()) { + ctx.emit( + new AguiEvent.ToolCallArgs( + ctx.getThreadId(), ctx.getRunId(), toolCallId, args)); + } + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java index f33eb70ffc..467636852d 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java @@ -19,10 +19,16 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.model.ToolMergeMode; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; import java.time.Duration; +import java.util.Map; import org.junit.jupiter.api.Test; /** @@ -39,6 +45,7 @@ void testDefaultConfig() { assertTrue(config.isEmitStateEvents()); assertTrue(config.isEmitToolCallArgs()); assertFalse(config.isEnableReasoning()); // Default should be false + assertTrue(config.isEnableActingChunk()); assertEquals(Duration.ofMinutes(10), config.getRunTimeout()); assertNull(config.getDefaultAgentId()); } @@ -51,7 +58,10 @@ void testBuilderWithDefaults() { assertEquals(ToolMergeMode.MERGE_FRONTEND_PRIORITY, config.getToolMergeMode()); assertTrue(config.isEmitStateEvents()); assertTrue(config.isEmitToolCallArgs()); + assertFalse(config.isEnableReasoning()); + assertTrue(config.isEnableActingChunk()); assertEquals(Duration.ofMinutes(10), config.getRunTimeout()); + assertNull(config.getDefaultAgentId()); } @Test @@ -95,6 +105,31 @@ void testBuilderEmitToolCallArgs() { assertTrue(configEnabled.isEmitToolCallArgs()); } + @Test + void testBuilderEnableReasoning() { + AguiAdapterConfig configDisabled = + AguiAdapterConfig.builder().enableReasoning(false).build(); + + assertFalse(configDisabled.isEnableReasoning()); + + AguiAdapterConfig configEnabled = AguiAdapterConfig.builder().enableReasoning(true).build(); + + assertTrue(configEnabled.isEnableReasoning()); + } + + @Test + void testBuilderEnableActingChunk() { + AguiAdapterConfig configDisabled = + AguiAdapterConfig.builder().enableActingChunk(false).build(); + + assertFalse(configDisabled.isEnableActingChunk()); + + AguiAdapterConfig configEnabled = + AguiAdapterConfig.builder().enableActingChunk(true).build(); + + assertTrue(configEnabled.isEnableActingChunk()); + } + @Test void testBuilderRunTimeout() { Duration customTimeout = Duration.ofMinutes(30); @@ -125,6 +160,91 @@ void testBuilderNullDefaultAgentId() { assertNull(config.getDefaultAgentId()); } + @Test + void testDefaultCustomConvertersIsEmpty() { + AguiAdapterConfig config = AguiAdapterConfig.defaultConfig(); + + assertNotNull(config.getCustomConverters()); + assertTrue(config.getCustomConverters().isEmpty()); + } + + @Test + void testBuilderRegisterCustomConverters() { + BlockEventConverter textConverter = + new BlockEventConverter<>() { + @Override + public Class supportedBlockType() { + return TextBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return true; + } + + @Override + public void convert(TextBlock block, Event event, StreamContext context) {} + }; + + BlockEventConverter toolConverter = + new BlockEventConverter<>() { + @Override + public Class supportedBlockType() { + return ToolUseBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return true; + } + + @Override + public void convert(ToolUseBlock block, Event event, StreamContext context) {} + }; + + AguiAdapterConfig config = + AguiAdapterConfig.builder() + .registerConverter(textConverter) + .registerConverter(toolConverter) + .build(); + + Map, BlockEventConverter> customConverters = config.getCustomConverters(); + + assertEquals(2, customConverters.size()); + assertTrue(customConverters.containsKey(TextBlock.class)); + assertTrue(customConverters.containsKey(ToolUseBlock.class)); + } + + @Test + void testCustomConvertersImmutability() { + BlockEventConverter dummyConverter = + new BlockEventConverter<>() { + @Override + public Class supportedBlockType() { + return TextBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return true; + } + + @Override + public void convert(TextBlock block, Event event, StreamContext context) {} + }; + + AguiAdapterConfig config = + AguiAdapterConfig.builder().registerConverter(dummyConverter).build(); + + Map, BlockEventConverter> customConverters = config.getCustomConverters(); + + assertThrows( + UnsupportedOperationException.class, + () -> { + customConverters.remove(TextBlock.class); + }); + } + @Test void testBuilderFullConfiguration() { AguiAdapterConfig config = @@ -133,6 +253,7 @@ void testBuilderFullConfiguration() { .emitStateEvents(false) .emitToolCallArgs(false) .enableReasoning(true) + .enableActingChunk(false) .runTimeout(Duration.ofHours(1)) .defaultAgentId("my-agent") .build(); @@ -141,6 +262,7 @@ void testBuilderFullConfiguration() { assertFalse(config.isEmitStateEvents()); assertFalse(config.isEmitToolCallArgs()); assertTrue(config.isEnableReasoning()); + assertFalse(config.isEnableActingChunk()); assertEquals(Duration.ofHours(1), config.getRunTimeout()); assertEquals("my-agent", config.getDefaultAgentId()); } diff --git a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java index e1ac649a69..9b897fbaaa 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.agui.adapter; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -28,6 +29,7 @@ import io.agentscope.core.agent.Event; import io.agentscope.core.agent.EventType; import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.RunAgentInput; @@ -654,9 +656,10 @@ void testToolUseBlockWithNullId() { } @Test - void testTextMessageEndNotDuplicatedWhenLastEventAfterToolCall() { - // Test that when a text message is interrupted by a tool call and then the last event - // contains text blocks with the same message ID, only one TextMessageEnd is emitted + void testTextMessageIgnoredAfterToolCallInterruption() { + // Test that when a text message is interrupted by a tool call, + // the active text message is closed immediately and marked as finished. + // Subsequent text blocks with the same message ID should be ignored. String msgId = "msg-text"; Msg firstMsg = Msg.builder() @@ -686,6 +689,7 @@ void testTextMessageEndNotDuplicatedWhenLastEventAfterToolCall() { Event firstEvent = new Event(EventType.REASONING, firstMsg, false); Event toolCallEvent = new Event(EventType.REASONING, toolCall1, false); Event lastEvent = new Event(EventType.REASONING, lastMsg, true); + when(mockAgent.stream(anyList(), any(StreamOptions.class))) .thenReturn(Flux.just(firstEvent, toolCallEvent, lastEvent)); @@ -700,7 +704,21 @@ void testTextMessageEndNotDuplicatedWhenLastEventAfterToolCall() { assertNotNull(events); - // Should have exactly one TextMessageEnd for the same message ID + long textStartCount = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .filter( + e -> { + AguiEvent.TextMessageStart start = + (AguiEvent.TextMessageStart) e; + return msgId.equals(start.messageId()); + }) + .count(); + assertEquals( + 1, + textStartCount, + "Should emit exactly 1 TextMessageStart. The subsequent block should be ignored"); + long textEndCount = events.stream() .filter(e -> e instanceof AguiEvent.TextMessageEnd) @@ -710,7 +728,11 @@ void testTextMessageEndNotDuplicatedWhenLastEventAfterToolCall() { return msgId.equals(end.messageId()); }) .count(); - assertEquals(1, textEndCount, "Should have exactly 1 TextMessageEnd per message ID"); + assertEquals( + 1, + textEndCount, + "Should emit exactly 1 TextMessageEnd: the one triggered by the tool" + + " interruption."); } @Test @@ -1696,4 +1718,202 @@ void testRunWithNullThinkingBlock() { !hasReasoningMessageStart, "Should NOT have ReasoningMessageStart for null thinking"); } + + @Test + void testAggregateLastEventBlockedWhenFinished() { + // Test that when a text or reasoning event is interrupted end(e.g., interrupted by a tool + // call), + // the final event (isLast=true) containing the aggregated content is strictly blocked and + // ignored. + // This prevents the UI from repeating the entire accumulated message at the end of a + // stream. + + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + + String targetMsgId = "msg-target"; + + // 1. Initial chunk (isLast = false) + Msg firstMsg = + Msg.builder() + .id(targetMsgId) + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking("Initial reasoning.") + .build(), + TextBlock.builder().text("Initial text.").build())) + .build(); + Event firstEvent = new Event(EventType.REASONING, firstMsg, false); + + // 2. Tool call chunk (forces closure, making targetMsgId "finished") + Msg toolMsg = + Msg.builder() + .id("msg-tc") + .role(MsgRole.ASSISTANT) + .content( + ToolUseBlock.builder() + .id("tc-1") + .name("get_weather") + .input(Map.of()) + .build()) + .build(); + Event toolEvent = new Event(EventType.REASONING, toolMsg, false); + + // 3. Final aggregate chunk (isLast = true) - This MUST be blocked! + Msg lastMsg = + Msg.builder() + .id(targetMsgId) + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking( + "Initial reasoning. Plus all aggregate" + + " chunk.") + .build(), + TextBlock.builder() + .text("Initial text. Plus all aggregate chunk.") + .build())) + .build(); + Event lastEvent = new Event(EventType.REASONING, lastMsg, true); + + // Mock the stream to return the sequence + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(firstEvent, toolEvent, lastEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages( + List.of( + AguiMessage.userMessage( + "msg-1", "Test aggregate blocking"))) + .build(); + + List events = adapterWithReasoning.run(input).collectList().block(); + + assertNotNull(events); + + // Verify Start events (should only be 1 of each, not restarted by the last aggregate event) + long textStartCount = + events.stream().filter(e -> e instanceof AguiEvent.TextMessageStart).count(); + long reasoningStartCount = + events.stream().filter(e -> e instanceof AguiEvent.ReasoningMessageStart).count(); + + assertEquals(1, textStartCount, "Should emit exactly 1 TextMessageStart"); + assertEquals(1, reasoningStartCount, "Should emit exactly 1 ReasoningMessageStart"); + + // Verify End events (should only be 1 of each, triggered strictly by the tool interruption) + long textEndCount = + events.stream().filter(e -> e instanceof AguiEvent.TextMessageEnd).count(); + long reasoningEndCount = + events.stream().filter(e -> e instanceof AguiEvent.ReasoningMessageEnd).count(); + + assertEquals( + 1, textEndCount, "Should emit exactly 1 TextMessageEnd due to tool interruption"); + assertEquals( + 1, + reasoningEndCount, + "Should emit exactly 1 ReasoningMessageEnd due to tool interruption"); + + // Verify that the aggregate content was strictly blocked and never emitted + boolean hasAggregateText = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .map(e -> (AguiEvent.TextMessageContent) e) + .anyMatch(e -> e.delta().contains("aggregate chunk")); + + assertTrue(!hasAggregateText, "The aggregated text block MUST be ignored and not emitted"); + + boolean hasAggregateReasoning = + events.stream() + .filter(e -> e instanceof AguiEvent.ReasoningMessageContent) + .map(e -> (AguiEvent.ReasoningMessageContent) e) + .anyMatch(e -> e.delta().contains("aggregate chunk")); + + assertTrue( + !hasAggregateReasoning, + "The aggregated reasoning block MUST be ignored and not emitted"); + } + + @Test + void testCustomConverterOverridesDefaultStrategy() { + // Create a custom strategy that overrides the default ToolResultBlock behavior + BlockEventConverter customConverter = + new BlockEventConverter<>() { + @Override + public Class supportedBlockType() { + return ToolResultBlock.class; + } + + @Override + public boolean isApplicable(Event event) { + return event.getType() == EventType.TOOL_RESULT; + } + + @Override + public void convert(ToolResultBlock block, Event event, StreamContext ctx) { + // Instead of normal ToolCallResult, emit a special CUSTOM event + ctx.emit( + new AguiEvent.Custom( + ctx.getThreadId(), + ctx.getRunId(), + "overridden_tool_result", + "Intercepted: " + block.getId())); + } + }; + + // Inject the custom strategy via config + AguiAdapterConfig customConfig = + AguiAdapterConfig.builder().registerConverter(customConverter).build(); + AguiAgentAdapter customAdapter = new AguiAgentAdapter(mockAgent, customConfig); + + // Simulate a ToolResult event + Msg toolResultMsg = + Msg.builder() + .id("msg-tr1") + .role(MsgRole.TOOL) + .content( + ToolResultBlock.builder() + .id("tc-custom-1") + .output(TextBlock.builder().text("4").build()) + .build()) + .build(); + + Event toolResultEvent = new Event(EventType.TOOL_RESULT, toolResultMsg, true); + + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(toolResultEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Calculate"))) + .build(); + + List events = customAdapter.run(input).collectList().block(); + assertNotNull(events); + + // Verify that the CUSTOM event emitted by our overridden strategy exists + boolean hasCustomEvent = + events.stream() + .filter(e -> e instanceof AguiEvent.Custom) + .map(e -> (AguiEvent.Custom) e) + .anyMatch( + e -> + "overridden_tool_result".equals(e.name()) + && "Intercepted: tc-custom-1".equals(e.value())); + assertTrue(hasCustomEvent, "Should emit the Custom event from the overridden strategy"); + + // Verify that the default ToolCallResult event was NOT emitted (perfect override) + boolean hasDefaultToolResult = + events.stream().anyMatch(e -> e instanceof AguiEvent.ToolCallResult); + assertFalse( + hasDefaultToolResult, + "Should NOT emit the default ToolCallResult because the strategy was overridden"); + } } diff --git a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/StreamContextTest.java b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/StreamContextTest.java new file mode 100644 index 0000000000..d33486314c --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/StreamContextTest.java @@ -0,0 +1,227 @@ +/* + * 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.agui.adapter; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import io.agentscope.core.agui.event.AguiEvent; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for StreamContext. + */ +class StreamContextTest { + + private static final String THREAD_ID = "thread-123"; + private static final String RUN_ID = "run-456"; + + private AguiAdapterConfig mockConfig; + private StreamContext context; + + @BeforeEach + void setUp() { + mockConfig = mock(AguiAdapterConfig.class); + context = new StreamContext(THREAD_ID, RUN_ID, mockConfig); + } + + @Nested + @DisplayName("1. Initialization & Basic Getters") + class InitializationTests { + @Test + @DisplayName("Should initialize properly and return correct context variables") + void testInitialization() { + assertEquals(THREAD_ID, context.getThreadId()); + assertEquals(RUN_ID, context.getRunId()); + assertEquals(mockConfig, context.getConfig()); + assertTrue(context.getAndClearEmittedEvents().isEmpty()); + } + } + + @Nested + @DisplayName("2. Event Emission & Deferred Queue Logic") + class EmissionAndDeferredTests { + @Test + @DisplayName("Should emit events and clear the buffer correctly") + void testEmitAndClear() { + AguiEvent event1 = + new AguiEvent.TextMessageStart(THREAD_ID, RUN_ID, "msg-1", "assistant"); + AguiEvent event2 = + new AguiEvent.TextMessageContent(THREAD_ID, RUN_ID, "msg-1", "hello"); + + context.emit(event1); + context.emit(event2); + + List emitted = context.getAndClearEmittedEvents(); + assertEquals(2, emitted.size()); + assertSame(event1, emitted.get(0)); + assertSame(event2, emitted.get(1)); + + // Verify buffer is cleared + assertTrue(context.getAndClearEmittedEvents().isEmpty()); + } + + @Test + @DisplayName("Should defer an end event and flush it to the emission queue") + void testDeferAndFlushSpecificEvent() { + AguiEvent endEvent = new AguiEvent.TextMessageEnd(THREAD_ID, RUN_ID, "msg-1"); + String id = StreamContext.PREFIX_TEXT + "msg-1"; + + context.deferEndEvent(id, endEvent); + context.flushEndEvent(id); + + List emitted = context.getAndClearEmittedEvents(); + assertEquals(1, emitted.size()); + assertSame(endEvent, emitted.get(0)); + + // Verify it was removed from deferred queue + assertTrue(context.flushAllRemainingDeferred().isEmpty()); + } + + @Test + @DisplayName("Edge Case: Flushing a non-existent deferred event should be safe") + void testFlushNonExistentDeferredEvent() { + assertDoesNotThrow(() -> context.flushEndEvent("non-existent-id")); + assertTrue(context.getAndClearEmittedEvents().isEmpty()); + } + + @Test + @DisplayName("Should flush all remaining deferred events safely") + void testFlushAllRemainingDeferred() { + AguiEvent event1 = new AguiEvent.TextMessageEnd(THREAD_ID, RUN_ID, "msg-1"); + AguiEvent event2 = new AguiEvent.TextMessageEnd(THREAD_ID, RUN_ID, "msg-2"); + + context.deferEndEvent("id1", event1); + context.deferEndEvent("id2", event2); + + List remaining = context.flushAllRemainingDeferred(); + assertEquals(2, remaining.size()); + assertTrue(remaining.contains(event1)); + assertTrue(remaining.contains(event2)); + + // Verify the deferred map is now empty + assertTrue(context.flushAllRemainingDeferred().isEmpty()); + } + } + + @Nested + @DisplayName("3. State Machine Tracking (Text, Reasoning, Tool)") + class StateMachineTests { + + @Test + @DisplayName("Text lifecycle: add, remove, and track finished state") + void testTextLifecycle() { + String msgId = "text-msg-1"; + + assertFalse(context.isTextActive(msgId)); + + context.addActiveText(msgId); + assertTrue(context.isTextActive(msgId)); + assertFalse(context.isTextFinished(msgId)); + + context.removeActiveText(msgId); + assertFalse(context.isTextActive(msgId)); + assertTrue(context.isTextFinished(msgId)); + } + + @Test + @DisplayName( + "Edge Case: Double-remove of an active ID should not throw and keep it finished") + void testDoubleRemoveActiveId() { + String msgId = "reasoning-msg-1"; + context.addActiveReasoning(msgId); + + assertDoesNotThrow( + () -> { + context.removeActiveReasoning(msgId); + context.removeActiveReasoning(msgId); // Double remove + }); + + assertFalse(context.isReasoningActive(msgId)); + assertTrue(context.isReasoningFinished(msgId)); + } + + @Test + @DisplayName("Tool lifecycle: tools only have active state, no finished state tracked") + void testToolLifecycle() { + String callId = "call-123"; + + context.addActiveTool(callId); + assertTrue(context.isToolActive(callId)); + + context.removeActiveTool(callId); + assertFalse(context.isToolActive(callId)); + } + } + + @Nested + @DisplayName("4. Interleaved Flush Semantics (Integration logic)") + class InterleavedFlushTests { + + @Test + @DisplayName("flushAllActiveTexts should flush queued events and update state to finished") + void testFlushAllActiveTexts() { + String msgId1 = "msg-1"; + String msgId2 = "msg-2"; + + AguiEvent endEvent1 = new AguiEvent.TextMessageEnd(THREAD_ID, RUN_ID, msgId1); + AguiEvent endEvent2 = new AguiEvent.TextMessageEnd(THREAD_ID, RUN_ID, msgId2); + + // Setup state and deferred events + context.addActiveText(msgId1); + context.addActiveText(msgId2); + context.deferEndEvent(StreamContext.PREFIX_TEXT + msgId1, endEvent1); + context.deferEndEvent(StreamContext.PREFIX_TEXT + msgId2, endEvent2); + + // Execute + context.flushAllActiveTexts(); + + // Assert state changes + assertFalse(context.isTextActive(msgId1)); + assertFalse(context.isTextActive(msgId2)); + assertTrue(context.isTextFinished(msgId1)); + assertTrue(context.isTextFinished(msgId2)); + + // Assert events were pushed to the emission queue + List emitted = context.getAndClearEmittedEvents(); + assertEquals(2, emitted.size()); + assertTrue(emitted.contains(endEvent1)); + assertTrue(emitted.contains(endEvent2)); + } + + @Test + @DisplayName( + "flushAllActiveReasonings should behave correctly when no active reasonings exist") + void testFlushEmptyReasonings() { + // Setup active text but NO active reasoning + context.addActiveText("msg-1"); + + assertDoesNotThrow(() -> context.flushAllActiveReasonings()); + + // Text should remain unaffected + assertTrue(context.isTextActive("msg-1")); + assertTrue(context.getAndClearEmittedEvents().isEmpty()); + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java index a2edda5af0..3ddc31a044 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java @@ -38,6 +38,7 @@ * agent-id-header: X-Agent-Id * enable-path-routing: true * enable-reasoning: false + * enable-acting-chunk: true * */ @ConfigurationProperties(prefix = "agentscope.agui") @@ -73,6 +74,14 @@ public class AguiProperties { */ private boolean enableReasoning = false; + /** + * Whether to enable acting chunk emissions. + * + *

When enabled, tools can emit intermediate chunks (e.g., Custom events for progress + * or real-time logs) during execution. + */ + private boolean enableActingChunk = true; + /** Default agent ID to use when not specified in the request. */ private String defaultAgentId = "default"; @@ -176,6 +185,14 @@ public void setEnableReasoning(boolean enableReasoning) { this.enableReasoning = enableReasoning; } + public boolean isEnableActingChunk() { + return enableActingChunk; + } + + public void setEnableActingChunk(boolean enableActingChunk) { + this.enableActingChunk = enableActingChunk; + } + public String getDefaultAgentId() { return defaultAgentId; } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java index e74d5a91b0..460171db14 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java @@ -17,9 +17,11 @@ import io.agentscope.core.agent.Agent; import io.agentscope.core.agui.adapter.AguiAdapterConfig; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.registry.AguiAgentRegistry; import io.agentscope.spring.boot.agui.common.AguiProperties; import io.agentscope.spring.boot.agui.common.ThreadSessionManager; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -68,21 +70,36 @@ public ThreadSessionManager threadSessionManager(AguiProperties props) { * @param registry The agent registry * @param sessionManager The thread session manager * @param props The configuration properties + * @param customConvertersProvider Provider for any custom block converters defined in the Spring context * @return A new AguiMvcController */ @Bean @ConditionalOnMissingBean public AguiMvcController aguiMvcController( - AguiAgentRegistry registry, ThreadSessionManager sessionManager, AguiProperties props) { - AguiAdapterConfig config = + AguiAgentRegistry registry, + ThreadSessionManager sessionManager, + AguiProperties props, + ObjectProvider> customConvertersProvider) { + AguiAdapterConfig.Builder configBuilder = AguiAdapterConfig.builder() .toolMergeMode(props.getDefaultToolMergeMode()) .runTimeout(props.getRunTimeout()) .emitStateEvents(props.isEmitStateEvents()) .emitToolCallArgs(props.isEmitToolCallArgs()) .enableReasoning(props.isEnableReasoning()) - .defaultAgentId(props.getDefaultAgentId()) - .build(); + .enableActingChunk(props.isEnableActingChunk()) + .defaultAgentId(props.getDefaultAgentId()); + + customConvertersProvider + .orderedStream() + .forEach( + converter -> { + @SuppressWarnings({"unchecked", "rawtypes"}) + BlockEventConverter rawConverter = converter; + configBuilder.registerConverter(rawConverter); + }); + + AguiAdapterConfig config = configBuilder.build(); return AguiMvcController.builder() .agentRegistry(registry) diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java index 23513f2b42..82bf276a12 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java @@ -17,9 +17,11 @@ import io.agentscope.core.agent.Agent; import io.agentscope.core.agui.adapter.AguiAdapterConfig; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.registry.AguiAgentRegistry; import io.agentscope.spring.boot.agui.common.AguiProperties; import io.agentscope.spring.boot.agui.common.ThreadSessionManager; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -71,21 +73,36 @@ public ThreadSessionManager threadSessionManager(AguiProperties props) { * @param registry The agent registry * @param sessionManager The thread session manager * @param props The configuration properties + * @param customConvertersProvider Provider for any custom block converters defined in the Spring context * @return A new AguiWebFluxHandler */ @Bean @ConditionalOnMissingBean public AguiWebFluxHandler aguiWebFluxHandler( - AguiAgentRegistry registry, ThreadSessionManager sessionManager, AguiProperties props) { - AguiAdapterConfig config = + AguiAgentRegistry registry, + ThreadSessionManager sessionManager, + AguiProperties props, + ObjectProvider> customConvertersProvider) { + AguiAdapterConfig.Builder configBuilder = AguiAdapterConfig.builder() .toolMergeMode(props.getDefaultToolMergeMode()) .runTimeout(props.getRunTimeout()) .emitStateEvents(props.isEmitStateEvents()) .emitToolCallArgs(props.isEmitToolCallArgs()) .enableReasoning(props.isEnableReasoning()) - .defaultAgentId(props.getDefaultAgentId()) - .build(); + .enableActingChunk(props.isEnableActingChunk()) + .defaultAgentId(props.getDefaultAgentId()); + + customConvertersProvider + .orderedStream() + .forEach( + converter -> { + @SuppressWarnings({"unchecked", "rawtypes"}) + BlockEventConverter rawConverter = converter; + configBuilder.registerConverter(rawConverter); + }); + + AguiAdapterConfig config = configBuilder.build(); return AguiWebFluxHandler.builder() .agentRegistry(registry)