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..85660c4b88 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 @@ -32,6 +32,7 @@ public class AguiAdapterConfig { private final boolean enableReasoning; private final Duration runTimeout; private final String defaultAgentId; + private final boolean enableToolProgressStream; private AguiAdapterConfig(Builder builder) { this.toolMergeMode = builder.toolMergeMode; @@ -40,6 +41,7 @@ private AguiAdapterConfig(Builder builder) { this.enableReasoning = builder.enableReasoning; this.runTimeout = builder.runTimeout; this.defaultAgentId = builder.defaultAgentId; + this.enableToolProgressStream = builder.enableToolProgressStream; } /** @@ -100,6 +102,18 @@ public String getDefaultAgentId() { return defaultAgentId; } + /** + * Check if tool execution progress should be streamed. + * + *

When enabled, intermediate tool execution events will be converted into + * reasoning messages (Thinking UI) to provide real-time feedback to the frontend. + * + * @return true if tool progress streaming is enabled + */ + public boolean isEnableToolProgressStream() { + return enableToolProgressStream; + } + /** * Creates a new builder for AguiAdapterConfig. * @@ -129,6 +143,7 @@ public static class Builder { private boolean enableReasoning = false; private Duration runTimeout = Duration.ofMinutes(10); private String defaultAgentId; + private boolean enableToolProgressStream = false; /** * Set the tool merge mode. @@ -200,6 +215,22 @@ public Builder defaultAgentId(String defaultAgentId) { return this; } + /** + * Set whether to enable tool execution progress stream. + * + *

When enabled, intermediate progress chunks generated during tool execution + * (e.g., download progress, code execution stdout) will be converted into + * {@code ReasoningMessage} events. This leverages the frontend's "Thinking" UI + * to display real-time feedback without modifying the AG-UI protocol. + * + * @param enableToolProgressStream true to convert progress events to thinking blocks + * @return This builder + */ + public Builder enableToolProgressStream(boolean enableToolProgressStream) { + this.enableToolProgressStream = enableToolProgressStream; + return this; + } + /** * Build the configuration. * diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java index 2314e72b9d..a3511e6ba6 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 @@ -102,6 +102,7 @@ public Flux run(RunAgentInput input) { StreamOptions.builder() .eventTypes(EventType.ALL) .incremental(true) + .includeActingChunk(config.isEnableToolProgressStream()) .build(); // Track state for event conversion @@ -147,35 +148,23 @@ private List convertEvent(Event event, EventConversionState state) { EventType type = event.getType(); if (type == EventType.REASONING || type == EventType.SUMMARY) { + String messageId = msg.getId(); + // 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)) { + if (!state.hasStartedTextMessage(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); - } + state.startTextMessage(messageId); } + events.add( + new AguiEvent.TextMessageContent( + state.threadId, state.runId, messageId, text)); } } else if (block instanceof ThinkingBlock thinkingBlock) { // Handle thinking blocks - convert to REASONING_* events (only if enabled) @@ -183,8 +172,6 @@ private List convertEvent(Event event, EventConversionState state) { 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( @@ -195,19 +182,9 @@ private List convertEvent(Event event, EventConversionState state) { "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); - } + events.add( + new AguiEvent.ReasoningMessageContent( + state.threadId, state.runId, messageId, thinking)); } } // If reasoning is disabled, ThinkingBlock content is ignored (backward @@ -219,7 +196,7 @@ private List convertEvent(Event event, EventConversionState state) { events.add( new AguiEvent.TextMessageEnd( state.threadId, state.runId, activeMessageId)); - state.endMessage(activeMessageId); + state.endTextMessage(activeMessageId); } // End any active reasoning message before starting tool call @@ -248,7 +225,7 @@ private List convertEvent(Event event, EventConversionState state) { } // Emit tool call args if enabled - if (config.isEmitToolCallArgs() && !event.isLast()) { + if (config.isEmitToolCallArgs()) { String args = toolUse.getContent(); if (args != null && !args.isEmpty()) { events.add( @@ -258,7 +235,26 @@ private List convertEvent(Event event, EventConversionState state) { } } } - } else if (type == EventType.TOOL_RESULT && event.isLast()) { + + // Independent lifecycle check: prevents dropping the final data chunk + // and safely handles empty termination packets. + if (event.isLast()) { + if (state.hasStartedTextMessage(messageId) + && !state.hasEndedTextMessage(messageId)) { + events.add( + new AguiEvent.TextMessageEnd(state.threadId, state.runId, messageId)); + state.endTextMessage(messageId); + } + if (config.isEnableReasoning() + && state.hasStartedReasoningMessage(messageId) + && !state.hasEndedReasoningMessage(messageId)) { + events.add( + new AguiEvent.ReasoningMessageEnd( + state.threadId, state.runId, messageId)); + state.endReasoningMessage(messageId); + } + } + } else if (type == EventType.TOOL_RESULT) { // Handle tool results for (ContentBlock block : msg.getContent()) { if (block instanceof ToolResultBlock toolResult) { @@ -267,7 +263,7 @@ private List convertEvent(Event event, EventConversionState state) { toolCallId = UUID.randomUUID().toString(); } - String result = extractToolResultText(toolResult); + String resultText = extractToolResultText(toolResult); boolean hasStarted = state.hasStartedToolCall(toolCallId); if (!hasStarted) { @@ -281,18 +277,56 @@ private List convertEvent(Event event, EventConversionState state) { state.startToolCall(toolCallId); } - // Ensure ToolCallEnd is emitted to close arguments phase - events.add(new AguiEvent.ToolCallEnd(state.threadId, state.runId, toolCallId)); + if (!event.isLast() && resultText != null && !resultText.isEmpty()) { + // Handle the progress of tool execution (only if enabled) + // (borrowing from the Reasoning block) + if (config.isEnableToolProgressStream()) { + String progressMsgId = "tool-progress-" + toolCallId; - events.add( - new AguiEvent.ToolCallResult( - state.threadId, - state.runId, - toolCallId, - result, - "tool", - msg.getId())); - state.endToolCall(toolCallId); + if (!state.hasStartedReasoningMessage(progressMsgId)) { + events.add( + new AguiEvent.ReasoningMessageStart( + state.threadId, + state.runId, + progressMsgId, + "assistant")); + state.startReasoningMessage(progressMsgId); + } + + events.add( + new AguiEvent.ReasoningMessageContent( + state.threadId, + state.runId, + progressMsgId, + resultText)); + } + } + + // Decouple termination logic from content extraction + // to ensure the final progress chunk is emitted before closing. + if (event.isLast()) { + String thinkingMsgId = "tool-progress-" + toolCallId; + if (state.hasStartedReasoningMessage(thinkingMsgId) + && !state.hasEndedReasoningMessage(thinkingMsgId)) { + events.add( + new AguiEvent.ReasoningMessageEnd( + state.threadId, state.runId, thinkingMsgId)); + state.endReasoningMessage(thinkingMsgId); + } + + // Send real tool call end and result events + events.add( + new AguiEvent.ToolCallEnd(state.threadId, state.runId, toolCallId)); + events.add( + new AguiEvent.ToolCallResult( + state.threadId, + state.runId, + toolCallId, + resultText, + "tool", + msg.getId())); + state.endToolCall(toolCallId); + } } } } @@ -309,9 +343,9 @@ private List convertEvent(Event event, EventConversionState state) { private Flux finishRun(EventConversionState state) { List events = new ArrayList<>(); - // End any messages that weren't properly ended - for (String messageId : state.getStartedMessages()) { - if (!state.hasEndedMessage(messageId)) { + // End any text messages that weren't properly ended + for (String messageId : state.getStartedTextMessages()) { + if (!state.hasEndedTextMessage(messageId)) { events.add(new AguiEvent.TextMessageEnd(state.threadId, state.runId, messageId)); } } @@ -385,8 +419,8 @@ private String serializeToolArgs(Map input) { 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 startedTextMessages = new LinkedHashSet<>(); + private final Set endedTextMessages = new LinkedHashSet<>(); private final Set startedToolCalls = new LinkedHashSet<>(); private final Set endedToolCalls = new LinkedHashSet<>(); private final Set startedReasoningMessages = new LinkedHashSet<>(); @@ -399,24 +433,24 @@ private static class EventConversionState { this.runId = runId; } - boolean hasStartedMessage(String messageId) { - return startedMessages.contains(messageId); + boolean hasStartedTextMessage(String messageId) { + return startedTextMessages.contains(messageId); } - void startMessage(String messageId) { - startedMessages.add(messageId); + void startTextMessage(String messageId) { + startedTextMessages.add(messageId); currentTextMessageId = messageId; } - void endMessage(String messageId) { - endedMessages.add(messageId); + void endTextMessage(String messageId) { + endedTextMessages.add(messageId); if (Objects.equals(messageId, currentTextMessageId)) { currentTextMessageId = null; } } - boolean hasEndedMessage(String messageId) { - return endedMessages.contains(messageId); + boolean hasEndedTextMessage(String messageId) { + return endedTextMessages.contains(messageId); } String getCurrentTextMessageId() { @@ -424,11 +458,11 @@ String getCurrentTextMessageId() { } boolean hasActiveTextMessage() { - return currentTextMessageId != null && !hasEndedMessage(currentTextMessageId); + return currentTextMessageId != null && !hasEndedTextMessage(currentTextMessageId); } - Set getStartedMessages() { - return startedMessages; + Set getStartedTextMessages() { + return startedTextMessages; } boolean hasStartedToolCall(String toolCallId) { 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..7c8d02352a 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(); @@ -758,8 +758,7 @@ void testToolUseBlockWithNullIdGeneratesUUID() { @Test void testTextMessageEndWithLastEventDirectly() { // Test that when the last event contains text blocks and the message hasn't been ended, - // the TextMessageEnd is emitted through the new hasEndedMessage check - // This test specifically covers the new code path at lines 153-158 + // the TextMessageContent is emitted to prevent tail-data loss, followed by TextMessageEnd. String msgId = "msg-text"; Msg textMsg = Msg.builder() @@ -797,10 +796,13 @@ void testTextMessageEndWithLastEventDirectly() { assertEquals(1, textEndCount, "Should have exactly 1 TextMessageEnd"); // Verify the event sequence + assertEquals(5, events.size(), "Should emit 5 events exactly"); 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)); + assertEquals("Hello world", ((AguiEvent.TextMessageContent) events.get(2)).delta()); + assertInstanceOf(AguiEvent.TextMessageEnd.class, events.get(3)); + assertInstanceOf(AguiEvent.RunFinished.class, events.get(4)); } @Test @@ -1696,4 +1698,202 @@ void testRunWithNullThinkingBlock() { !hasReasoningMessageStart, "Should NOT have ReasoningMessageStart for null thinking"); } + + @Test + void testToolResultProgressStreamingAndAccumulation() { + // Test that tool intermediate progress is converted to reasoning blocks + // and final result is accumulated correctly. + AguiAdapterConfig config = + AguiAdapterConfig.builder().enableToolProgressStream(true).build(); + AguiAgentAdapter adapterWithProgress = new AguiAgentAdapter(mockAgent, config); + + // Chunk 1: isLast = false + Msg trChunk1 = + Msg.builder() + .id("msg-tr1") + .role(MsgRole.TOOL) + .content( + ToolResultBlock.builder() + .id("tc-1") + .output(TextBlock.builder().text("Downloading 50%").build()) + .build()) + .build(); + Event event1 = new Event(EventType.TOOL_RESULT, trChunk1, false); + + // Chunk 2: isLast = true + Msg trChunk2 = + Msg.builder() + .id("msg-tr1") + .role(MsgRole.TOOL) + .content( + ToolResultBlock.builder() + .id("tc-1") + .output(TextBlock.builder().text(", Done").build()) + .build()) + .build(); + Event event2 = new Event(EventType.TOOL_RESULT, trChunk2, true); + + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(event1, event2)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Download it"))) + .build(); + + List events = adapterWithProgress.run(input).collectList().block(); + assertNotNull(events); + + String expectedThinkingId = "tool-progress-tc-1"; + + // Verify fake reasoning block for progress was created and ended + boolean hasFakeReasoningStart = + events.stream() + .filter(e -> e instanceof AguiEvent.ReasoningMessageStart) + .anyMatch( + e -> + expectedThinkingId.equals( + ((AguiEvent.ReasoningMessageStart) e).messageId())); + assertTrue(hasFakeReasoningStart, "Should emit ReasoningMessageStart for tool progress"); + + boolean hasFakeReasoningEnd = + events.stream() + .filter(e -> e instanceof AguiEvent.ReasoningMessageEnd) + .anyMatch( + e -> + expectedThinkingId.equals( + ((AguiEvent.ReasoningMessageEnd) e).messageId())); + assertTrue(hasFakeReasoningEnd, "Should emit ReasoningMessageEnd to close tool progress"); + + // Verify final accumulated result + AguiEvent.ToolCallResult toolResult = + events.stream() + .filter(e -> e instanceof AguiEvent.ToolCallResult) + .map(e -> (AguiEvent.ToolCallResult) e) + .findFirst() + .orElse(null); + + assertNotNull(toolResult, "Should have ToolCallResult"); + assertEquals(", Done", toolResult.content(), "Should accumulate all chunks"); + } + + @Test + void testTailEndDataPreservationWhenIsLastIsTrue() { + // Test that content in the very last event (isLast=true) is NOT dropped + Msg lastMsgWithContent = + Msg.builder() + .id("msg-text") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("Last crucial words.").build()) + .build(); + + // One single event, carries content AND isLast=true + Event event = new Event(EventType.REASONING, lastMsgWithContent, true); + when(mockAgent.stream(anyList(), any(StreamOptions.class))).thenReturn(Flux.just(event)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("t1") + .runId("r1") + .messages(List.of(AguiMessage.userMessage("m1", "Hi"))) + .build(); + + List events = adapter.run(input).collectList().block(); + assertNotNull(events); + + // Find events associated with this message + boolean hasContent = + events.stream().anyMatch(e -> e instanceof AguiEvent.TextMessageContent); + boolean hasEnd = events.stream().anyMatch(e -> e instanceof AguiEvent.TextMessageEnd); + + assertTrue(hasContent, "Must emit TextMessageContent even if isLast is true"); + assertTrue(hasEnd, "Must emit TextMessageEnd"); + + // Verify Content comes before End + int contentIndex = + events.indexOf( + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .findFirst() + .get()); + int endIndex = + events.indexOf( + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageEnd) + .findFirst() + .get()); + assertTrue(contentIndex < endIndex, "Content must be emitted before End"); + } + + @Test + void testEmptyContentListClosesActiveLifecycle() { + // Test that an empty content list with isLast=true properly closes active states + Msg msg1 = + Msg.builder() + .id("msg-1") + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text("Start ").build()) + .build(); + Event event1 = new Event(EventType.REASONING, msg1, false); + + // Empty content, but signals end + Msg msg2 = + Msg.builder() + .id("msg-1") + .role(MsgRole.ASSISTANT) + .content(List.of()) // Empty content! + .build(); + Event event2 = new Event(EventType.REASONING, msg2, true); + + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(event1, event2)); + + RunAgentInput input = RunAgentInput.builder().threadId("t1").runId("r1").build(); + + List events = adapter.run(input).collectList().block(); + assertNotNull(events); + + long endCount = events.stream().filter(e -> e instanceof AguiEvent.TextMessageEnd).count(); + assertEquals( + 1, + endCount, + "Should successfully close the text message even if last content block is empty"); + } + + @Test + void testToolCallArgsTailEndPreservation() { + // Test that tool argument JSON in the final chunk is not dropped + Msg toolMsg = + Msg.builder() + .id("msg-tc1") + .role(MsgRole.ASSISTANT) + .content( + ToolUseBlock.builder() + .id("tc-1") + .name("tool") + .content("}") // The final closing bracket + .build()) + .build(); + + // The tool use block comes with isLast=true + Event event = new Event(EventType.REASONING, toolMsg, true); + when(mockAgent.stream(anyList(), any(StreamOptions.class))).thenReturn(Flux.just(event)); + + RunAgentInput input = RunAgentInput.builder().threadId("t1").runId("r1").build(); + + List events = adapter.run(input).collectList().block(); + assertNotNull(events); + + AguiEvent.ToolCallArgs toolArgs = + events.stream() + .filter(e -> e instanceof AguiEvent.ToolCallArgs) + .map(e -> (AguiEvent.ToolCallArgs) e) + .findFirst() + .orElse(null); + + assertNotNull(toolArgs, "Must emit ToolCallArgs even if event.isLast() is true"); + assertEquals("}", toolArgs.delta()); + } }