From c6a610bf06dfd3dcf8e167bffd4184d800495246 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Tue, 5 May 2026 22:39:26 +0800 Subject: [PATCH 01/20] refactor(agui): redesign event dispatching with strategy pattern and deferred queue --- .../agentscope/core/message/ContentBlock.java | 7 +- .../agentscope/core/message/CustomBlock.java | 116 +++++ .../core/agui/adapter/AguiAgentAdapter.java | 468 +++--------------- .../core/agui/adapter/StreamContext.java | 191 +++++++ .../adapter/strategy/BlockEventConverter.java | 45 ++ .../strategy/CustomBlockConverter.java | 43 ++ .../adapter/strategy/TextBlockConverter.java | 56 +++ .../strategy/ThinkingBlockConverter.java | 61 +++ .../strategy/ToolResultBlockConverter.java | 92 ++++ .../strategy/ToolUseBlockConverter.java | 61 +++ .../agui/adapter/AguiAgentAdapterTest.java | 30 +- 11 files changed, 769 insertions(+), 401 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/StreamContext.java create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/BlockEventConverter.java create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockConverter.java create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ThinkingBlockConverter.java create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultBlockConverter.java create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolUseBlockConverter.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java index 55afe0393c..d9b9ac10a7 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java @@ -35,6 +35,7 @@ *
  • {@link VideoBlock} - Video content (URL or Base64) *
  • {@link ToolUseBlock} - Tool execution requests *
  • {@link ToolResultBlock} - Tool execution results + *
  • {@link CustomBlock} - Custom extension block to trigger AG-UI Custom events * * *

    Uses Jackson annotations for polymorphic JSON serialization with the "type" discriminator @@ -49,7 +50,8 @@ @JsonSubTypes.Type(value = AudioBlock.class, name = "audio"), @JsonSubTypes.Type(value = VideoBlock.class, name = "video"), @JsonSubTypes.Type(value = ToolUseBlock.class, name = "tool_use"), - @JsonSubTypes.Type(value = ToolResultBlock.class, name = "tool_result") + @JsonSubTypes.Type(value = ToolResultBlock.class, name = "tool_result"), + @JsonSubTypes.Type(value = CustomBlock.class, name = "custom") }) public sealed class ContentBlock implements State permits TextBlock, @@ -58,4 +60,5 @@ public sealed class ContentBlock implements State VideoBlock, ThinkingBlock, ToolUseBlock, - ToolResultBlock {} + ToolResultBlock, + CustomBlock {} diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java new file mode 100644 index 0000000000..c0658e5d8d --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java @@ -0,0 +1,116 @@ +/* + * 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.message; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a custom extension block specifically designed to trigger AG-UI Custom events. + * + *

    This block allows for the transmission of arbitrary JSON-serializable payloads + * to the frontend. It is commonly used for real-time progress pushes, workflow + * state changes, and any other customized interactive behaviors within the AG-UI protocol. + * + *

    The name identifies the custom event type, and the value can be a Map, String, + * Number, or any JSON-serializable object containing the event payload. + */ +public final class CustomBlock extends ContentBlock { + + private final String name; + private final Object value; + + /** + * Creates a new custom block for JSON deserialization. + * + * @param name The name of the custom event + * @param value The arbitrary payload value associated with the event + */ + @JsonCreator + public CustomBlock( + @JsonProperty("name") String name, + @JsonProperty("value") Object value) { + this.name = name; + this.value = value; + } + + /** + * Gets the name of this custom block. + * + * @return The event name + */ + public String getName() { + return name; + } + + /** + * Gets the payload value of this custom block. + * + * @return The payload object + */ + public Object getValue() { + return value; + } + + /** + * Creates a new builder for constructing CustomBlock instances. + * + * @return A new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for constructing CustomBlock instances. + */ + public static class Builder { + + private String name; + private Object value; + + /** + * Sets the name for the custom block event. + * + * @param name The custom event name + * @return This builder for chaining + */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Sets the arbitrary payload value for the block. + * + * @param value payload object + * @return This builder for chaining + */ + public Builder value(Object value) { + this.value = value; + return this; + } + + /** + * Builds a new CustomBlock with the configured properties. + * + * @return A new CustomBlock instance + */ + public CustomBlock build() { + return new CustomBlock(name, value); + } + } +} \ No newline at end of file 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..f0e88b0339 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 @@ -20,24 +20,28 @@ import io.agentscope.core.agent.EventType; import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.agui.converter.AguiMessageConverter; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; +import io.agentscope.core.agui.adapter.strategy.CustomBlockConverter; +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.event.AguiEvent; import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.CustomBlock; 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 reactor.core.publisher.Flux; + import java.util.ArrayList; -import java.util.LinkedHashSet; +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; /** * Adapter that bridges AgentScope agents to the AG-UI protocol. @@ -48,15 +52,15 @@ *

    Event Mapping: *

    * *

    Reasoning Support: *

    @@ -66,17 +70,25 @@ public class AguiAgentAdapter { private final Agent agent; private final AguiAdapterConfig config; private final AguiMessageConverter messageConverter; + private final Map, BlockEventConverter> converters = new HashMap<>(); /** - * 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.agent = Objects.requireNonNull(agent); + this.config = Objects.requireNonNull(config); this.messageConverter = new AguiMessageConverter(); + + // Register block conversion strategies + converters.put(TextBlock.class, new TextBlockConverter()); + converters.put(ThinkingBlock.class, new ThinkingBlockConverter()); + converters.put(ToolUseBlock.class, new ToolUseBlockConverter()); + converters.put(ToolResultBlock.class, new ToolResultBlockConverter()); + converters.put(CustomBlock.class, new CustomBlockConverter()); } /** @@ -89,399 +101,73 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { * @return A Flux of AG-UI events */ public Flux run(RunAgentInput input) { - return Flux.defer( - () -> { - String threadId = input.getThreadId(); - String runId = input.getRunId(); - - // Convert AG-UI messages to AgentScope messages - List msgs = messageConverter.toMsgList(input.getMessages()); - - // Create stream options - use incremental mode for true streaming - StreamOptions options = - StreamOptions.builder() - .eventTypes(EventType.ALL) - .incremental(true) - .build(); - - // Track state for event conversion - EventConversionState state = new EventConversionState(threadId, runId); - - 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)); - }); - }); + String threadId = input.getThreadId(); + String runId = input.getRunId(); + List msgs = messageConverter.toMsgList(input.getMessages()); + // Create stream options - use incremental mode for true streaming + StreamOptions options = StreamOptions.builder().eventTypes(EventType.ALL).incremental(true).build(); + + return Flux.defer(() -> { + 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 -> processEvent(event, ctx)), + + // Emit any pending end events + Flux.defer(() -> Flux.fromIterable(ctx.flushAllRemainingDeferred())), + + // Emit RUN_FINISHED + Flux.just(new AguiEvent.RunFinished(threadId, runId)) + ).onErrorResume(error -> handleError(threadId, runId, ctx, error)); + }); } /** - * Convert an AgentScope event to AG-UI events. + * Dispatches the incoming event to the appropriate converter strategies based on block types. * - * @param event The AgentScope event - * @param state The conversion state - * @return List of AG-UI 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 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(); - - // 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); - } + @SuppressWarnings("unchecked") + private List processEvent(Event event, StreamContext ctx) { + // Dispatch each content block to its corresponding converter + for (ContentBlock block : event.getMessage().getContent()) { + BlockEventConverter converter = + (BlockEventConverter) converters.get(block.getClass()); + + if (converter != null && converter.isApplicable(event)) { + converter.convert(block, event, ctx); } } - return events; + return ctx.getAndClearEmittedEvents(); } /** - * Finish the run by emitting any pending end events and RUN_FINISHED. + * Handles errors that occur during the stream pipeline. + * Guarantees that all deferred end events are flushed before the error event is emitted. * - * @param state The conversion state - * @return Flux of final events + * @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 Flux finishRun(EventConversionState state) { + private Flux handleError(String threadId, String runId, StreamContext ctx, Throwable error) { List events = new ArrayList<>(); + events.addAll(ctx.flushAllRemainingDeferred()); - // 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)); - } - } - - // Emit RUN_FINISHED - events.add(new AguiEvent.RunFinished(state.threadId, state.runId)); + 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)); 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; - } - - 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; - } - - /** - * Serialize tool arguments to JSON string. - * - * @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. - */ - 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; - } - - boolean hasActiveReasoningMessage() { - return currentReasoningMessageId != null - && !hasEndedReasoningMessage(currentReasoningMessageId); - } - - Set getStartedReasoningMessages() { - return startedReasoningMessages; - } - } -} +} \ No newline at end of file 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..7974886c8a --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/StreamContext.java @@ -0,0 +1,191 @@ +/* + * 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.*; + +/** + * 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 { + // 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 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<>(); + + private final Set activeTextIds = new LinkedHashSet<>(); + private final Set activeReasoningIds = new LinkedHashSet<>(); + private final Set activeToolIds = new LinkedHashSet<>(); + + // Fallback ID for tool results that might lack an explicit ID + private String lastActiveToolId = null; + + /** + * 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; + } + + public String getThreadId() { return threadId; } + public String getRunId() { return runId; } + 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) { + this.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) { + this.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. + * Typically called during stream termination or error recovery to ensure all UI components are closed. + * + * @return A list of all remaining deferred events + */ + public List flushAllRemainingDeferred() { + List remaining = new ArrayList<>(deferredEndEvents.values()); + + deferredEndEvents.clear(); + activeTextIds.clear(); + activeReasoningIds.clear(); + activeToolIds.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); } + + // --- 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); } + + // --- Tool State Management --- + + public boolean isToolActive(String id) { + return activeToolIds.contains(id); + } + + public void addActiveTool(String id) { + this.activeToolIds.add(id); + this.lastActiveToolId = id; // Update the fallback ID + } + + public void removeActiveTool(String id) { + this.activeToolIds.remove(id); + // If the removed ID matches the last recorded fallback ID, reset or step back the pointer + if (Objects.equals(this.lastActiveToolId, id)) { + if (activeToolIds.isEmpty()) { + this.lastActiveToolId = null; + } else { + // Retrieve the last inserted element from the LinkedHashSet + String[] array = activeToolIds.toArray(new String[0]); + this.lastActiveToolId = array[array.length - 1]; + } + } + } + + public String getLastActiveToolId() { + return this.lastActiveToolId; + } +} 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..67ed91d1bd --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/BlockEventConverter.java @@ -0,0 +1,45 @@ +/* + * 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 { + + /** + * 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/CustomBlockConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java new file mode 100644 index 0000000000..803e5318f9 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java @@ -0,0 +1,43 @@ +/* + * 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.agui.event.AguiEvent; +import io.agentscope.core.message.CustomBlock; + +/** + * Converter for handling CustomBlock events, transforming them into AG-UI Custom events. + */ +public class CustomBlockConverter implements BlockEventConverter { + + @Override + public boolean isApplicable(Event event) { + // Custom events can occur at any stage, so always return true. + return true; + } + + @Override + public void convert(CustomBlock block, Event event, StreamContext ctx) { + ctx.emit(new AguiEvent.Custom( + ctx.getThreadId(), + ctx.getRunId(), + block.getName(), + block.getValue() + )); + } +} \ No newline at end of file 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..d6920efa2e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockConverter.java @@ -0,0 +1,56 @@ +/* + * 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 boolean isApplicable(Event event) { + return event.getType() == EventType.REASONING || event.getType() == EventType.SUMMARY; + } + + @Override + public void convert(TextBlock block, Event event, StreamContext ctx) { + String text = block.getText(); + String msgId = event.getMessage().getId(); + + if (text != null && !text.isEmpty()) { + if (!ctx.isTextActive(msgId)) { + ctx.flushAllActiveTexts(); + + 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); + } + + 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..1770be7418 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ThinkingBlockConverter.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.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 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 thinking = block.getThinking(); + String msgId = event.getMessage().getId(); + + if (thinking != null && !thinking.isEmpty()) { + if (!ctx.isReasoningActive(msgId)) { + ctx.flushAllActiveReasonings(); + + 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); + } + + 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..627653914c --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultBlockConverter.java @@ -0,0 +1,92 @@ +/* + * 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 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() : + (ctx.getLastActiveToolId() != null ? ctx.getLastActiveToolId() : 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 != null ? 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 + */ + private 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; + } +} \ No newline at end of file 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..76a0249896 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolUseBlockConverter.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.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 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() : + (ctx.getLastActiveToolId() != null ? ctx.getLastActiveToolId() : 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() : "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)); + } + } + } +} \ No newline at end of file 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..0fc7082709 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 @@ -269,7 +269,7 @@ void testRunWithStreamingSummaryEvents() { long contentCount = events.stream().filter(e -> e instanceof AguiEvent.TextMessageContent).count(); - assertEquals(2, contentCount, "Should stream summary chunks as text deltas"); + assertEquals(3, contentCount, "Should stream summary chunks as text deltas"); long startCount = events.stream().filter(e -> e instanceof AguiEvent.TextMessageStart).count(); @@ -654,9 +654,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 testTextMessageLifecycleWhenInterruptedByToolCall() { + // Test that when a text message is interrupted by a tool call, the active text message + // is closed immediately. When subsequent text blocks with the same message ID arrive, + // a new text message lifecycle (Start -> Content -> End) is initiated. String msgId = "msg-text"; Msg firstMsg = Msg.builder() @@ -700,7 +701,7 @@ void testTextMessageEndNotDuplicatedWhenLastEventAfterToolCall() { assertNotNull(events); - // Should have exactly one TextMessageEnd for the same message ID + // Verify that TextMessageEnd is emitted twice due to the tool call interruption long textEndCount = events.stream() .filter(e -> e instanceof AguiEvent.TextMessageEnd) @@ -710,7 +711,19 @@ void testTextMessageEndNotDuplicatedWhenLastEventAfterToolCall() { return msgId.equals(end.messageId()); }) .count(); - assertEquals(1, textEndCount, "Should have exactly 1 TextMessageEnd per message ID"); + assertEquals(2, textEndCount, "Should emit exactly 2 TextMessageEnds: one for the interrupt, one for the final chunk"); + + // Optional: You can also verify that it was started twice for the same reason + 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(2, textStartCount, "Should emit exactly 2 TextMessageStarts due to the tool call interruption"); } @Test @@ -799,8 +812,9 @@ void testTextMessageEndWithLastEventDirectly() { // Verify the event sequence assertInstanceOf(AguiEvent.RunStarted.class, events.get(0)); assertInstanceOf(AguiEvent.TextMessageStart.class, events.get(1)); - assertInstanceOf(AguiEvent.TextMessageEnd.class, events.get(2)); - assertInstanceOf(AguiEvent.RunFinished.class, events.get(3)); + assertInstanceOf(AguiEvent.TextMessageContent.class, events.get(2)); + assertInstanceOf(AguiEvent.TextMessageEnd.class, events.get(3)); + assertInstanceOf(AguiEvent.RunFinished.class, events.get(4)); } @Test From c8ece673723fc5aa64e05aa91a46050067456736 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Wed, 6 May 2026 11:26:00 +0800 Subject: [PATCH 02/20] fix: format code style --- .../agentscope/core/message/CustomBlock.java | 6 +- .../core/agui/adapter/AguiAgentAdapter.java | 60 ++++++++++--------- .../core/agui/adapter/StreamContext.java | 50 ++++++++++++---- .../strategy/CustomBlockConverter.java | 11 ++-- .../adapter/strategy/TextBlockConverter.java | 12 +++- .../strategy/ThinkingBlockConverter.java | 13 +++- .../strategy/ToolResultBlockConverter.java | 36 ++++++----- .../strategy/ToolUseBlockConverter.java | 22 ++++--- .../agui/adapter/AguiAgentAdapterTest.java | 14 ++++- 9 files changed, 146 insertions(+), 78 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java index c0658e5d8d..6dfc5ccf8c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java @@ -40,9 +40,7 @@ public final class CustomBlock extends ContentBlock { * @param value The arbitrary payload value associated with the event */ @JsonCreator - public CustomBlock( - @JsonProperty("name") String name, - @JsonProperty("value") Object value) { + public CustomBlock(@JsonProperty("name") String name, @JsonProperty("value") Object value) { this.name = name; this.value = value; } @@ -113,4 +111,4 @@ public CustomBlock build() { return new CustomBlock(name, value); } } -} \ No newline at end of file +} 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 f0e88b0339..b7d18f5880 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,13 +19,13 @@ import io.agentscope.core.agent.Event; import io.agentscope.core.agent.EventType; import io.agentscope.core.agent.StreamOptions; -import io.agentscope.core.agui.converter.AguiMessageConverter; import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.adapter.strategy.CustomBlockConverter; 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; @@ -35,13 +35,12 @@ import io.agentscope.core.message.ThinkingBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; -import reactor.core.publisher.Flux; - import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import reactor.core.publisher.Flux; /** * Adapter that bridges AgentScope agents to the AG-UI protocol. @@ -105,27 +104,32 @@ public Flux run(RunAgentInput input) { String runId = input.getRunId(); List msgs = messageConverter.toMsgList(input.getMessages()); // Create stream options - use incremental mode for true streaming - StreamOptions options = StreamOptions.builder().eventTypes(EventType.ALL).incremental(true).build(); - - return Flux.defer(() -> { - 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 -> processEvent(event, ctx)), - - // Emit any pending end events - Flux.defer(() -> Flux.fromIterable(ctx.flushAllRemainingDeferred())), - - // Emit RUN_FINISHED - Flux.just(new AguiEvent.RunFinished(threadId, runId)) - ).onErrorResume(error -> handleError(threadId, runId, ctx, error)); - }); + StreamOptions options = + StreamOptions.builder().eventTypes(EventType.ALL).incremental(true).build(); + + return Flux.defer( + () -> { + 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 -> processEvent(event, ctx)), + + // Emit any pending end events + Flux.defer( + () -> + Flux.fromIterable( + ctx.flushAllRemainingDeferred())), + + // Emit RUN_FINISHED + Flux.just(new AguiEvent.RunFinished(threadId, runId))) + .onErrorResume(error -> handleError(threadId, runId, ctx, error)); + }); } /** @@ -160,14 +164,16 @@ private List processEvent(Event event, StreamContext ctx) { * @param error The thrown exception * @return A Flux containing the fallback closure events */ - private Flux handleError(String threadId, String runId, StreamContext ctx, Throwable error) { + private Flux handleError( + String threadId, String runId, StreamContext ctx, Throwable error) { List events = new ArrayList<>(); events.addAll(ctx.flushAllRemainingDeferred()); - String msg = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); + 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)); return Flux.fromIterable(events); } -} \ No newline at end of file +} 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 index 7974886c8a..482586a5c7 100644 --- 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 @@ -16,7 +16,13 @@ package io.agentscope.core.agui.adapter; import io.agentscope.core.agui.event.AguiEvent; -import java.util.*; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; /** * Context holder for the AG-UI stream pipeline. @@ -60,9 +66,17 @@ public StreamContext(String threadId, String runId, AguiAdapterConfig config) { this.config = config; } - public String getThreadId() { return threadId; } - public String getRunId() { return runId; } - public AguiAdapterConfig getConfig() { return config; } + public String getThreadId() { + return threadId; + } + + public String getRunId() { + return runId; + } + + public AguiAdapterConfig getConfig() { + return config; + } // --- Event Emission and Deferred Management API --- @@ -150,15 +164,31 @@ public void flushAllActiveReasonings() { // --- 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); } + 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); + } // --- 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); } + 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); + } // --- Tool State Management --- diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java index 803e5318f9..27e7844765 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java @@ -33,11 +33,8 @@ public boolean isApplicable(Event event) { @Override public void convert(CustomBlock block, Event event, StreamContext ctx) { - ctx.emit(new AguiEvent.Custom( - ctx.getThreadId(), - ctx.getRunId(), - block.getName(), - block.getValue() - )); + ctx.emit( + new AguiEvent.Custom( + ctx.getThreadId(), ctx.getRunId(), block.getName(), block.getValue())); } -} \ No newline at end of file +} 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 index d6920efa2e..707803de50 100644 --- 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 @@ -40,12 +40,18 @@ public void convert(TextBlock block, Event event, StreamContext ctx) { if (!ctx.isTextActive(msgId)) { ctx.flushAllActiveTexts(); - 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.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); } - ctx.emit(new AguiEvent.TextMessageContent(ctx.getThreadId(), ctx.getRunId(), msgId, text)); + ctx.emit( + new AguiEvent.TextMessageContent( + ctx.getThreadId(), ctx.getRunId(), msgId, text)); } if (event.isLast() && ctx.isTextActive(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 index 1770be7418..6fa61b9d7f 100644 --- 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 @@ -45,12 +45,19 @@ public void convert(ThinkingBlock block, Event event, StreamContext ctx) { if (!ctx.isReasoningActive(msgId)) { ctx.flushAllActiveReasonings(); - 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.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); } - ctx.emit(new AguiEvent.ReasoningMessageContent(ctx.getThreadId(), ctx.getRunId(), msgId, thinking)); + ctx.emit( + new AguiEvent.ReasoningMessageContent( + ctx.getThreadId(), ctx.getRunId(), msgId, thinking)); } if (event.isLast() && ctx.isReasoningActive(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 index 627653914c..221aa1497e 100644 --- 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 @@ -22,7 +22,6 @@ import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; - import java.util.UUID; /** @@ -37,8 +36,12 @@ public boolean isApplicable(Event event) { @Override public void convert(ToolResultBlock block, Event event, StreamContext ctx) { - String toolCallId = block.getId() != null ? block.getId() : - (ctx.getLastActiveToolId() != null ? ctx.getLastActiveToolId() : UUID.randomUUID().toString()); + String toolCallId = + block.getId() != null + ? block.getId() + : (ctx.getLastActiveToolId() != null + ? ctx.getLastActiveToolId() + : UUID.randomUUID().toString()); String result = extractToolResultText(block); // Closing Start/End Phase @@ -47,19 +50,24 @@ public void convert(ToolResultBlock block, Event event, StreamContext ctx) { } 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)); + 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 != null ? result : "", - "tool", - event.getMessage().getId() - )); + ctx.emit( + new AguiEvent.ToolCallResult( + ctx.getThreadId(), + ctx.getRunId(), + toolCallId, + result != null ? result : "", + "tool", + event.getMessage().getId())); if (ctx.isToolActive(toolCallId)) { ctx.removeActiveTool(toolCallId); @@ -89,4 +97,4 @@ private String extractToolResultText(ToolResultBlock toolResult) { return !sb.isEmpty() ? sb.toString() : null; } -} \ No newline at end of file +} 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 index 76a0249896..0407106d0c 100644 --- 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 @@ -20,7 +20,6 @@ import io.agentscope.core.agui.adapter.StreamContext; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.message.ToolUseBlock; - import java.util.UUID; /** @@ -35,8 +34,12 @@ public boolean isApplicable(Event event) { @Override public void convert(ToolUseBlock block, Event event, StreamContext ctx) { - String toolCallId = block.getId() != null ? block.getId() : - (ctx.getLastActiveToolId() != null ? ctx.getLastActiveToolId() : UUID.randomUUID().toString()); + String toolCallId = + block.getId() != null + ? block.getId() + : (ctx.getLastActiveToolId() != null + ? ctx.getLastActiveToolId() + : UUID.randomUUID().toString()); if (!ctx.isToolActive(toolCallId)) { // End any active Text/Reasoning message before starting tool call @@ -44,8 +47,11 @@ public void convert(ToolUseBlock block, Event event, StreamContext ctx) { ctx.flushAllActiveReasonings(); String toolName = block.getName() != null ? block.getName() : "unknown"; - ctx.emit(new AguiEvent.ToolCallStart(ctx.getThreadId(), ctx.getRunId(), toolCallId, toolName)); - ctx.deferEndEvent(StreamContext.PREFIX_TOOL + toolCallId, + 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); } @@ -54,8 +60,10 @@ public void convert(ToolUseBlock block, Event event, StreamContext ctx) { 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)); + ctx.emit( + new AguiEvent.ToolCallArgs( + ctx.getThreadId(), ctx.getRunId(), toolCallId, args)); } } } -} \ No newline at end of file +} 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 0fc7082709..79f2ad17e9 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 @@ -711,7 +711,11 @@ void testTextMessageLifecycleWhenInterruptedByToolCall() { return msgId.equals(end.messageId()); }) .count(); - assertEquals(2, textEndCount, "Should emit exactly 2 TextMessageEnds: one for the interrupt, one for the final chunk"); + assertEquals( + 2, + textEndCount, + "Should emit exactly 2 TextMessageEnds: one for the interrupt, one for the final" + + " chunk"); // Optional: You can also verify that it was started twice for the same reason long textStartCount = @@ -719,11 +723,15 @@ void testTextMessageLifecycleWhenInterruptedByToolCall() { .filter(e -> e instanceof AguiEvent.TextMessageStart) .filter( e -> { - AguiEvent.TextMessageStart start = (AguiEvent.TextMessageStart) e; + AguiEvent.TextMessageStart start = + (AguiEvent.TextMessageStart) e; return msgId.equals(start.messageId()); }) .count(); - assertEquals(2, textStartCount, "Should emit exactly 2 TextMessageStarts due to the tool call interruption"); + assertEquals( + 2, + textStartCount, + "Should emit exactly 2 TextMessageStarts due to the tool call interruption"); } @Test From cce154d1faed47f5d4629d85a89c3516a3127a56 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Wed, 6 May 2026 13:47:38 +0800 Subject: [PATCH 03/20] test: add ag-ui CUSTOM event testcase --- .../agentscope/core/message/CustomBlock.java | 8 +- .../core/agui/adapter/StreamContext.java | 15 +++ .../adapter/strategy/TextBlockConverter.java | 2 +- .../strategy/ThinkingBlockConverter.java | 2 +- .../strategy/ToolUseBlockConverter.java | 7 +- .../agui/adapter/AguiAgentAdapterTest.java | 93 +++++++++++++++++++ 6 files changed, 119 insertions(+), 8 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java index 6dfc5ccf8c..515a360ae5 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java @@ -36,12 +36,12 @@ public final class CustomBlock extends ContentBlock { /** * Creates a new custom block for JSON deserialization. * - * @param name The name of the custom event + * @param name The name of the custom event (null will be converted to empty string) * @param value The arbitrary payload value associated with the event */ @JsonCreator public CustomBlock(@JsonProperty("name") String name, @JsonProperty("value") Object value) { - this.name = name; + this.name = name != null ? name : ""; this.value = value; } @@ -105,10 +105,10 @@ public Builder value(Object value) { /** * Builds a new CustomBlock with the configured properties. * - * @return A new CustomBlock instance + * @return A new CustomBlock instance (null name will be converted to empty string) */ public CustomBlock build() { - return new CustomBlock(name, value); + return new CustomBlock(name != null ? name : "", value); } } } 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 index 482586a5c7..629b85abe4 100644 --- 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 @@ -66,14 +66,29 @@ public StreamContext(String threadId, String runId, AguiAdapterConfig config) { 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; } 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 index 707803de50..41278f431b 100644 --- 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 @@ -36,7 +36,7 @@ public void convert(TextBlock block, Event event, StreamContext ctx) { String text = block.getText(); String msgId = event.getMessage().getId(); - if (text != null && !text.isEmpty()) { + if (text != null && !text.isBlank()) { if (!ctx.isTextActive(msgId)) { ctx.flushAllActiveTexts(); 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 index 6fa61b9d7f..6ea32d5613 100644 --- 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 @@ -41,7 +41,7 @@ public void convert(ThinkingBlock block, Event event, StreamContext ctx) { String thinking = block.getThinking(); String msgId = event.getMessage().getId(); - if (thinking != null && !thinking.isEmpty()) { + if (thinking != null && !thinking.isBlank()) { if (!ctx.isReasoningActive(msgId)) { ctx.flushAllActiveReasonings(); 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 index 0407106d0c..4c917fab0e 100644 --- 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 @@ -46,7 +46,10 @@ public void convert(ToolUseBlock block, Event event, StreamContext ctx) { ctx.flushAllActiveTexts(); ctx.flushAllActiveReasonings(); - String toolName = block.getName() != null ? block.getName() : "unknown"; + String toolName = + block.getName() != null && !block.getName().isBlank() + ? block.getName() + : "unknown"; ctx.emit( new AguiEvent.ToolCallStart( ctx.getThreadId(), ctx.getRunId(), toolCallId, toolName)); @@ -59,7 +62,7 @@ public void convert(ToolUseBlock block, Event event, StreamContext ctx) { // Emit tool call args if enabled if (ctx.getConfig().isEmitToolCallArgs() && !event.isLast()) { String args = block.getContent(); - if (args != null && !args.isEmpty()) { + if (args != null && !args.isBlank()) { 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/AguiAgentAdapterTest.java b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java index 79f2ad17e9..b3dcae7661 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 @@ -31,6 +31,7 @@ import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.message.CustomBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; @@ -1718,4 +1719,96 @@ void testRunWithNullThinkingBlock() { !hasReasoningMessageStart, "Should NOT have ReasoningMessageStart for null thinking"); } + + @Test + void testRunWithCustomEventForToolProgress() { + // Simulate intermediate tool progress using CustomBlock (50% downloaded) + Msg progressMsg1 = + Msg.builder() + .id("msg-tr1") + .role(MsgRole.TOOL) + .content( + CustomBlock.builder() + .name("tool_progress") + .value(Map.of("progress", "50%")) + .build()) + .build(); + Event progressEvent1 = new Event(EventType.TOOL_RESULT, progressMsg1, false); + + // Simulate intermediate tool progress (100% downloaded) + Msg progressMsg2 = + Msg.builder() + .id("msg-tr1") + .role(MsgRole.TOOL) + .content( + CustomBlock.builder() + .name("tool_progress") + .value(Map.of("progress", "100%")) + .build()) + .build(); + Event progressEvent2 = new Event(EventType.TOOL_RESULT, progressMsg2, false); + + // Simulate final tool result (isLast = true) + Msg finalResultMsg = + Msg.builder() + .id("msg-tr1") + .role(MsgRole.TOOL) + .content( + ToolResultBlock.builder() + .id("tc-1") + .output( + TextBlock.builder() + .text("Download complete") + .build()) + .build()) + .build(); + Event finalResultEvent = new Event(EventType.TOOL_RESULT, finalResultMsg, true); + + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(progressEvent1, progressEvent2, finalResultEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Download file"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Verify that exactly 2 Custom events were emitted for the progress + long customEventCount = events.stream().filter(e -> e instanceof AguiEvent.Custom).count(); + assertEquals(2, customEventCount, "Should have 2 Custom events for tool progress"); + + // Verify the content of the Custom events + List customEvents = + events.stream() + .filter(e -> e instanceof AguiEvent.Custom) + .map(e -> (AguiEvent.Custom) e) + .toList(); + + assertEquals("tool_progress", customEvents.get(0).name()); + Map value1 = (Map) customEvents.get(0).value(); + assertEquals("50%", value1.get("progress")); + + assertEquals("tool_progress", customEvents.get(1).name()); + Map value2 = (Map) customEvents.get(1).value(); + assertEquals("100%", value2.get("progress")); + + // Verify that the final ToolCallResult was emitted correctly + long toolResultCount = + events.stream().filter(e -> e instanceof AguiEvent.ToolCallResult).count(); + assertEquals( + 1, toolResultCount, "Should have exactly 1 ToolCallResult for the final event"); + + AguiEvent.ToolCallResult finalResult = + (AguiEvent.ToolCallResult) + events.stream() + .filter(e -> e instanceof AguiEvent.ToolCallResult) + .findFirst() + .orElseThrow(); + assertEquals("Download complete", finalResult.content()); + } } From e9d8cb9ab47c3e8f56c21bb44d58a428d771c0da Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Wed, 13 May 2026 13:30:11 +0800 Subject: [PATCH 04/20] fix: duplicate text/reasoning content --- .../core/agui/adapter/strategy/TextBlockConverter.java | 8 +++++--- .../agui/adapter/strategy/ThinkingBlockConverter.java | 8 +++++--- .../core/agui/adapter/AguiAgentAdapterTest.java | 7 +++---- 3 files changed, 13 insertions(+), 10 deletions(-) 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 index 41278f431b..d13607ab31 100644 --- 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 @@ -49,9 +49,11 @@ public void convert(TextBlock block, Event event, StreamContext ctx) { ctx.addActiveText(msgId); } - ctx.emit( - new AguiEvent.TextMessageContent( - ctx.getThreadId(), ctx.getRunId(), msgId, text)); + if (!event.isLast()) { + ctx.emit( + new AguiEvent.TextMessageContent( + ctx.getThreadId(), ctx.getRunId(), msgId, text)); + } } if (event.isLast() && ctx.isTextActive(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 index 6ea32d5613..6b07ada66d 100644 --- 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 @@ -55,9 +55,11 @@ public void convert(ThinkingBlock block, Event event, StreamContext ctx) { ctx.addActiveReasoning(msgId); } - ctx.emit( - new AguiEvent.ReasoningMessageContent( - ctx.getThreadId(), ctx.getRunId(), msgId, thinking)); + if (!event.isLast()) { + ctx.emit( + new AguiEvent.ReasoningMessageContent( + ctx.getThreadId(), ctx.getRunId(), msgId, thinking)); + } } if (event.isLast() && ctx.isReasoningActive(msgId)) { 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 b3dcae7661..1bafd9efc2 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 @@ -270,7 +270,7 @@ void testRunWithStreamingSummaryEvents() { long contentCount = events.stream().filter(e -> e instanceof AguiEvent.TextMessageContent).count(); - assertEquals(3, contentCount, "Should stream summary chunks as text deltas"); + assertEquals(2, contentCount, "Should stream summary chunks as text deltas"); long startCount = events.stream().filter(e -> e instanceof AguiEvent.TextMessageStart).count(); @@ -821,9 +821,8 @@ void testTextMessageEndWithLastEventDirectly() { // Verify the event sequence assertInstanceOf(AguiEvent.RunStarted.class, events.get(0)); assertInstanceOf(AguiEvent.TextMessageStart.class, events.get(1)); - assertInstanceOf(AguiEvent.TextMessageContent.class, events.get(2)); - assertInstanceOf(AguiEvent.TextMessageEnd.class, events.get(3)); - assertInstanceOf(AguiEvent.RunFinished.class, events.get(4)); + assertInstanceOf(AguiEvent.TextMessageEnd.class, events.get(2)); + assertInstanceOf(AguiEvent.RunFinished.class, events.get(3)); } @Test From b6605951b840abeea8af4e76cbfce5dd87a889e8 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Wed, 13 May 2026 14:50:50 +0800 Subject: [PATCH 05/20] fix: duplicate reasoning start/end agui event --- .../core/agui/adapter/StreamContext.java | 20 +++++++++ .../adapter/strategy/TextBlockConverter.java | 5 ++- .../strategy/ThinkingBlockConverter.java | 5 ++- .../agui/adapter/AguiAgentAdapterTest.java | 43 +++++++++---------- 4 files changed, 49 insertions(+), 24 deletions(-) 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 index 629b85abe4..51f697aae9 100644 --- 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 @@ -17,6 +17,7 @@ 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; @@ -50,6 +51,9 @@ public class StreamContext { private final Set activeReasoningIds = new LinkedHashSet<>(); private final Set activeToolIds = new LinkedHashSet<>(); + private final Set finishedTextIds = new HashSet<>(); + private final Set finishedReasoningIds = new HashSet<>(); + // Fallback ID for tool results that might lack an explicit ID private String lastActiveToolId = null; @@ -148,10 +152,16 @@ public List flushAllRemainingDeferred() { List remaining = new ArrayList<>(deferredEndEvents.values()); deferredEndEvents.clear(); + activeTextIds.clear(); activeReasoningIds.clear(); activeToolIds.clear(); + finishedTextIds.clear(); + finishedReasoningIds.clear(); + + lastActiveToolId = null; + return remaining; } @@ -189,6 +199,11 @@ public void addActiveText(String id) { public void removeActiveText(String id) { activeTextIds.remove(id); + finishedTextIds.add(id); + } + + public boolean isTextFinished(String id) { + return finishedTextIds.contains(id); } // --- Reasoning State Management --- @@ -203,6 +218,11 @@ public void addActiveReasoning(String id) { public void removeActiveReasoning(String id) { activeReasoningIds.remove(id); + finishedReasoningIds.add(id); + } + + public boolean isReasoningFinished(String id) { + return finishedReasoningIds.contains(id); } // --- Tool State Management --- 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 index d13607ab31..37f0bfeb19 100644 --- 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 @@ -33,9 +33,12 @@ public boolean isApplicable(Event event) { @Override public void convert(TextBlock block, Event event, StreamContext ctx) { - String text = block.getText(); String msgId = event.getMessage().getId(); + if (ctx.isTextFinished(msgId)) { + return; + } + String text = block.getText(); if (text != null && !text.isBlank()) { if (!ctx.isTextActive(msgId)) { ctx.flushAllActiveTexts(); 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 index 6b07ada66d..5152195786 100644 --- 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 @@ -38,9 +38,12 @@ public void convert(ThinkingBlock block, Event event, StreamContext ctx) { return; } - String thinking = block.getThinking(); String msgId = event.getMessage().getId(); + if (ctx.isReasoningFinished(msgId)) { + return; + } + String thinking = block.getThinking(); if (thinking != null && !thinking.isBlank()) { if (!ctx.isReasoningActive(msgId)) { ctx.flushAllActiveReasonings(); 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 1bafd9efc2..631fbd589f 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 @@ -655,10 +655,10 @@ void testToolUseBlockWithNullId() { } @Test - void testTextMessageLifecycleWhenInterruptedByToolCall() { - // Test that when a text message is interrupted by a tool call, the active text message - // is closed immediately. When subsequent text blocks with the same message ID arrive, - // a new text message lifecycle (Start -> Content -> End) is initiated. + 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() @@ -688,6 +688,7 @@ void testTextMessageLifecycleWhenInterruptedByToolCall() { 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)); @@ -702,37 +703,35 @@ void testTextMessageLifecycleWhenInterruptedByToolCall() { assertNotNull(events); - // Verify that TextMessageEnd is emitted twice due to the tool call interruption - long textEndCount = + long textStartCount = events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageEnd) + .filter(e -> e instanceof AguiEvent.TextMessageStart) .filter( e -> { - AguiEvent.TextMessageEnd end = (AguiEvent.TextMessageEnd) e; - return msgId.equals(end.messageId()); + AguiEvent.TextMessageStart start = + (AguiEvent.TextMessageStart) e; + return msgId.equals(start.messageId()); }) .count(); assertEquals( - 2, - textEndCount, - "Should emit exactly 2 TextMessageEnds: one for the interrupt, one for the final" - + " chunk"); + 1, + textStartCount, + "Should emit exactly 1 TextMessageStart. The subsequent block should be ignored"); - // Optional: You can also verify that it was started twice for the same reason - long textStartCount = + long textEndCount = events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) + .filter(e -> e instanceof AguiEvent.TextMessageEnd) .filter( e -> { - AguiEvent.TextMessageStart start = - (AguiEvent.TextMessageStart) e; - return msgId.equals(start.messageId()); + AguiEvent.TextMessageEnd end = (AguiEvent.TextMessageEnd) e; + return msgId.equals(end.messageId()); }) .count(); assertEquals( - 2, - textStartCount, - "Should emit exactly 2 TextMessageStarts due to the tool call interruption"); + 1, + textEndCount, + "Should emit exactly 1 TextMessageEnd: the one triggered by the tool" + + " interruption."); } @Test From 68d83f1ef6842f3681ae2a98414be8226c9695a4 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Wed, 13 May 2026 21:02:53 +0800 Subject: [PATCH 06/20] fix: force end the reasoning event before start the text event --- .../core/agui/adapter/strategy/TextBlockConverter.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 37f0bfeb19..c3546fa0f6 100644 --- 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 @@ -42,6 +42,8 @@ public void convert(TextBlock block, Event event, StreamContext ctx) { if (text != null && !text.isBlank()) { if (!ctx.isTextActive(msgId)) { ctx.flushAllActiveTexts(); + // When the text is output, it signifies that the "reasoning" has end + ctx.flushAllActiveReasonings(); ctx.emit( new AguiEvent.TextMessageStart( From a1758d7d7bc6536c43354babee2b5a84f52aea86 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 16 May 2026 17:32:45 +0800 Subject: [PATCH 07/20] fix: add code comments and fix minor bugs --- .../core/agui/adapter/AguiAgentAdapter.java | 21 +++++++++++-------- .../core/agui/adapter/StreamContext.java | 18 ++++++++-------- .../adapter/strategy/TextBlockConverter.java | 5 +++-- .../strategy/ThinkingBlockConverter.java | 4 +++- .../strategy/ToolResultBlockConverter.java | 2 +- .../strategy/ToolUseBlockConverter.java | 2 +- 6 files changed, 29 insertions(+), 23 deletions(-) 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 b7d18f5880..58996695be 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 @@ -78,8 +78,8 @@ public class AguiAgentAdapter { * @param config The adapter configuration */ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { - this.agent = Objects.requireNonNull(agent); - this.config = Objects.requireNonNull(config); + this.agent = Objects.requireNonNull(agent, "agent cannot be null"); + this.config = Objects.requireNonNull(config, "config cannot be null"); this.messageConverter = new AguiMessageConverter(); // Register block conversion strategies @@ -100,15 +100,18 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { * @return A Flux of AG-UI events */ public Flux run(RunAgentInput input) { - String threadId = input.getThreadId(); - String runId = input.getRunId(); - List msgs = messageConverter.toMsgList(input.getMessages()); - // Create stream options - use incremental mode for true streaming - StreamOptions options = - StreamOptions.builder().eventTypes(EventType.ALL).incremental(true).build(); - return Flux.defer( () -> { + String threadId = input.getThreadId(); + String runId = input.getRunId(); + + // Convert AG-UI messages to AgentScope messages + List msgs = messageConverter.toMsgList(input.getMessages()); + + // Create stream options - use incremental mode for true streaming + StreamOptions options = + StreamOptions.builder().eventTypes(EventType.ALL).incremental(true).build(); + StreamContext ctx = new StreamContext(threadId, runId, config); return Flux.concat( 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 index 51f697aae9..6787e909d3 100644 --- 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 @@ -105,7 +105,7 @@ public AguiAdapterConfig getConfig() { * @param event The AG-UI event to emit */ public void emit(AguiEvent event) { - this.emittedEvents.add(event); + emittedEvents.add(event); } /** @@ -116,7 +116,7 @@ public void emit(AguiEvent event) { * @param endEvent The end event to defer */ public void deferEndEvent(String id, AguiEvent endEvent) { - this.deferredEndEvents.put(id, endEvent); + deferredEndEvents.put(id, endEvent); } /** @@ -232,25 +232,25 @@ public boolean isToolActive(String id) { } public void addActiveTool(String id) { - this.activeToolIds.add(id); - this.lastActiveToolId = id; // Update the fallback ID + activeToolIds.add(id); + lastActiveToolId = id; // Update the fallback ID } public void removeActiveTool(String id) { - this.activeToolIds.remove(id); + activeToolIds.remove(id); // If the removed ID matches the last recorded fallback ID, reset or step back the pointer - if (Objects.equals(this.lastActiveToolId, id)) { + if (Objects.equals(lastActiveToolId, id)) { if (activeToolIds.isEmpty()) { - this.lastActiveToolId = null; + lastActiveToolId = null; } else { // Retrieve the last inserted element from the LinkedHashSet String[] array = activeToolIds.toArray(new String[0]); - this.lastActiveToolId = array[array.length - 1]; + lastActiveToolId = array[array.length - 1]; } } } public String getLastActiveToolId() { - return this.lastActiveToolId; + return lastActiveToolId; } } 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 index c3546fa0f6..d6944a715f 100644 --- 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 @@ -34,15 +34,16 @@ public boolean isApplicable(Event event) { @Override public void convert(TextBlock block, Event event, StreamContext ctx) { String msgId = event.getMessage().getId(); + // After the id is end, it is rejected to be start again in this flow if (ctx.isTextFinished(msgId)) { return; } String text = block.getText(); - if (text != null && !text.isBlank()) { + if (text != null && !text.isEmpty()) { if (!ctx.isTextActive(msgId)) { ctx.flushAllActiveTexts(); - // When the text is output, it signifies that the "reasoning" has end + // When the text start, it signifies that the reasoning should end ctx.flushAllActiveReasonings(); ctx.emit( 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 index 5152195786..6ca6ec785e 100644 --- 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 @@ -39,14 +39,16 @@ public void convert(ThinkingBlock block, Event event, StreamContext ctx) { } String msgId = event.getMessage().getId(); + // After the id is end, it is rejected to be start again in this flow if (ctx.isReasoningFinished(msgId)) { return; } String thinking = block.getThinking(); - if (thinking != null && !thinking.isBlank()) { + if (thinking != null && !thinking.isEmpty()) { if (!ctx.isReasoningActive(msgId)) { ctx.flushAllActiveReasonings(); + ctx.flushAllActiveTexts(); ctx.emit( new AguiEvent.ReasoningMessageStart( 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 index 221aa1497e..5bcf8585d7 100644 --- 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 @@ -65,7 +65,7 @@ public void convert(ToolResultBlock block, Event event, StreamContext ctx) { ctx.getThreadId(), ctx.getRunId(), toolCallId, - result != null ? result : "", + result, "tool", event.getMessage().getId())); 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 index 4c917fab0e..98e2aff46a 100644 --- 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 @@ -62,7 +62,7 @@ public void convert(ToolUseBlock block, Event event, StreamContext ctx) { // Emit tool call args if enabled if (ctx.getConfig().isEmitToolCallArgs() && !event.isLast()) { String args = block.getContent(); - if (args != null && !args.isBlank()) { + if (args != null && !args.isEmpty()) { ctx.emit( new AguiEvent.ToolCallArgs( ctx.getThreadId(), ctx.getRunId(), toolCallId, args)); From f3b18aaffd0467a97b5326cfc33de5e28b81b2b4 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 16 May 2026 20:59:35 +0800 Subject: [PATCH 08/20] fix: add code comments and fix minor bugs --- .../core/agui/adapter/AguiAgentAdapter.java | 5 ++++- .../agentscope/core/agui/adapter/StreamContext.java | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) 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 58996695be..b7cfbd63b1 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 @@ -110,7 +110,10 @@ public Flux run(RunAgentInput input) { // Create stream options - use incremental mode for true streaming StreamOptions options = - StreamOptions.builder().eventTypes(EventType.ALL).incremental(true).build(); + StreamOptions.builder() + .eventTypes(EventType.ALL) + .incremental(true) + .build(); StreamContext ctx = new StreamContext(threadId, runId, config); 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 index 6787e909d3..329f79f1a5 100644 --- 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 @@ -31,12 +31,6 @@ * to ensure strict adherence to the AG-UI lifecycle protocol. */ public class StreamContext { - // 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 String threadId; private final String runId; private final AguiAdapterConfig config; @@ -47,6 +41,12 @@ public class StreamContext { // 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<>(); From dcb6b0ed47e5de8f6a317a4b2ab8b7ee81d3e1e2 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 16 May 2026 22:36:57 +0800 Subject: [PATCH 09/20] fix: optimize the tool call event correlation without default ID --- .../core/agui/adapter/StreamContext.java | 68 ++++++++++++++----- .../strategy/ToolResultBlockConverter.java | 4 +- .../strategy/ToolUseBlockConverter.java | 13 ++-- 3 files changed, 59 insertions(+), 26 deletions(-) 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 index 329f79f1a5..17e30957a9 100644 --- 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 @@ -23,7 +23,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.UUID; /** * Context holder for the AG-UI stream pipeline. @@ -54,8 +56,8 @@ public class StreamContext { private final Set finishedTextIds = new HashSet<>(); private final Set finishedReasoningIds = new HashSet<>(); - // Fallback ID for tool results that might lack an explicit ID - private String lastActiveToolId = null; + private final Set anonymousToolIds = new LinkedHashSet<>(); + private final Map anonymousToolNames = new LinkedHashMap<>(); /** * Initializes a new StreamContext. @@ -160,8 +162,6 @@ public List flushAllRemainingDeferred() { finishedTextIds.clear(); finishedReasoningIds.clear(); - lastActiveToolId = null; - return remaining; } @@ -233,24 +233,60 @@ public boolean isToolActive(String id) { public void addActiveTool(String id) { activeToolIds.add(id); - lastActiveToolId = id; // Update the fallback ID } public void removeActiveTool(String id) { activeToolIds.remove(id); - // If the removed ID matches the last recorded fallback ID, reset or step back the pointer - if (Objects.equals(lastActiveToolId, id)) { - if (activeToolIds.isEmpty()) { - lastActiveToolId = null; - } else { - // Retrieve the last inserted element from the LinkedHashSet - String[] array = activeToolIds.toArray(new String[0]); - lastActiveToolId = array[array.length - 1]; - } + anonymousToolIds.remove(id); + anonymousToolNames.remove(id); + } + + /** + * Resolves the tool call ID for a ToolResultBlock that does not provide an explicit ID. + * + *

    An anonymous tool result can only be matched safely when there is exactly one active tool + * call. If multiple tool calls are active, the result is ambiguous and should not be associated + * with any particular tool call by guesswork. + * + * @return the only active tool call ID, or an empty Optional if the result cannot be matched + */ + public Optional resolveAnonymousToolResultId() { + if (activeToolIds.size() == 1) { + return Optional.of(activeToolIds.iterator().next()); } + return Optional.empty(); } - public String getLastActiveToolId() { - return lastActiveToolId; + /** + * Resolves or creates a stable local ID for a ToolUseBlock that does not provide an explicit ID. + * + *

    When streaming tool arguments, multiple ToolUseBlock chunks may belong to the same anonymous + * tool call. This method reuses an active anonymous tool call with the same normalized tool name + * whenever the match is unambiguous. If no matching anonymous tool call exists, a new local ID is + * generated and tracked for subsequent chunks. + * + * @param toolName The tool name from the ToolUseBlock + * @return a stable local tool call ID for the anonymous tool use + */ + public String resolveOrCreateAnonymousToolUseId(String toolName) { + String normalizedName = + toolName != null && !toolName.isBlank() ? toolName : "unknown"; + + String matchedId = null; + for (String id : anonymousToolIds) { + if (activeToolIds.contains(id) + && Objects.equals(anonymousToolNames.get(id), normalizedName)) { + matchedId = id; + } + } + + if (matchedId != null) { + return matchedId; + } + + String generatedId = UUID.randomUUID().toString(); + anonymousToolIds.add(generatedId); + anonymousToolNames.put(generatedId, normalizedName); + return generatedId; } } 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 index 5bcf8585d7..799ac01480 100644 --- 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 @@ -39,9 +39,7 @@ public void convert(ToolResultBlock block, Event event, StreamContext ctx) { String toolCallId = block.getId() != null ? block.getId() - : (ctx.getLastActiveToolId() != null - ? ctx.getLastActiveToolId() - : UUID.randomUUID().toString()); + : ctx.resolveAnonymousToolResultId().orElseGet(() -> UUID.randomUUID().toString()); String result = extractToolResultText(block); // Closing Start/End Phase 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 index 98e2aff46a..c290b5be22 100644 --- 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 @@ -34,22 +34,21 @@ public boolean isApplicable(Event event) { @Override public void convert(ToolUseBlock block, Event event, StreamContext ctx) { + String toolName = + block.getName() != null && !block.getName().isBlank() + ? block.getName() + : "unknown"; + String toolCallId = block.getId() != null ? block.getId() - : (ctx.getLastActiveToolId() != null - ? ctx.getLastActiveToolId() - : UUID.randomUUID().toString()); + : ctx.resolveOrCreateAnonymousToolUseId(toolName); 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)); From 5609ac5691254fdc6fcac47ea30b9d5c10ef9328 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 16 May 2026 22:50:33 +0800 Subject: [PATCH 10/20] fix: code format --- .../core/agui/adapter/StreamContext.java | 17 ++++------------- .../strategy/ToolResultBlockConverter.java | 3 ++- .../adapter/strategy/ToolUseBlockConverter.java | 5 +---- 3 files changed, 7 insertions(+), 18 deletions(-) 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 index 17e30957a9..ba9b753119 100644 --- 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 @@ -146,22 +146,14 @@ public void flushEndEvent(String id) { /** * Flushes all remaining deferred end events. - * Typically called during stream termination or error recovery to ensure all UI components are closed. + * 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 events + * @return A list of all remaining deferred end events */ public List flushAllRemainingDeferred() { List remaining = new ArrayList<>(deferredEndEvents.values()); - deferredEndEvents.clear(); - - activeTextIds.clear(); - activeReasoningIds.clear(); - activeToolIds.clear(); - - finishedTextIds.clear(); - finishedReasoningIds.clear(); - return remaining; } @@ -269,8 +261,7 @@ public Optional resolveAnonymousToolResultId() { * @return a stable local tool call ID for the anonymous tool use */ public String resolveOrCreateAnonymousToolUseId(String toolName) { - String normalizedName = - toolName != null && !toolName.isBlank() ? toolName : "unknown"; + String normalizedName = toolName != null && !toolName.isBlank() ? toolName : "unknown"; String matchedId = null; for (String id : anonymousToolIds) { 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 index 799ac01480..06c638221f 100644 --- 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 @@ -39,7 +39,8 @@ public void convert(ToolResultBlock block, Event event, StreamContext ctx) { String toolCallId = block.getId() != null ? block.getId() - : ctx.resolveAnonymousToolResultId().orElseGet(() -> UUID.randomUUID().toString()); + : ctx.resolveAnonymousToolResultId() + .orElseGet(() -> UUID.randomUUID().toString()); String result = extractToolResultText(block); // Closing Start/End Phase 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 index c290b5be22..485b55fe2a 100644 --- 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 @@ -20,7 +20,6 @@ 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. @@ -35,9 +34,7 @@ public boolean isApplicable(Event event) { @Override public void convert(ToolUseBlock block, Event event, StreamContext ctx) { String toolName = - block.getName() != null && !block.getName().isBlank() - ? block.getName() - : "unknown"; + block.getName() != null && !block.getName().isBlank() ? block.getName() : "unknown"; String toolCallId = block.getId() != null From b82f3c5dbed3f98ef452df82a49e94e600114b69 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Mon, 18 May 2026 17:06:33 +0800 Subject: [PATCH 11/20] fix: optimize the interrupt handling mechanism in text/reasoning events --- .../adapter/strategy/TextBlockConverter.java | 7 +- .../strategy/ThinkingBlockConverter.java | 7 +- .../agui/adapter/AguiAgentAdapterTest.java | 120 ++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) 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 index d6944a715f..f71a8a0849 100644 --- 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 @@ -34,8 +34,11 @@ public boolean isApplicable(Event event) { @Override public void convert(TextBlock block, Event event, StreamContext ctx) { String msgId = event.getMessage().getId(); - // After the id is end, it is rejected to be start again in this flow - if (ctx.isTextFinished(msgId)) { + // 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; } 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 index 6ca6ec785e..86f34bb1f1 100644 --- 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 @@ -39,8 +39,11 @@ public void convert(ThinkingBlock block, Event event, StreamContext ctx) { } String msgId = event.getMessage().getId(); - // After the id is end, it is rejected to be start again in this flow - if (ctx.isReasoningFinished(msgId)) { + // 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; } 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 631fbd589f..96c9503f83 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 @@ -1809,4 +1809,124 @@ void testRunWithCustomEventForToolProgress() { .orElseThrow(); assertEquals("Download complete", finalResult.content()); } + + @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"); + } } From 2873a294e87bbd4571594d464553d21ceba1b953 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Mon, 18 May 2026 18:05:22 +0800 Subject: [PATCH 12/20] fix: remove the logic for handling anonymous tools --- .../core/agui/adapter/StreamContext.java | 56 ------------------- .../strategy/ToolResultBlockConverter.java | 6 +- .../strategy/ToolUseBlockConverter.java | 13 +++-- 3 files changed, 11 insertions(+), 64 deletions(-) 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 index ba9b753119..b2cb9a558a 100644 --- 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 @@ -22,10 +22,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; import java.util.Set; -import java.util.UUID; /** * Context holder for the AG-UI stream pipeline. @@ -56,9 +53,6 @@ public class StreamContext { private final Set finishedTextIds = new HashSet<>(); private final Set finishedReasoningIds = new HashSet<>(); - private final Set anonymousToolIds = new LinkedHashSet<>(); - private final Map anonymousToolNames = new LinkedHashMap<>(); - /** * Initializes a new StreamContext. * @@ -229,55 +223,5 @@ public void addActiveTool(String id) { public void removeActiveTool(String id) { activeToolIds.remove(id); - anonymousToolIds.remove(id); - anonymousToolNames.remove(id); - } - - /** - * Resolves the tool call ID for a ToolResultBlock that does not provide an explicit ID. - * - *

    An anonymous tool result can only be matched safely when there is exactly one active tool - * call. If multiple tool calls are active, the result is ambiguous and should not be associated - * with any particular tool call by guesswork. - * - * @return the only active tool call ID, or an empty Optional if the result cannot be matched - */ - public Optional resolveAnonymousToolResultId() { - if (activeToolIds.size() == 1) { - return Optional.of(activeToolIds.iterator().next()); - } - return Optional.empty(); - } - - /** - * Resolves or creates a stable local ID for a ToolUseBlock that does not provide an explicit ID. - * - *

    When streaming tool arguments, multiple ToolUseBlock chunks may belong to the same anonymous - * tool call. This method reuses an active anonymous tool call with the same normalized tool name - * whenever the match is unambiguous. If no matching anonymous tool call exists, a new local ID is - * generated and tracked for subsequent chunks. - * - * @param toolName The tool name from the ToolUseBlock - * @return a stable local tool call ID for the anonymous tool use - */ - public String resolveOrCreateAnonymousToolUseId(String toolName) { - String normalizedName = toolName != null && !toolName.isBlank() ? toolName : "unknown"; - - String matchedId = null; - for (String id : anonymousToolIds) { - if (activeToolIds.contains(id) - && Objects.equals(anonymousToolNames.get(id), normalizedName)) { - matchedId = id; - } - } - - if (matchedId != null) { - return matchedId; - } - - String generatedId = UUID.randomUUID().toString(); - anonymousToolIds.add(generatedId); - anonymousToolNames.put(generatedId, normalizedName); - return generatedId; } } 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 index 06c638221f..497ccb24a9 100644 --- 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 @@ -37,10 +37,10 @@ public boolean isApplicable(Event event) { @Override public void convert(ToolResultBlock block, Event event, StreamContext ctx) { String toolCallId = - block.getId() != null + block.getId() != null && !block.getId().isBlank() ? block.getId() - : ctx.resolveAnonymousToolResultId() - .orElseGet(() -> UUID.randomUUID().toString()); + : UUID.randomUUID().toString(); + String result = extractToolResultText(block); // Closing Start/End Phase 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 index 485b55fe2a..77a2ea47c5 100644 --- 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 @@ -20,6 +20,7 @@ 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. @@ -33,19 +34,21 @@ public boolean isApplicable(Event event) { @Override public void convert(ToolUseBlock block, Event event, StreamContext ctx) { - String toolName = - block.getName() != null && !block.getName().isBlank() ? block.getName() : "unknown"; - String toolCallId = - block.getId() != null + block.getId() != null && !block.getId().isBlank() ? block.getId() - : ctx.resolveOrCreateAnonymousToolUseId(toolName); + : 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)); From a229876261a5f74750684fa580906067b324d15a Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Mon, 18 May 2026 23:25:47 +0800 Subject: [PATCH 13/20] fix: remove ag-ui custom event --- .../agentscope/core/message/ContentBlock.java | 7 +- .../agentscope/core/message/CustomBlock.java | 114 ------------------ .../core/agui/adapter/AguiAgentAdapter.java | 4 - .../strategy/CustomBlockConverter.java | 40 ------ .../agui/adapter/AguiAgentAdapterTest.java | 93 -------------- 5 files changed, 2 insertions(+), 256 deletions(-) delete mode 100644 agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java delete mode 100644 agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java index d9b9ac10a7..55afe0393c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ContentBlock.java @@ -35,7 +35,6 @@ *

  • {@link VideoBlock} - Video content (URL or Base64) *
  • {@link ToolUseBlock} - Tool execution requests *
  • {@link ToolResultBlock} - Tool execution results - *
  • {@link CustomBlock} - Custom extension block to trigger AG-UI Custom events * * *

    Uses Jackson annotations for polymorphic JSON serialization with the "type" discriminator @@ -50,8 +49,7 @@ @JsonSubTypes.Type(value = AudioBlock.class, name = "audio"), @JsonSubTypes.Type(value = VideoBlock.class, name = "video"), @JsonSubTypes.Type(value = ToolUseBlock.class, name = "tool_use"), - @JsonSubTypes.Type(value = ToolResultBlock.class, name = "tool_result"), - @JsonSubTypes.Type(value = CustomBlock.class, name = "custom") + @JsonSubTypes.Type(value = ToolResultBlock.class, name = "tool_result") }) public sealed class ContentBlock implements State permits TextBlock, @@ -60,5 +58,4 @@ public sealed class ContentBlock implements State VideoBlock, ThinkingBlock, ToolUseBlock, - ToolResultBlock, - CustomBlock {} + ToolResultBlock {} diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java deleted file mode 100644 index 515a360ae5..0000000000 --- a/agentscope-core/src/main/java/io/agentscope/core/message/CustomBlock.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.core.message; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Represents a custom extension block specifically designed to trigger AG-UI Custom events. - * - *

    This block allows for the transmission of arbitrary JSON-serializable payloads - * to the frontend. It is commonly used for real-time progress pushes, workflow - * state changes, and any other customized interactive behaviors within the AG-UI protocol. - * - *

    The name identifies the custom event type, and the value can be a Map, String, - * Number, or any JSON-serializable object containing the event payload. - */ -public final class CustomBlock extends ContentBlock { - - private final String name; - private final Object value; - - /** - * Creates a new custom block for JSON deserialization. - * - * @param name The name of the custom event (null will be converted to empty string) - * @param value The arbitrary payload value associated with the event - */ - @JsonCreator - public CustomBlock(@JsonProperty("name") String name, @JsonProperty("value") Object value) { - this.name = name != null ? name : ""; - this.value = value; - } - - /** - * Gets the name of this custom block. - * - * @return The event name - */ - public String getName() { - return name; - } - - /** - * Gets the payload value of this custom block. - * - * @return The payload object - */ - public Object getValue() { - return value; - } - - /** - * Creates a new builder for constructing CustomBlock instances. - * - * @return A new builder instance - */ - public static Builder builder() { - return new Builder(); - } - - /** - * Builder for constructing CustomBlock instances. - */ - public static class Builder { - - private String name; - private Object value; - - /** - * Sets the name for the custom block event. - * - * @param name The custom event name - * @return This builder for chaining - */ - public Builder name(String name) { - this.name = name; - return this; - } - - /** - * Sets the arbitrary payload value for the block. - * - * @param value payload object - * @return This builder for chaining - */ - public Builder value(Object value) { - this.value = value; - return this; - } - - /** - * Builds a new CustomBlock with the configured properties. - * - * @return A new CustomBlock instance (null name will be converted to empty string) - */ - public CustomBlock build() { - return new CustomBlock(name != null ? name : "", value); - } - } -} 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 b7cfbd63b1..7dff36a397 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 @@ -20,7 +20,6 @@ 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.CustomBlockConverter; import io.agentscope.core.agui.adapter.strategy.TextBlockConverter; import io.agentscope.core.agui.adapter.strategy.ThinkingBlockConverter; import io.agentscope.core.agui.adapter.strategy.ToolResultBlockConverter; @@ -29,7 +28,6 @@ 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.CustomBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ThinkingBlock; @@ -54,7 +52,6 @@ *

  • AgentScope REASONING/SUMMARY events → AG-UI REASONING_* events (for ThinkingBlock)
  • *
  • AgentScope TOOL_RESULT events → AG-UI TOOL_CALL_END events
  • *
  • ToolUseBlock content → AG-UI TOOL_CALL_START events
  • - *
  • CustomBlock content → AG-UI CUSTOM events
  • * * *

    Reasoning Support: @@ -87,7 +84,6 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { converters.put(ThinkingBlock.class, new ThinkingBlockConverter()); converters.put(ToolUseBlock.class, new ToolUseBlockConverter()); converters.put(ToolResultBlock.class, new ToolResultBlockConverter()); - converters.put(CustomBlock.class, new CustomBlockConverter()); } /** diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java deleted file mode 100644 index 27e7844765..0000000000 --- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/CustomBlockConverter.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.core.agui.adapter.strategy; - -import io.agentscope.core.agent.Event; -import io.agentscope.core.agui.adapter.StreamContext; -import io.agentscope.core.agui.event.AguiEvent; -import io.agentscope.core.message.CustomBlock; - -/** - * Converter for handling CustomBlock events, transforming them into AG-UI Custom events. - */ -public class CustomBlockConverter implements BlockEventConverter { - - @Override - public boolean isApplicable(Event event) { - // Custom events can occur at any stage, so always return true. - return true; - } - - @Override - public void convert(CustomBlock block, Event event, StreamContext ctx) { - ctx.emit( - new AguiEvent.Custom( - ctx.getThreadId(), ctx.getRunId(), block.getName(), block.getValue())); - } -} 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 96c9503f83..5403f87f2a 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 @@ -31,7 +31,6 @@ import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.RunAgentInput; -import io.agentscope.core.message.CustomBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; @@ -1718,98 +1717,6 @@ void testRunWithNullThinkingBlock() { "Should NOT have ReasoningMessageStart for null thinking"); } - @Test - void testRunWithCustomEventForToolProgress() { - // Simulate intermediate tool progress using CustomBlock (50% downloaded) - Msg progressMsg1 = - Msg.builder() - .id("msg-tr1") - .role(MsgRole.TOOL) - .content( - CustomBlock.builder() - .name("tool_progress") - .value(Map.of("progress", "50%")) - .build()) - .build(); - Event progressEvent1 = new Event(EventType.TOOL_RESULT, progressMsg1, false); - - // Simulate intermediate tool progress (100% downloaded) - Msg progressMsg2 = - Msg.builder() - .id("msg-tr1") - .role(MsgRole.TOOL) - .content( - CustomBlock.builder() - .name("tool_progress") - .value(Map.of("progress", "100%")) - .build()) - .build(); - Event progressEvent2 = new Event(EventType.TOOL_RESULT, progressMsg2, false); - - // Simulate final tool result (isLast = true) - Msg finalResultMsg = - Msg.builder() - .id("msg-tr1") - .role(MsgRole.TOOL) - .content( - ToolResultBlock.builder() - .id("tc-1") - .output( - TextBlock.builder() - .text("Download complete") - .build()) - .build()) - .build(); - Event finalResultEvent = new Event(EventType.TOOL_RESULT, finalResultMsg, true); - - when(mockAgent.stream(anyList(), any(StreamOptions.class))) - .thenReturn(Flux.just(progressEvent1, progressEvent2, finalResultEvent)); - - RunAgentInput input = - RunAgentInput.builder() - .threadId("thread-1") - .runId("run-1") - .messages(List.of(AguiMessage.userMessage("msg-1", "Download file"))) - .build(); - - List events = adapter.run(input).collectList().block(); - - assertNotNull(events); - - // Verify that exactly 2 Custom events were emitted for the progress - long customEventCount = events.stream().filter(e -> e instanceof AguiEvent.Custom).count(); - assertEquals(2, customEventCount, "Should have 2 Custom events for tool progress"); - - // Verify the content of the Custom events - List customEvents = - events.stream() - .filter(e -> e instanceof AguiEvent.Custom) - .map(e -> (AguiEvent.Custom) e) - .toList(); - - assertEquals("tool_progress", customEvents.get(0).name()); - Map value1 = (Map) customEvents.get(0).value(); - assertEquals("50%", value1.get("progress")); - - assertEquals("tool_progress", customEvents.get(1).name()); - Map value2 = (Map) customEvents.get(1).value(); - assertEquals("100%", value2.get("progress")); - - // Verify that the final ToolCallResult was emitted correctly - long toolResultCount = - events.stream().filter(e -> e instanceof AguiEvent.ToolCallResult).count(); - assertEquals( - 1, toolResultCount, "Should have exactly 1 ToolCallResult for the final event"); - - AguiEvent.ToolCallResult finalResult = - (AguiEvent.ToolCallResult) - events.stream() - .filter(e -> e instanceof AguiEvent.ToolCallResult) - .findFirst() - .orElseThrow(); - assertEquals("Download complete", finalResult.content()); - } - @Test void testAggregateLastEventBlockedWhenFinished() { // Test that when a text or reasoning event is interrupted end(e.g., interrupted by a tool From c011db418152e91a3082ab565f3cffdfb5a7b210 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Mon, 18 May 2026 23:53:55 +0800 Subject: [PATCH 14/20] feat: support user-defined ContentBlock conversion strategies --- .../core/agui/adapter/AguiAdapterConfig.java | 37 +++++++++ .../core/agui/adapter/AguiAgentAdapter.java | 7 +- .../agui/adapter/AguiAgentAdapterTest.java | 77 +++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) 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..8730d411b9 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,13 @@ */ package io.agentscope.core.agui.adapter; +import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.model.ToolMergeMode; +import io.agentscope.core.message.ContentBlock; import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; /** * Configuration for the AG-UI agent adapter. @@ -32,6 +37,7 @@ public class AguiAdapterConfig { private final boolean enableReasoning; private final Duration runTimeout; private final String defaultAgentId; + private final Map, BlockEventConverter> customConverters; private AguiAdapterConfig(Builder builder) { this.toolMergeMode = builder.toolMergeMode; @@ -40,6 +46,10 @@ private AguiAdapterConfig(Builder builder) { this.enableReasoning = builder.enableReasoning; this.runTimeout = builder.runTimeout; this.defaultAgentId = builder.defaultAgentId; + this.customConverters = + builder.customConverters != null + ? Map.copyOf(builder.customConverters) + : Collections.emptyMap(); } /** @@ -100,6 +110,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. * @@ -129,6 +148,7 @@ public static class Builder { private boolean enableReasoning = false; private Duration runTimeout = Duration.ofMinutes(10); private String defaultAgentId; + private final Map, BlockEventConverter> customConverters = new HashMap<>(); /** * Set the tool merge mode. @@ -200,6 +220,23 @@ 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 blockClass The class of the ContentBlock to convert + * @param converter The custom converter strategy + * @param The ContentBlock type + * @return This builder + */ + public Builder registerConverter( + Class blockClass, BlockEventConverter converter) { + this.customConverters.put(blockClass, 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 7dff36a397..84380bdb43 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 @@ -79,11 +79,16 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { this.config = Objects.requireNonNull(config, "config cannot be null"); this.messageConverter = new AguiMessageConverter(); - // Register block conversion strategies + // Register default block conversion strategies converters.put(TextBlock.class, new TextBlockConverter()); converters.put(ThinkingBlock.class, new ThinkingBlockConverter()); converters.put(ToolUseBlock.class, new ToolUseBlockConverter()); converters.put(ToolResultBlock.class, new ToolResultBlockConverter()); + + // Override with custom converters if provided by the user + if (!config.getCustomConverters().isEmpty()) { + converters.putAll(config.getCustomConverters()); + } } /** 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 5403f87f2a..7a978d1af5 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; @@ -1836,4 +1838,79 @@ void testAggregateLastEventBlockedWhenFinished() { !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 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(ToolResultBlock.class, 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"); + } } From e3cf742f6679cc57d147855ddeb61fb8cdb47609 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Tue, 19 May 2026 12:13:23 +0800 Subject: [PATCH 15/20] feat: provide the enableActingChunk configuration in AguiAdapterConfig --- .../core/agui/adapter/AguiAdapterConfig.java | 29 +++++++++++++++++ .../core/agui/adapter/AguiAgentAdapter.java | 1 + .../agui/adapter/AguiAdapterConfigTest.java | 32 +++++++++++++++++++ .../boot/agui/common/AguiProperties.java | 17 ++++++++++ 4 files changed, 79 insertions(+) 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 8730d411b9..86aed4731b 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 @@ -35,6 +35,7 @@ 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; @@ -44,6 +45,7 @@ private AguiAdapterConfig(Builder builder) { 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 = @@ -92,6 +94,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. * @@ -146,6 +160,7 @@ 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<>(); @@ -198,6 +213,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. * 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 84380bdb43..be2f93144d 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 @@ -114,6 +114,7 @@ public Flux run(RunAgentInput input) { StreamOptions.builder() .eventTypes(EventType.ALL) .incremental(true) + .includeActingChunk(config.isEnableActingChunk()) .build(); StreamContext ctx = new StreamContext(threadId, runId, config); 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..ecc9277a9c 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 @@ -39,6 +39,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 +52,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 +99,32 @@ 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); @@ -133,6 +163,7 @@ void testBuilderFullConfiguration() { .emitStateEvents(false) .emitToolCallArgs(false) .enableReasoning(true) + .enableActingChunk(false) .runTimeout(Duration.ofHours(1)) .defaultAgentId("my-agent") .build(); @@ -141,6 +172,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-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; } From b5547c54c7c97d5e5d8be94f438f2b8bc1094493 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Tue, 19 May 2026 13:31:39 +0800 Subject: [PATCH 16/20] feat: custom block converters support springboot auto-configuration --- .../core/agui/adapter/AguiAdapterConfig.java | 10 +- .../core/agui/adapter/AguiAgentAdapter.java | 19 ++-- .../adapter/strategy/BlockEventConverter.java | 7 ++ .../adapter/strategy/TextBlockConverter.java | 5 + .../strategy/ThinkingBlockConverter.java | 5 + .../strategy/ToolResultBlockConverter.java | 5 + .../strategy/ToolUseBlockConverter.java | 5 + .../agui/adapter/AguiAdapterConfigTest.java | 94 ++++++++++++++++++- .../agui/adapter/AguiAgentAdapterTest.java | 9 +- .../AgentscopeAguiMvcAutoConfiguration.java | 25 ++++- ...gentscopeAguiWebFluxAutoConfiguration.java | 25 ++++- 11 files changed, 182 insertions(+), 27 deletions(-) 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 86aed4731b..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 @@ -17,7 +17,6 @@ import io.agentscope.core.agui.adapter.strategy.BlockEventConverter; import io.agentscope.core.agui.model.ToolMergeMode; -import io.agentscope.core.message.ContentBlock; import java.time.Duration; import java.util.Collections; import java.util.HashMap; @@ -255,14 +254,13 @@ public Builder defaultAgentId(String defaultAgentId) { *

    This allows users to override the default conversion strategies for specific * ContentBlock types, enabling deep customization of AG-UI event generation. * - * @param blockClass The class of the ContentBlock to convert * @param converter The custom converter strategy - * @param The ContentBlock type * @return This builder */ - public Builder registerConverter( - Class blockClass, BlockEventConverter converter) { - this.customConverters.put(blockClass, converter); + public Builder registerConverter(BlockEventConverter converter) { + if (converter != null && converter.supportedBlockType() != null) { + this.customConverters.put(converter.supportedBlockType(), converter); + } return this; } 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 be2f93144d..ca143ddd39 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 @@ -29,10 +29,6 @@ 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 java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -80,10 +76,10 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { this.messageConverter = new AguiMessageConverter(); // Register default block conversion strategies - converters.put(TextBlock.class, new TextBlockConverter()); - converters.put(ThinkingBlock.class, new ThinkingBlockConverter()); - converters.put(ToolUseBlock.class, new ToolUseBlockConverter()); - converters.put(ToolResultBlock.class, new ToolResultBlockConverter()); + registerDefault(new TextBlockConverter()); + registerDefault(new ThinkingBlockConverter()); + registerDefault(new ToolUseBlockConverter()); + registerDefault(new ToolResultBlockConverter()); // Override with custom converters if provided by the user if (!config.getCustomConverters().isEmpty()) { @@ -91,6 +87,13 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { } } + /** + * Helper method to safely register a default converter using its own declared supported type. + */ + private void registerDefault(BlockEventConverter converter) { + converters.put(converter.supportedBlockType(), converter); + } + /** * Run the agent with AG-UI protocol input. * 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 index 67ed91d1bd..9cba8c50a5 100644 --- 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 @@ -26,6 +26,13 @@ */ 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. * 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 index f71a8a0849..f1efad4635 100644 --- 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 @@ -26,6 +26,11 @@ */ 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; 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 index 86f34bb1f1..cbabb052d8 100644 --- 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 @@ -26,6 +26,11 @@ */ 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; 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 index 497ccb24a9..0aabcebbeb 100644 --- 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 @@ -29,6 +29,11 @@ */ 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(); 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 index 77a2ea47c5..9da9098e97 100644 --- 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 @@ -27,6 +27,11 @@ */ 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; 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 ecc9277a9c..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; /** @@ -106,8 +112,7 @@ void testBuilderEnableReasoning() { assertFalse(configDisabled.isEnableReasoning()); - AguiAdapterConfig configEnabled = - AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAdapterConfig configEnabled = AguiAdapterConfig.builder().enableReasoning(true).build(); assertTrue(configEnabled.isEnableReasoning()); } @@ -155,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 = 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 7a978d1af5..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 @@ -1844,6 +1844,11 @@ 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; @@ -1863,9 +1868,7 @@ public void convert(ToolResultBlock block, Event event, StreamContext ctx) { // Inject the custom strategy via config AguiAdapterConfig customConfig = - AguiAdapterConfig.builder() - .registerConverter(ToolResultBlock.class, customConverter) - .build(); + AguiAdapterConfig.builder().registerConverter(customConverter).build(); AguiAgentAdapter customAdapter = new AguiAgentAdapter(mockAgent, customConfig); // Simulate a ToolResult event 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) From 11c6841d96d951abeddbea0c27f65ffe3e274b9a Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Tue, 19 May 2026 14:59:23 +0800 Subject: [PATCH 17/20] feat: add an example for the ag-ui tool progress event --- .../CustomToolResultBlockConverter.java | 109 ++++++++++++++++++ .../examples/agui/tools/ExampleTools.java | 20 +++- .../agui/src/main/resources/application.yml | 1 + .../strategy/ToolResultBlockConverter.java | 2 +- 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java diff --git a/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java new file mode 100644 index 0000000000..7d803f8368 --- /dev/null +++ b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java @@ -0,0 +1,109 @@ +/* + * 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.examples.agui.config; + +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agui.adapter.StreamContext; +import io.agentscope.core.agui.adapter.strategy.ToolResultBlockConverter; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.util.JsonUtils; +import java.util.UUID; +import org.springframework.stereotype.Component; + +/** + * Custom converter that overrides the framework's default ToolResultBlockConverter + * to emit ag-ui tool progress events. + * + *

    Before overriding any default BlockEventConverter, please ensure you have a + * thorough understanding of the default implementation. + * + *

    IMPORTANT: In production environments, please modify with caution other than tool + * progress events. Otherwise, you assume all consequences and risks. + * + *

    This is currently provided as a demonstration version only. Please modify it + * according to your own requirements. + * + *

    Feel free to submit an issue on GitHub if you encounter any problems. + */ +@Component +public class CustomToolResultBlockConverter extends ToolResultBlockConverter { + + @Override + public boolean isApplicable(Event event) { + // return event.getType() == EventType.TOOL_RESULT && event.isLast(); + // The check for event.isLast() needs to be removed, otherwise only the final result event + // will be emitted + return event.getType() == EventType.TOOL_RESULT; + } + + @Override + public void convert(ToolResultBlock block, Event event, StreamContext ctx) { + // Extract the tool invocation progress from metadata and package it into a ToolCallResult + // event + // This is currently provided as a demonstration version only. + // Please modify it according to your own requirements. + if (block.getMetadata() != null && block.getMetadata().size() != 0) { + ctx.emit( + new AguiEvent.ToolCallResult( + ctx.getThreadId(), + ctx.getRunId(), + // TODO: Currently using tool name to replace tool call id + block.getName(), + JsonUtils.getJsonCodec().toJson(block.getMetadata()), + "tool", + event.getMessage().getId())); + return; + } + + String toolCallId = + block.getId() != null && !block.getId().isBlank() + ? block.getId() + : UUID.randomUUID().toString(); + + String result = super.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); + } + } +} diff --git a/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java index 079190a4f9..2bfab6ce75 100644 --- a/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java +++ b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java @@ -17,9 +17,11 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolEmitter; import io.agentscope.core.tool.ToolParam; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.Map; import java.util.Random; /** @@ -43,17 +45,33 @@ public class ExampleTools { @Tool(name = "get_weather", description = "Get current weather information for a city") public ToolResultBlock getWeather( @ToolParam(name = "city", description = "The city name (e.g., 'Beijing', 'New York')") - String city) { + String city, + ToolEmitter emitter) { // Mock weather data String[] conditions = {"Sunny", "Cloudy", "Partly Cloudy", "Rainy", "Overcast"}; String condition = conditions[random.nextInt(conditions.length)]; int temperature = random.nextInt(35) + 5; // 5-40 degrees int humidity = random.nextInt(60) + 30; // 30-90% + // Pass progress information in metadata + // To take effect, it is necessary to customize the ToolResultBlockConverter to override the + // framework default + emitter.emit( + ToolResultBlock.builder() + .name("get_weather") + .metadata(Map.of("progress", "50%")) + .build()); + String result = String.format( "Weather in %s:\n- Condition: %s\n- Temperature: %d°C\n- Humidity: %d%%", city, condition, temperature, humidity); + + emitter.emit( + ToolResultBlock.builder() + .name("get_weather") + .metadata(Map.of("progress", "100%")) + .build()); return ToolResultBlock.text(result); } diff --git a/agentscope-examples/agui/src/main/resources/application.yml b/agentscope-examples/agui/src/main/resources/application.yml index 8443b62a7b..3590d8eb05 100644 --- a/agentscope-examples/agui/src/main/resources/application.yml +++ b/agentscope-examples/agui/src/main/resources/application.yml @@ -45,6 +45,7 @@ agentscope: max-thread-sessions: 1000 session-timeout-minutes: 30 enable-reasoning: false + enable-acting-chunk: false # Logging logging: 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 index 0aabcebbeb..d46605ffb0 100644 --- 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 @@ -84,7 +84,7 @@ public void convert(ToolResultBlock block, Event event, StreamContext ctx) { * @param toolResult The tool result block * @return The text content, or null if not present */ - private String extractToolResultText(ToolResultBlock toolResult) { + protected String extractToolResultText(ToolResultBlock toolResult) { if (toolResult.getOutput() == null || toolResult.getOutput().isEmpty()) { return null; } From 9e03c96b6c2a7e2d4621a31374909708fa539dd7 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Wed, 20 May 2026 22:33:50 +0800 Subject: [PATCH 18/20] docs: update the ag-ui sample project and docs --- .../integration/agui/README.md | 89 +++++++++++++++++++ .../agui/config/AgentConfiguration.java | 2 +- .../agui/src/main/resources/static/index.html | 12 +++ .../main/resources/static/js/agui-client.js | 4 + docs/en/intro.md | 4 +- docs/zh/intro.md | 4 +- docs/zh/task/agui.md | 3 +- 7 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 agentscope-examples/integration/agui/README.md diff --git a/agentscope-examples/integration/agui/README.md b/agentscope-examples/integration/agui/README.md new file mode 100644 index 0000000000..483dc149f6 --- /dev/null +++ b/agentscope-examples/integration/agui/README.md @@ -0,0 +1,89 @@ +# Quick Start + +本案例展示了如何基于 Spring Boot 构建支持 [AgentScope](https://github.com/agentscope-ai/agentscope-java) AG-UI 协议的 Web 服务。通过实现标准的交互协议,本项目提供了包括多智能体路由、智能体推理展示(需在 yml 中开启)和工具调用进度反馈(需在 yml 中开启)在内的前后端交互体验。 + +## 环境要求 + +* JDK 17 及以上 +* Maven 3.8+ +* 有效的 DashScope API Key(本项目默认使用 `qwen-plus` 模型) + +## 示例运行 + +### 1. AG-UI 服务端参数配置 + +项目的核心配置位于 `application.yml` 中: + +```yaml +agentscope: + agui: + path-prefix: /agui # AG-UI 接口统一前缀 + cors-enabled: true # 开启跨域支持 + default-agent-id: default # 默认处理请求的 Agent ID + agent-id-header: X-Agent-Id # 通过 HTTP Header 进行 Agent 路由 + enable-path-routing: true # 允许通过 URL 路径进行 Agent 路由 + server-side-memory: true # 是否按 threadId 在后端管理会话内存 + session-timeout-minutes: 30 # 会话超时时间 + enable-reasoning: false # 是否启用推理/思考内容输出(模型也需要支持并开启) + emit-tool-call-args: true # 是否发出工具调用参数事件 + enable-acting-chunk: false # 是否发出工具调用进度信息 +``` + +### 2. 运行应用 + +本项目基于 Spring Boot,使用以下 Maven 命令启动应用: + +```bash +mvn spring-boot:run +``` + +### 3. 开始体验 + +应用启动后,将在本机的 `8080` 端口提供服务。本项目提供了两种体验方式: + +#### 方式一:可视化 Web 界面 + +直接在浏览器中打开内置的交互式页面: +**http://localhost:8080** +该页面原生对接了 AG-UI 协议,支持实时显示流式打字效果、工具调用状态以及内部推理过程。 + +#### 方式二:API / cURL 调用体验多智能体路由 + +基于 `AgentConfiguration.java` 的注册逻辑,本项目实现了三种不同的 Agent。你可以通过不同的调用策略来体验路由机制: + +**1. 访问默认 Agent(带工具,支持天气和计算):** + +```shell +curl -N -X POST http://localhost:8080/agui/run \ + -H "Content-Type: application/json" \ + -d '{"threadId":"test-1","runId":"1","messages":[{"id":"m1","role":"user","content":"北京今天天气如何?"}]}' +``` + +**2. 通过 URL 路径路由访问纯聊天 Agent(`chat`):** + +```shell +curl -N -X POST http://localhost:8080/agui/run/chat \ + -H "Content-Type: application/json" \ + -d '{"threadId":"test-2","runId":"1","messages":[{"id":"m1","role":"user","content":"你好,我们来聊聊天吧。"}]}' +``` + +**3. 通过 HTTP Header 路由访问计算器 Agent(`calculator`):** + +```shell +curl -N -X POST http://localhost:8080/agui/run \ + -H "Content-Type: application/json" \ + -H "X-Agent-Id: calculator" \ + -d '{"threadId":"test-3","runId":"1","messages":[{"id":"m1","role":"user","content":"计算 12.5 乘以 4"}]}' +``` + +--- + +## 核心代码架构说明 + +为便于二次开发,可重点关注以下核心类: + +* **`AgentConfiguration.java`**:演示了如何通过 `AguiAgentRegistryCustomizer` 注册多个独立 Agent 工厂(Factory 模式),确保并发场景下每个请求获取纯净的 Agent 实例。 +* **`CustomToolResultBlockConverter.java`**:高阶特性演示。通过继承并重写框架底层的 Event 转换逻辑(基于 @Component 注册生效),允许将带有进度 Metadata 的工具(如 get_weather)封装为 AguiEvent.ToolCallResult 事件,从而向前端实时推送工具执行的中间状态。(注:需在 application.yml 中开启 enable-acting-chunk: true) +* **`ExampleTools.java`**:Agent 可用的工具集合。展示了 `ToolEmitter` 的高级用法,实现了执行过程的多阶段状态通知。 +* **`index.html`**:实现了一个完整的 AG-UI 客户端界面,处理流式文本 (`onTextContent`)、推理过程 (`onReasoningContent`) 以及工具调用 (`onToolCallStart`) 的事件分发。 + diff --git a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java b/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java index dae080f07e..737c77b605 100644 --- a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java +++ b/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java @@ -92,7 +92,7 @@ private Agent createDefaultAgent() { .model( DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-plus").stream( true) - .enableThinking(false) + .enableThinking(true) .formatter(new DashScopeChatFormatter()) .build()) .toolkit(toolkit) diff --git a/agentscope-examples/integration/agui/src/main/resources/static/index.html b/agentscope-examples/integration/agui/src/main/resources/static/index.html index 97c72c4257..dbd32b0066 100644 --- a/agentscope-examples/integration/agui/src/main/resources/static/index.html +++ b/agentscope-examples/integration/agui/src/main/resources/static/index.html @@ -472,6 +472,18 @@

    AgentScope AG-UI Demo

    onToolCallEnd: (toolCallId) => { // Tool call completed }, + onToolCallResult: (toolCallId, content) => { + try { + const resultObj = JSON.parse(content); + if (resultObj && resultObj.progress) { + appendMessage('tool', `⏳ 进度: ${resultObj.progress}`); + } else { + appendMessage('tool', `📄 执行结果: ${content}`); + } + } catch (e) { + appendMessage('tool', `✅ 返回结果: ${content}`); + } + }, onError: (error) => { hideTypingIndicator(); appendMessage('error', `Error: ${error}`); diff --git a/agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js b/agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js index 58dc260d50..fe6c0f443b 100644 --- a/agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js +++ b/agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js @@ -252,6 +252,10 @@ class AguiClient { callbacks.onToolCallEnd?.(event.toolCallId); break; + case 'TOOL_CALL_RESULT': + callbacks.onToolCallResult?.(event.toolCallId, event.content); + break; + case 'STATE_SNAPSHOT': callbacks.onStateSnapshot?.(event.snapshot); break; diff --git a/docs/en/intro.md b/docs/en/intro.md index 6a1e05ee68..becdfd6757 100644 --- a/docs/en/intro.md +++ b/docs/en/intro.md @@ -11,7 +11,7 @@ hide-toc: true

    Harness framework for distributed, enterprise-grade agents.

    -

    AgentScope Java is the open-source agent framework for the JVM. ReAct reasoning, Harness engineering infrastructure, multi-agent orchestration, and MCP/A2A protocol support — from local prototype to enterprise-scale deployment.

    +

    AgentScope Java is the open-source agent framework for the JVM. ReAct reasoning, Harness engineering infrastructure, multi-agent orchestration, and MCP/A2A/AG-UI protocol support — from local prototype to enterprise-scale deployment.

    - MCP · A2A + MCP · A2A · AG-UI open protocol support
    diff --git a/docs/zh/intro.md b/docs/zh/intro.md index 7fd90e43c5..44814bf1de 100644 --- a/docs/zh/intro.md +++ b/docs/zh/intro.md @@ -13,7 +13,7 @@ hide-toc: true

    专为分布式、企业级智能体
    打造的 Harness 框架。

    -

    AgentScope Java 是面向 JVM 的开源 Agent 框架。提供 ReAct 推理、Harness 工程化基础设施、多智能体编排与 MCP/A2A 协议支持,覆盖从本地原型到企业级分布式部署全链路。

    +

    AgentScope Java 是面向 JVM 的开源 Agent 框架。提供 ReAct 推理、Harness 工程化基础设施、多智能体编排与 MCP/A2A/AG-UI 协议支持,覆盖从本地原型到企业级分布式部署全链路。

    - MCP · A2A + MCP · A2A · AG-UI 开放协议支持
    diff --git a/docs/zh/task/agui.md b/docs/zh/task/agui.md index 2b0b05843a..0151b6393a 100644 --- a/docs/zh/task/agui.md +++ b/docs/zh/task/agui.md @@ -149,7 +149,8 @@ function App() { ## 示例项目 -完整示例见 [agentscope-examples/integration/agui](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/integration/agui): +完整示例见 [agentscope-examples/integration/agui](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/integration/agui) +示例文档见 [docs](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/integration/agui/README.md) ```bash export DASHSCOPE_API_KEY=your-key From 8b0894c8b5af309731f77a5edaa40d10e4dfe2c4 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 6 Jun 2026 22:15:59 +0800 Subject: [PATCH 19/20] fix: resolve conflicts --- .../integration/agui/README.md | 89 --- agentscope-examples/integration/agui/pom.xml | 77 --- .../examples/agui/AguiExampleApplication.java | 55 -- .../agui/config/AgentConfiguration.java | 164 ------ .../CustomToolResultBlockConverter.java | 109 ---- .../examples/agui/tools/ExampleTools.java | 194 ------ .../agui/src/main/resources/application.yml | 55 -- .../agui/src/main/resources/logback.xml | 31 - .../agui/src/main/resources/static/index.html | 554 ------------------ .../main/resources/static/js/agui-client.js | 288 --------- agentscope-examples/pom.xml | 1 - docs/en/intro.md | 4 +- docs/zh/intro.md | 4 +- docs/zh/task/agui.md | 3 +- 14 files changed, 5 insertions(+), 1623 deletions(-) delete mode 100644 agentscope-examples/integration/agui/README.md delete mode 100644 agentscope-examples/integration/agui/pom.xml delete mode 100644 agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/AguiExampleApplication.java delete mode 100644 agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java delete mode 100644 agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java delete mode 100644 agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java delete mode 100644 agentscope-examples/integration/agui/src/main/resources/application.yml delete mode 100644 agentscope-examples/integration/agui/src/main/resources/logback.xml delete mode 100644 agentscope-examples/integration/agui/src/main/resources/static/index.html delete mode 100644 agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js diff --git a/agentscope-examples/integration/agui/README.md b/agentscope-examples/integration/agui/README.md deleted file mode 100644 index 483dc149f6..0000000000 --- a/agentscope-examples/integration/agui/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Quick Start - -本案例展示了如何基于 Spring Boot 构建支持 [AgentScope](https://github.com/agentscope-ai/agentscope-java) AG-UI 协议的 Web 服务。通过实现标准的交互协议,本项目提供了包括多智能体路由、智能体推理展示(需在 yml 中开启)和工具调用进度反馈(需在 yml 中开启)在内的前后端交互体验。 - -## 环境要求 - -* JDK 17 及以上 -* Maven 3.8+ -* 有效的 DashScope API Key(本项目默认使用 `qwen-plus` 模型) - -## 示例运行 - -### 1. AG-UI 服务端参数配置 - -项目的核心配置位于 `application.yml` 中: - -```yaml -agentscope: - agui: - path-prefix: /agui # AG-UI 接口统一前缀 - cors-enabled: true # 开启跨域支持 - default-agent-id: default # 默认处理请求的 Agent ID - agent-id-header: X-Agent-Id # 通过 HTTP Header 进行 Agent 路由 - enable-path-routing: true # 允许通过 URL 路径进行 Agent 路由 - server-side-memory: true # 是否按 threadId 在后端管理会话内存 - session-timeout-minutes: 30 # 会话超时时间 - enable-reasoning: false # 是否启用推理/思考内容输出(模型也需要支持并开启) - emit-tool-call-args: true # 是否发出工具调用参数事件 - enable-acting-chunk: false # 是否发出工具调用进度信息 -``` - -### 2. 运行应用 - -本项目基于 Spring Boot,使用以下 Maven 命令启动应用: - -```bash -mvn spring-boot:run -``` - -### 3. 开始体验 - -应用启动后,将在本机的 `8080` 端口提供服务。本项目提供了两种体验方式: - -#### 方式一:可视化 Web 界面 - -直接在浏览器中打开内置的交互式页面: -**http://localhost:8080** -该页面原生对接了 AG-UI 协议,支持实时显示流式打字效果、工具调用状态以及内部推理过程。 - -#### 方式二:API / cURL 调用体验多智能体路由 - -基于 `AgentConfiguration.java` 的注册逻辑,本项目实现了三种不同的 Agent。你可以通过不同的调用策略来体验路由机制: - -**1. 访问默认 Agent(带工具,支持天气和计算):** - -```shell -curl -N -X POST http://localhost:8080/agui/run \ - -H "Content-Type: application/json" \ - -d '{"threadId":"test-1","runId":"1","messages":[{"id":"m1","role":"user","content":"北京今天天气如何?"}]}' -``` - -**2. 通过 URL 路径路由访问纯聊天 Agent(`chat`):** - -```shell -curl -N -X POST http://localhost:8080/agui/run/chat \ - -H "Content-Type: application/json" \ - -d '{"threadId":"test-2","runId":"1","messages":[{"id":"m1","role":"user","content":"你好,我们来聊聊天吧。"}]}' -``` - -**3. 通过 HTTP Header 路由访问计算器 Agent(`calculator`):** - -```shell -curl -N -X POST http://localhost:8080/agui/run \ - -H "Content-Type: application/json" \ - -H "X-Agent-Id: calculator" \ - -d '{"threadId":"test-3","runId":"1","messages":[{"id":"m1","role":"user","content":"计算 12.5 乘以 4"}]}' -``` - ---- - -## 核心代码架构说明 - -为便于二次开发,可重点关注以下核心类: - -* **`AgentConfiguration.java`**:演示了如何通过 `AguiAgentRegistryCustomizer` 注册多个独立 Agent 工厂(Factory 模式),确保并发场景下每个请求获取纯净的 Agent 实例。 -* **`CustomToolResultBlockConverter.java`**:高阶特性演示。通过继承并重写框架底层的 Event 转换逻辑(基于 @Component 注册生效),允许将带有进度 Metadata 的工具(如 get_weather)封装为 AguiEvent.ToolCallResult 事件,从而向前端实时推送工具执行的中间状态。(注:需在 application.yml 中开启 enable-acting-chunk: true) -* **`ExampleTools.java`**:Agent 可用的工具集合。展示了 `ToolEmitter` 的高级用法,实现了执行过程的多阶段状态通知。 -* **`index.html`**:实现了一个完整的 AG-UI 客户端界面,处理流式文本 (`onTextContent`)、推理过程 (`onReasoningContent`) 以及工具调用 (`onToolCallStart`) 的事件分发。 - diff --git a/agentscope-examples/integration/agui/pom.xml b/agentscope-examples/integration/agui/pom.xml deleted file mode 100644 index 7a426a9797..0000000000 --- a/agentscope-examples/integration/agui/pom.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - 4.0.0 - - - io.agentscope - agentscope-examples - ${revision} - ../../pom.xml - - - io.agentscope.examples - agui - jar - - AgentScope Examples - AG-UI - AG-UI Protocol example with frontend integration - - - - - - org.springframework.boot - spring-boot-dependencies - ${spring.boot.version} - pom - import - - - - - - - - io.agentscope - agentscope-core - - - - - io.agentscope - agentscope-extensions-agui - - - - - io.agentscope - agentscope-agui-spring-boot-starter - - - - - org.springframework.boot - spring-boot-starter-webflux - - - - - diff --git a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/AguiExampleApplication.java b/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/AguiExampleApplication.java deleted file mode 100644 index e44def4f79..0000000000 --- a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/AguiExampleApplication.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.examples.agui; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -/** - * AG-UI Example Application. - * - *

    This application demonstrates how to expose AgentScope agents via the AG-UI protocol - * using Spring WebFlux. - * - *

    Usage: - *

      - *
    1. Set the DASHSCOPE_API_KEY environment variable
    2. - *
    3. Run this application
    4. - *
    5. Open http://localhost:8080 in a browser
    6. - *
    7. Or use curl: curl -X POST http://localhost:8080/agui/run -H "Content-Type: application/json" -d '{"threadId":"test","runId":"1","messages":[{"id":"m1","role":"user","content":"Hello!"}]}'
    8. - *
    - */ -@SpringBootApplication -public class AguiExampleApplication { - - public static void main(String[] args) { - SpringApplication.run(AguiExampleApplication.class, args); - printStartupInfo(); - } - - private static void printStartupInfo() { - System.out.println("\n=== AG-UI Example Application Started ==="); - System.out.println("Open: http://localhost:8080"); - System.out.println("API: POST http://localhost:8080/agui/run"); - System.out.println("\nExample curl command:"); - System.out.println(" curl -N -X POST http://localhost:8080/agui/run \\"); - System.out.println(" -H \"Content-Type: application/json\" \\"); - System.out.println( - " -d" - + " '{\"threadId\":\"test\",\"runId\":\"1\",\"messages\":[{\"id\":\"m1\",\"role\":\"user\",\"content\":\"Hello!\"}]}'"); - System.out.println("\nPress Ctrl+C to stop."); - } -} diff --git a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java b/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java deleted file mode 100644 index 737c77b605..0000000000 --- a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.examples.agui.config; - -import io.agentscope.core.ReActAgent; -import io.agentscope.core.agent.Agent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.memory.InMemoryMemory; -import io.agentscope.core.model.DashScopeChatModel; -import io.agentscope.core.tool.Toolkit; -import io.agentscope.examples.agui.tools.ExampleTools; -import io.agentscope.spring.boot.agui.common.AguiAgentRegistryCustomizer; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Configuration class that registers agents with the AG-UI registry. - * - *

    This example demonstrates how to register multiple agents with different IDs. - * Clients can select which agent to use via: - *

      - *
    • URL path variable: {@code POST /agui/run/{agentId}}
    • - *
    • HTTP header: {@code X-Agent-Id: agentId}
    • - *
    • Request body: {@code forwardedProps.agentId}
    • - *
    - */ -@Configuration -public class AgentConfiguration { - - @Bean - public AguiAgentRegistryCustomizer aguiAgentRegistryCustomizer() { - AguiAgentRegistryCustomizer aguiAgentRegistryCustomizer = - registry -> { - // Register a factory for the default agent - // Using a factory ensures each request gets a fresh agent instance - registry.registerFactory("default", this::createDefaultAgent); - - // Register additional agents with different IDs - // Example: a simple chat agent without tools - registry.registerFactory("chat", this::createChatAgent); - - // Example: an agent specialized for calculations - registry.registerFactory("calculator", this::createCalculatorAgent); - }; - - System.out.println("Registered agents with AG-UI registry: default, chat, calculator"); - System.out.println("Access agents via:"); - System.out.println(" - POST /agui/run (uses default-agent-id from config)"); - System.out.println(" - POST /agui/run/chat (uses 'chat' agent)"); - System.out.println(" - POST /agui/run with X-Agent-Id header"); - - return aguiAgentRegistryCustomizer; - } - - /** - * Create the default agent instance. - * - *

    This agent is configured with: - *

      - *
    • DashScope qwen-plus model with streaming enabled
    • - *
    • Example tools (get_weather, calculate)
    • - *
    • In-memory conversation memory
    • - *
    - */ - private Agent createDefaultAgent() { - String apiKey = getRequiredApiKey(); - - // Create toolkit with example tools - Toolkit toolkit = new Toolkit(); - toolkit.registerTool(new ExampleTools()); - - // Create the agent - return ReActAgent.builder() - .name("AG-UI Assistant") - .sysPrompt( - "You are a helpful AI assistant exposed via the AG-UI protocol. " - + "You can help users with various tasks including weather queries " - + "and calculations. Be concise and helpful in your responses.") - .model( - DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-plus").stream( - true) - .enableThinking(true) - .formatter(new DashScopeChatFormatter()) - .build()) - .toolkit(toolkit) - .memory(new InMemoryMemory()) - .maxIters(10) - .build(); - } - - /** - * Create a simple chat agent without tools. - * - *

    This agent is a pure conversational assistant. - */ - private Agent createChatAgent() { - String apiKey = getRequiredApiKey(); - - return ReActAgent.builder() - .name("Chat Assistant") - .sysPrompt( - "You are a friendly conversational assistant. " - + "Engage in natural conversation and help users " - + "with general questions and discussions.") - .model( - DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-plus").stream( - true) - .formatter(new DashScopeChatFormatter()) - .build()) - .memory(new InMemoryMemory()) - .maxIters(1) - .build(); - } - - /** - * Create a calculator agent specialized for mathematical operations. - */ - private Agent createCalculatorAgent() { - String apiKey = getRequiredApiKey(); - - // Create toolkit with only calculation tools - Toolkit toolkit = new Toolkit(); - toolkit.registerTool(new ExampleTools()); - - return ReActAgent.builder() - .name("Calculator Agent") - .sysPrompt( - "You are a mathematical assistant specialized in calculations. " - + "Use the calculate tool to perform mathematical operations. " - + "Always show your work and explain the results.") - .model( - DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-plus").stream( - true) - .formatter(new DashScopeChatFormatter()) - .build()) - .toolkit(toolkit) - .memory(new InMemoryMemory()) - .maxIters(5) - .build(); - } - - private String getRequiredApiKey() { - String apiKey = System.getenv("DASHSCOPE_API_KEY"); - if (apiKey == null || apiKey.isEmpty()) { - throw new IllegalStateException( - "DASHSCOPE_API_KEY environment variable is required. " - + "Please set it before starting the application."); - } - return apiKey; - } -} diff --git a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java b/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java deleted file mode 100644 index 7d803f8368..0000000000 --- a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/config/CustomToolResultBlockConverter.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.examples.agui.config; - -import io.agentscope.core.agent.Event; -import io.agentscope.core.agent.EventType; -import io.agentscope.core.agui.adapter.StreamContext; -import io.agentscope.core.agui.adapter.strategy.ToolResultBlockConverter; -import io.agentscope.core.agui.event.AguiEvent; -import io.agentscope.core.message.ToolResultBlock; -import io.agentscope.core.util.JsonUtils; -import java.util.UUID; -import org.springframework.stereotype.Component; - -/** - * Custom converter that overrides the framework's default ToolResultBlockConverter - * to emit ag-ui tool progress events. - * - *

    Before overriding any default BlockEventConverter, please ensure you have a - * thorough understanding of the default implementation. - * - *

    IMPORTANT: In production environments, please modify with caution other than tool - * progress events. Otherwise, you assume all consequences and risks. - * - *

    This is currently provided as a demonstration version only. Please modify it - * according to your own requirements. - * - *

    Feel free to submit an issue on GitHub if you encounter any problems. - */ -@Component -public class CustomToolResultBlockConverter extends ToolResultBlockConverter { - - @Override - public boolean isApplicable(Event event) { - // return event.getType() == EventType.TOOL_RESULT && event.isLast(); - // The check for event.isLast() needs to be removed, otherwise only the final result event - // will be emitted - return event.getType() == EventType.TOOL_RESULT; - } - - @Override - public void convert(ToolResultBlock block, Event event, StreamContext ctx) { - // Extract the tool invocation progress from metadata and package it into a ToolCallResult - // event - // This is currently provided as a demonstration version only. - // Please modify it according to your own requirements. - if (block.getMetadata() != null && block.getMetadata().size() != 0) { - ctx.emit( - new AguiEvent.ToolCallResult( - ctx.getThreadId(), - ctx.getRunId(), - // TODO: Currently using tool name to replace tool call id - block.getName(), - JsonUtils.getJsonCodec().toJson(block.getMetadata()), - "tool", - event.getMessage().getId())); - return; - } - - String toolCallId = - block.getId() != null && !block.getId().isBlank() - ? block.getId() - : UUID.randomUUID().toString(); - - String result = super.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); - } - } -} diff --git a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java b/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java deleted file mode 100644 index 2bfab6ce75..0000000000 --- a/agentscope-examples/integration/agui/src/main/java/io/agentscope/examples/agui/tools/ExampleTools.java +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.agentscope.examples.agui.tools; - -import io.agentscope.core.message.ToolResultBlock; -import io.agentscope.core.tool.Tool; -import io.agentscope.core.tool.ToolEmitter; -import io.agentscope.core.tool.ToolParam; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Map; -import java.util.Random; - -/** - * Example tools for the AG-UI demo. - * - *

    These tools demonstrate how to create tool functions that can be called - * by the agent during conversation. - */ -public class ExampleTools { - - private final Random random = new Random(); - - /** - * Get weather information for a city. - * - *

    Note: This is a mock implementation for demo purposes. - * - * @param city The city name to get weather for - * @return Weather information - */ - @Tool(name = "get_weather", description = "Get current weather information for a city") - public ToolResultBlock getWeather( - @ToolParam(name = "city", description = "The city name (e.g., 'Beijing', 'New York')") - String city, - ToolEmitter emitter) { - // Mock weather data - String[] conditions = {"Sunny", "Cloudy", "Partly Cloudy", "Rainy", "Overcast"}; - String condition = conditions[random.nextInt(conditions.length)]; - int temperature = random.nextInt(35) + 5; // 5-40 degrees - int humidity = random.nextInt(60) + 30; // 30-90% - - // Pass progress information in metadata - // To take effect, it is necessary to customize the ToolResultBlockConverter to override the - // framework default - emitter.emit( - ToolResultBlock.builder() - .name("get_weather") - .metadata(Map.of("progress", "50%")) - .build()); - - String result = - String.format( - "Weather in %s:\n- Condition: %s\n- Temperature: %d°C\n- Humidity: %d%%", - city, condition, temperature, humidity); - - emitter.emit( - ToolResultBlock.builder() - .name("get_weather") - .metadata(Map.of("progress", "100%")) - .build()); - return ToolResultBlock.text(result); - } - - /** - * Perform a simple calculation. - * - * @param expression The math expression to evaluate - * @return The calculation result - */ - @Tool( - name = "calculate", - description = "Perform a simple arithmetic calculation (supports +, -, *, /)") - public ToolResultBlock calculate( - @ToolParam( - name = "expression", - description = - "The arithmetic expression to calculate (e.g., '2 + 3 * 4')") - String expression) { - try { - // Simple expression evaluator (for demo purposes) - double result = evaluateExpression(expression); - return ToolResultBlock.text("Result: " + formatNumber(result)); - } catch (Exception e) { - return ToolResultBlock.error("Failed to calculate: " + e.getMessage()); - } - } - - /** - * Get the current date and time. - * - * @return Current date and time - */ - @Tool(name = "get_current_time", description = "Get the current date and time") - public ToolResultBlock getCurrentTime() { - LocalDateTime now = LocalDateTime.now(); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - return ToolResultBlock.text("Current time: " + now.format(formatter)); - } - - /** - * Simple expression evaluator. - * Supports basic arithmetic: +, -, *, / - */ - private double evaluateExpression(String expression) { - // Remove whitespace - expression = expression.replaceAll("\\s+", ""); - - // Handle addition and subtraction (lowest precedence) - int lastPlusOrMinus = -1; - int parenDepth = 0; - for (int i = expression.length() - 1; i >= 0; i--) { - char c = expression.charAt(i); - if (c == ')') parenDepth++; - else if (c == '(') parenDepth--; - else if (parenDepth == 0 && (c == '+' || c == '-') && i > 0) { - lastPlusOrMinus = i; - break; - } - } - - if (lastPlusOrMinus > 0) { - String left = expression.substring(0, lastPlusOrMinus); - String right = expression.substring(lastPlusOrMinus + 1); - char op = expression.charAt(lastPlusOrMinus); - if (op == '+') { - return evaluateExpression(left) + evaluateExpression(right); - } else { - return evaluateExpression(left) - evaluateExpression(right); - } - } - - // Handle multiplication and division - int lastMultOrDiv = -1; - parenDepth = 0; - for (int i = expression.length() - 1; i >= 0; i--) { - char c = expression.charAt(i); - if (c == ')') parenDepth++; - else if (c == '(') parenDepth--; - else if (parenDepth == 0 && (c == '*' || c == '/')) { - lastMultOrDiv = i; - break; - } - } - - if (lastMultOrDiv > 0) { - String left = expression.substring(0, lastMultOrDiv); - String right = expression.substring(lastMultOrDiv + 1); - char op = expression.charAt(lastMultOrDiv); - if (op == '*') { - return evaluateExpression(left) * evaluateExpression(right); - } else { - return evaluateExpression(left) / evaluateExpression(right); - } - } - - // Handle parentheses - if (expression.startsWith("(") && expression.endsWith(")")) { - return evaluateExpression(expression.substring(1, expression.length() - 1)); - } - - // Parse number - return parseNumber(expression); - } - - private double parseNumber(String expression) { - try { - return Double.parseDouble(expression); - } catch (NumberFormatException e) { - throw new IllegalArgumentException( - "Invalid numeric value in expression: '" + expression + "'", e); - } - } - - private String formatNumber(double value) { - if (value == (long) value) { - return String.valueOf((long) value); - } - return String.format("%.4f", value); - } -} diff --git a/agentscope-examples/integration/agui/src/main/resources/application.yml b/agentscope-examples/integration/agui/src/main/resources/application.yml deleted file mode 100644 index 3590d8eb05..0000000000 --- a/agentscope-examples/integration/agui/src/main/resources/application.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2024-2026 the original author or authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# AG-UI Example Application Configuration - -server: - port: 8080 - -spring: - main: - banner-mode: off - -# AG-UI Configuration -agentscope: - agui: - path-prefix: /agui - cors-enabled: true - cors-allowed-origins: - - "*" - run-timeout: 10m - emit-state-events: true - emit-tool-call-args: true - default-agent-id: default - # Agent ID routing configuration - # Agent ID can be passed via: - # 1. URL path variable: POST /agui/run/{agentId} (highest priority) - # 2. HTTP header: X-Agent-Id (configurable) - # 3. Request body: forwardedProps.agentId - # 4. Default: uses default-agent-id - agent-id-header: X-Agent-Id - enable-path-routing: true - # Server-side memory management - server-side-memory: true - max-thread-sessions: 1000 - session-timeout-minutes: 30 - enable-reasoning: false - enable-acting-chunk: false - -# Logging -logging: - level: - root: INFO - io.agentscope: DEBUG - diff --git a/agentscope-examples/integration/agui/src/main/resources/logback.xml b/agentscope-examples/integration/agui/src/main/resources/logback.xml deleted file mode 100644 index 4146594095..0000000000 --- a/agentscope-examples/integration/agui/src/main/resources/logback.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - diff --git a/agentscope-examples/integration/agui/src/main/resources/static/index.html b/agentscope-examples/integration/agui/src/main/resources/static/index.html deleted file mode 100644 index dbd32b0066..0000000000 --- a/agentscope-examples/integration/agui/src/main/resources/static/index.html +++ /dev/null @@ -1,554 +0,0 @@ - - - - - - - - AgentScope AG-UI Demo - - - -

    -
    -

    AgentScope AG-UI Demo

    -

    Chat with an AI agent via the AG-UI protocol

    -
    - -
    -
    - Ready -
    - -
    - -
    - - - -
    -
    - -
    - - - - - - diff --git a/agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js b/agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js deleted file mode 100644 index fe6c0f443b..0000000000 --- a/agentscope-examples/integration/agui/src/main/resources/static/js/agui-client.js +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * AG-UI Protocol Client - * - * A minimal JavaScript client for communicating with AG-UI protocol servers. - * - * @example - * const client = new AguiClient('/agui/run'); - * await client.run({ - * threadId: 'thread-123', - * runId: 'run-456', - * messages: [{ id: 'msg-1', role: 'user', content: 'Hello!' }] - * }, { - * onTextContent: (delta) => console.log(delta), - * onReasoningContent: (delta) => console.log('Reasoning:', delta), - * onRunFinished: () => console.log('Done') - * }); - */ -class AguiClient { - /** - * Create a new AG-UI client. - * @param {string} endpoint - The AG-UI run endpoint URL - */ - constructor(endpoint) { - this.endpoint = endpoint; - this.abortController = null; - } - - /** - * Abort the current run if one is in progress. - * This will close the SSE connection and trigger agent interruption on the backend. - */ - abort() { - if (this.abortController) { - console.log('Aborting current run...'); - this.abortController.abort(); - this.abortController = null; - } - } - - /** - * Check if a run is currently in progress. - * @returns {boolean} True if running - */ - isRunning() { - return this.abortController !== null; - } - - /** - * Run an agent with the given input. - * @param {Object} input - The run input - * @param {string} input.threadId - Thread identifier - * @param {string} input.runId - Run identifier - * @param {Array} input.messages - Array of messages - * @param {Array} [input.tools] - Optional tools - * @param {Array} [input.context] - Optional context - * @param {Object} [input.state] - Optional state - * @param {Object} [input.forwardedProps] - Optional forwarded properties - * @param {Object} callbacks - Event callbacks - * @param {Function} [callbacks.onReasoningMessageStart] - Called when reasoning message starts - * @param {Function} [callbacks.onReasoningContent] - Called with reasoning content delta - * @param {Function} [callbacks.onReasoningMessageEnd] - Called when reasoning message ends - * @returns {Promise} Resolves when the run completes - */ - async run(input, callbacks = {}) { - // Create abort controller for this run - this.abortController = new AbortController(); - const signal = this.abortController.signal; - - const response = await fetch(this.endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream' - }, - body: JSON.stringify(input), - signal: signal - }); - - if (!response.ok) { - this.abortController = null; - throw new Error(`HTTP error: ${response.status} ${response.statusText}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - console.log('Starting to read SSE stream...'); - let eventSequence = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - - if (done) { - console.log('Stream ended, remaining buffer:', buffer); - break; - } - - const chunk = decoder.decode(value, { stream: true }); - console.log('Received chunk:', chunk.length, 'bytes'); - buffer += chunk; - - // Try both \n\n and \r\n\r\n as delimiters - let delimiter = '\n\n'; - let delimiterIndex = buffer.indexOf(delimiter); - - // If \n\n not found, try \r\n\r\n (Windows-style) - if (delimiterIndex === -1) { - delimiter = '\r\n\r\n'; - delimiterIndex = buffer.indexOf(delimiter); - } - - // Process complete SSE messages - while (delimiterIndex !== -1) { - const message = buffer.substring(0, delimiterIndex); - buffer = buffer.substring(delimiterIndex + delimiter.length); - - console.log('Processing SSE message:', message.substring(0, 100)); - - // Process each line in the message - const lines = message.split(/\r?\n/); - for (const line of lines) { - if (line.startsWith('data:')) { - try { - // Handle both "data: " and "data:" formats - const jsonStr = line.startsWith('data: ') ? line.substring(6) : line.substring(5); - const event = JSON.parse(jsonStr); - eventSequence++; - console.log(`[${eventSequence}] Received event:`, event.type, event); - this.handleEvent(event, callbacks); - } catch (e) { - console.warn('Failed to parse event:', line, e); - } - } - } - - // Check for next delimiter - delimiterIndex = buffer.indexOf('\n\n'); - if (delimiterIndex === -1) { - delimiterIndex = buffer.indexOf('\r\n\r\n'); - if (delimiterIndex !== -1) delimiter = '\r\n\r\n'; - } else { - delimiter = '\n\n'; - } - } - } - - // Process any remaining data in buffer - if (buffer.trim()) { - console.log('Processing remaining buffer:', buffer); - const lines = buffer.split(/\r?\n/); - for (const line of lines) { - if (line.startsWith('data:')) { - try { - const jsonStr = line.startsWith('data: ') ? line.substring(6) : line.substring(5); - const event = JSON.parse(jsonStr); - console.log('Received final event:', event.type, event); - this.handleEvent(event, callbacks); - } catch (e) { - console.warn('Failed to parse remaining event:', line, e); - } - } - } - } - } finally { - reader.releaseLock(); - this.abortController = null; - } - } - - /** - * Handle an AG-UI event. - * @param {Object} event - The event object - * @param {Object} callbacks - Event callbacks - */ - handleEvent(event, callbacks) { - if (!event || !event.type) { - console.warn('Invalid event received:', event); - return; - } - - const type = event.type; - - try { - switch (type) { - case 'RUN_STARTED': - callbacks.onRunStarted?.(event.threadId, event.runId); - break; - - case 'RUN_FINISHED': - callbacks.onRunFinished?.(event.threadId, event.runId); - break; - - case 'TEXT_MESSAGE_START': - callbacks.onTextMessageStart?.(event.messageId, event.role); - break; - - case 'TEXT_MESSAGE_CONTENT': - // Ensure delta is not null/undefined - const delta = event.delta || ''; - if (delta) { - callbacks.onTextContent?.(delta, event.messageId); - } - break; - - case 'TEXT_MESSAGE_END': - callbacks.onTextMessageEnd?.(event.messageId); - break; - - case 'REASONING_MESSAGE_START': - callbacks.onReasoningMessageStart?.(event.messageId, event.role); - break; - - case 'REASONING_MESSAGE_CONTENT': - // Ensure delta is not null/undefined - const reasoningDelta = event.delta || ''; - if (reasoningDelta) { - callbacks.onReasoningContent?.(reasoningDelta, event.messageId); - } - break; - - case 'REASONING_MESSAGE_END': - callbacks.onReasoningMessageEnd?.(event.messageId); - break; - - case 'TOOL_CALL_START': - callbacks.onToolCallStart?.(event.toolCallId, event.toolCallName); - break; - - case 'TOOL_CALL_ARGS': - callbacks.onToolCallArgs?.(event.toolCallId, event.delta); - break; - - case 'TOOL_CALL_END': - callbacks.onToolCallEnd?.(event.toolCallId); - break; - - case 'TOOL_CALL_RESULT': - callbacks.onToolCallResult?.(event.toolCallId, event.content); - break; - - case 'STATE_SNAPSHOT': - callbacks.onStateSnapshot?.(event.snapshot); - break; - - case 'STATE_DELTA': - callbacks.onStateDelta?.(event.delta); - break; - - case 'RAW': - if (event.rawEvent?.error) { - callbacks.onError?.(event.rawEvent.error); - } else { - callbacks.onRawEvent?.(event.rawEvent); - } - break; - - default: - console.log('Unknown event type:', type, event); - } - } catch (error) { - console.error('Error handling event:', type, error); - } - } -} - -// Export for module systems -if (typeof module !== 'undefined' && module.exports) { - module.exports = { AguiClient }; -} - diff --git a/agentscope-examples/pom.xml b/agentscope-examples/pom.xml index 69fafee5e7..349a7c0861 100644 --- a/agentscope-examples/pom.xml +++ b/agentscope-examples/pom.xml @@ -45,7 +45,6 @@ documentation/graceful-shutdown integration/micronaut integration/quarkus - integration/agui integration/a2a-rocketmq agents/boba-tea-shop agents/harness-examples diff --git a/docs/en/intro.md b/docs/en/intro.md index becdfd6757..6a1e05ee68 100644 --- a/docs/en/intro.md +++ b/docs/en/intro.md @@ -11,7 +11,7 @@ hide-toc: true

    Harness framework for distributed, enterprise-grade agents.

    -

    AgentScope Java is the open-source agent framework for the JVM. ReAct reasoning, Harness engineering infrastructure, multi-agent orchestration, and MCP/A2A/AG-UI protocol support — from local prototype to enterprise-scale deployment.

    +

    AgentScope Java is the open-source agent framework for the JVM. ReAct reasoning, Harness engineering infrastructure, multi-agent orchestration, and MCP/A2A protocol support — from local prototype to enterprise-scale deployment.

    - MCP · A2A · AG-UI + MCP · A2A open protocol support
    diff --git a/docs/zh/intro.md b/docs/zh/intro.md index 44814bf1de..7fd90e43c5 100644 --- a/docs/zh/intro.md +++ b/docs/zh/intro.md @@ -13,7 +13,7 @@ hide-toc: true

    专为分布式、企业级智能体
    打造的 Harness 框架。

    -

    AgentScope Java 是面向 JVM 的开源 Agent 框架。提供 ReAct 推理、Harness 工程化基础设施、多智能体编排与 MCP/A2A/AG-UI 协议支持,覆盖从本地原型到企业级分布式部署全链路。

    +

    AgentScope Java 是面向 JVM 的开源 Agent 框架。提供 ReAct 推理、Harness 工程化基础设施、多智能体编排与 MCP/A2A 协议支持,覆盖从本地原型到企业级分布式部署全链路。

    - MCP · A2A · AG-UI + MCP · A2A 开放协议支持
    diff --git a/docs/zh/task/agui.md b/docs/zh/task/agui.md index 0151b6393a..2b0b05843a 100644 --- a/docs/zh/task/agui.md +++ b/docs/zh/task/agui.md @@ -149,8 +149,7 @@ function App() { ## 示例项目 -完整示例见 [agentscope-examples/integration/agui](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/integration/agui) -示例文档见 [docs](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/integration/agui/README.md) +完整示例见 [agentscope-examples/integration/agui](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/integration/agui): ```bash export DASHSCOPE_API_KEY=your-key From 6f36b226498132f46205a550cc8c646b074d8195 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sun, 7 Jun 2026 17:31:13 +0800 Subject: [PATCH 20/20] fix: add tests and resolve problems --- .../core/agui/adapter/AguiAgentAdapter.java | 27 ++- .../core/agui/adapter/StreamContextTest.java | 227 ++++++++++++++++++ 2 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/StreamContextTest.java 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 ca143ddd39..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 @@ -30,6 +30,7 @@ import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -62,7 +63,7 @@ public class AguiAgentAdapter { private final Agent agent; private final AguiAdapterConfig config; private final AguiMessageConverter messageConverter; - private final Map, BlockEventConverter> converters = new HashMap<>(); + private final Map, BlockEventConverter> converters; /** * Creates a new AguiAgentAdapter and registers all block conversion strategies. @@ -75,23 +76,28 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { this.config = Objects.requireNonNull(config, "config cannot be null"); this.messageConverter = new AguiMessageConverter(); + Map, BlockEventConverter> tempConverters = new HashMap<>(); + // Register default block conversion strategies - registerDefault(new TextBlockConverter()); - registerDefault(new ThinkingBlockConverter()); - registerDefault(new ToolUseBlockConverter()); - registerDefault(new ToolResultBlockConverter()); + 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()) { - converters.putAll(config.getCustomConverters()); + 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(BlockEventConverter converter) { - converters.put(converter.supportedBlockType(), converter); + private void registerDefault( + Map, BlockEventConverter> map, BlockEventConverter converter) { + map.put(converter.supportedBlockType(), converter); } /** @@ -152,6 +158,10 @@ public Flux run(RunAgentInput input) { */ @SuppressWarnings("unchecked") private List processEvent(Event event, StreamContext ctx) { + if (event == null || event.getMessage() == null) { + return ctx.getAndClearEmittedEvents(); + } + // Dispatch each content block to its corresponding converter for (ContentBlock block : event.getMessage().getContent()) { BlockEventConverter converter = @@ -178,6 +188,7 @@ private List processEvent(Event event, StreamContext ctx) { private Flux handleError( String threadId, String runId, StreamContext ctx, Throwable error) { List events = new ArrayList<>(); + events.addAll(ctx.getAndClearEmittedEvents()); events.addAll(ctx.flushAllRemainingDeferred()); String msg = 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()); + } + } +}