From e978c29d81711b021326d6b617c45034ea2a9bd3 Mon Sep 17 00:00:00 2001 From: Karson To Date: Thu, 15 Jan 2026 14:12:13 +0800 Subject: [PATCH 1/4] Support LLM thinking mode output --- .../agui/config/AgentConfiguration.java | 2 +- .../core/agui/adapter/AguiAgentAdapter.java | 34 +++ .../agui/adapter/AguiAgentAdapterTest.java | 209 ++++++++++++++++++ 3 files changed, 244 insertions(+), 1 deletion(-) diff --git a/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java index 5003ec051..cd501e6f6 100644 --- a/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java +++ b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java @@ -87,7 +87,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-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 30a83fcee..937cba77c 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 @@ -25,6 +25,7 @@ import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.util.JsonException; @@ -156,6 +157,39 @@ private List convertEvent(Event event, EventConversionState state) { state.endMessage(messageId); } } + } else if (block instanceof ThinkingBlock thinkingBlock) { + // Handle thinking blocks - convert to text messages with special messageId + String thinking = thinkingBlock.getThinking(); + if (thinking != null && !thinking.isEmpty()) { + String thinkingMessageId = msg.getId() + "-thinking"; + + // Start message if not started + if (!state.hasStartedMessage(thinkingMessageId)) { + events.add( + new AguiEvent.TextMessageStart( + state.threadId, + state.runId, + thinkingMessageId, + "assistant")); + state.startMessage(thinkingMessageId); + } + + if (!event.isLast()) { + // In incremental mode, thinking is already the delta + events.add( + new AguiEvent.TextMessageContent( + state.threadId, + state.runId, + thinkingMessageId, + thinking)); + } else { + // End message if this is the last event + events.add( + new AguiEvent.TextMessageEnd( + state.threadId, state.runId, thinkingMessageId)); + state.endMessage(thinkingMessageId); + } + } } else if (block instanceof ToolUseBlock toolUse) { // End any active text message before starting tool call if (state.hasActiveTextMessage()) { 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 2e0b6917f..e78184014 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 @@ -34,6 +34,7 @@ import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; 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.List; @@ -508,4 +509,212 @@ void testReactiveStreamCompletion() { .expectNextMatches(e -> e instanceof AguiEvent.RunFinished) .verifyComplete(); } + + @Test + void testRunWithThinkingBlockEvent() { + Msg reasoningMsg = + Msg.builder() + .id("msg-r1") + .role(MsgRole.ASSISTANT) + .content( + ThinkingBlock.builder() + .thinking("Let me think about this problem step by step...") + .build()) + .build(); + + Event reasoningEvent = new Event(EventType.REASONING, reasoningMsg, false); + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(reasoningEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Find thinking message events + AguiEvent.TextMessageStart thinkingStart = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .filter(e -> e.messageId().endsWith("-thinking")) + .findFirst() + .orElse(null); + + assertNotNull(thinkingStart, "Should have TextMessageStart for thinking"); + assertEquals("msg-r1-thinking", thinkingStart.messageId()); + assertEquals("assistant", thinkingStart.role()); + + AguiEvent.TextMessageContent thinkingContent = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .map(e -> (AguiEvent.TextMessageContent) e) + .filter(e -> e.messageId().endsWith("-thinking")) + .findFirst() + .orElse(null); + + assertNotNull(thinkingContent, "Should have TextMessageContent for thinking"); + assertTrue( + thinkingContent.delta().contains("think about this problem"), + "Should contain thinking content"); + } + + @Test + void testRunWithStreamingThinkingBlockEvents() { + // Simulate streaming thinking: multiple events with same message ID + Msg thinkingChunk1 = + Msg.builder() + .id("msg-thinking") + .role(MsgRole.ASSISTANT) + .content(ThinkingBlock.builder().thinking("First thought").build()) + .build(); + + Msg thinkingChunk2 = + Msg.builder() + .id("msg-thinking") + .role(MsgRole.ASSISTANT) + .content(ThinkingBlock.builder().thinking("Second thought").build()) + .build(); + + Event event1 = new Event(EventType.REASONING, thinkingChunk1, false); + Event event2 = new Event(EventType.REASONING, thinkingChunk2, false); + + 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", "Hi"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Count TextMessageContent events for thinking - should have 2 (one for each chunk) + long thinkingContentCount = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .map(e -> (AguiEvent.TextMessageContent) e) + .filter(e -> e.messageId().endsWith("-thinking")) + .count(); + assertEquals( + 2, thinkingContentCount, "Should have 2 content events for streaming thinking"); + + // Should only have 1 TextMessageStart for thinking (same message ID) + long thinkingStartCount = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .filter(e -> e.messageId().endsWith("-thinking")) + .count(); + assertEquals( + 1, + thinkingStartCount, + "Should have only 1 start event for same thinking message ID"); + } + + @Test + void testRunWithThinkingAndTextMixedContent() { + // Message with both thinking and text + Msg mixedMsg = + Msg.builder() + .id("msg-mixed") + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking("I need to analyze this carefully.") + .build(), + TextBlock.builder() + .text("Based on my analysis, here's the answer.") + .build())) + .build(); + + Event mixedEvent = new Event(EventType.REASONING, mixedMsg, false); + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(mixedEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Question?"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Should have thinking message events + boolean hasThinkingStart = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .anyMatch(e -> e.messageId().endsWith("-thinking")); + boolean hasThinkingContent = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .map(e -> (AguiEvent.TextMessageContent) e) + .anyMatch(e -> e.messageId().endsWith("-thinking")); + + // Should have regular text message events + boolean hasTextStart = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .anyMatch(e -> !e.messageId().endsWith("-thinking")); + boolean hasTextContent = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .map(e -> (AguiEvent.TextMessageContent) e) + .anyMatch(e -> !e.messageId().endsWith("-thinking")); + + assertTrue(hasThinkingStart, "Should have TextMessageStart for thinking"); + assertTrue(hasThinkingContent, "Should have TextMessageContent for thinking"); + assertTrue(hasTextStart, "Should have TextMessageStart for text"); + assertTrue(hasTextContent, "Should have TextMessageContent for text"); + } + + @Test + void testRunWithEmptyThinkingBlock() { + // Empty thinking block should be skipped + Msg reasoningMsg = + Msg.builder() + .id("msg-r1") + .role(MsgRole.ASSISTANT) + .content(ThinkingBlock.builder().thinking("").build()) + .build(); + + Event reasoningEvent = new Event(EventType.REASONING, reasoningMsg, false); + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(reasoningEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Should NOT have any thinking message events for empty thinking + boolean hasThinkingStart = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .anyMatch(e -> e.messageId().endsWith("-thinking")); + + assertTrue(!hasThinkingStart, "Should NOT have TextMessageStart for empty thinking"); + } } From 968a1a15034bac7d3a69812595dcf626f24b9a60 Mon Sep 17 00:00:00 2001 From: Karson To Date: Thu, 15 Jan 2026 15:04:41 +0800 Subject: [PATCH 2/4] test case --- .../agui/adapter/AguiAgentAdapterTest.java | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) 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 e78184014..1bf3c6723 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 @@ -717,4 +717,195 @@ void testRunWithEmptyThinkingBlock() { assertTrue(!hasThinkingStart, "Should NOT have TextMessageStart for empty thinking"); } + + @Test + void testRunWithThinkingBlockLastEvent() { + // Test the isLast() == true branch for ThinkingBlock + Msg reasoningMsg = + Msg.builder() + .id("msg-r1") + .role(MsgRole.ASSISTANT) + .content(ThinkingBlock.builder().thinking("Final thinking content").build()) + .build(); + + Event reasoningEvent = new Event(EventType.REASONING, reasoningMsg, true); // isLast = true + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(reasoningEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Should have TextMessageStart and TextMessageEnd (not TextMessageContent) + AguiEvent.TextMessageStart thinkingStart = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .filter(e -> e.messageId().endsWith("-thinking")) + .findFirst() + .orElse(null); + + assertNotNull(thinkingStart, "Should have TextMessageStart for thinking"); + + AguiEvent.TextMessageEnd thinkingEnd = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageEnd) + .map(e -> (AguiEvent.TextMessageEnd) e) + .filter(e -> e.messageId().endsWith("-thinking")) + .findFirst() + .orElse(null); + + assertNotNull(thinkingEnd, "Should have TextMessageEnd for thinking when isLast=true"); + + // Should NOT have TextMessageContent when isLast=true + boolean hasContent = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .map(e -> (AguiEvent.TextMessageContent) e) + .anyMatch(e -> e.messageId().endsWith("-thinking")); + + assertTrue(!hasContent, "Should NOT have TextMessageContent when isLast=true"); + } + + @Test + void testRunWithThinkingAndToolCallMixed() { + // Test thinking content mixed with tool call + Msg mixedMsg = + Msg.builder() + .id("msg-mixed") + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking("I need to use a tool to get the answer.") + .build(), + ToolUseBlock.builder() + .id("tc-1") + .name("get_weather") + .input(Map.of("city", "Beijing")) + .build())) + .build(); + + Event mixedEvent = new Event(EventType.REASONING, mixedMsg, false); + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(mixedEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Weather?"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Should have thinking events + boolean hasThinkingStart = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .anyMatch(e -> e.messageId().endsWith("-thinking")); + + // Should have tool call events + boolean hasToolStart = events.stream().anyMatch(e -> e instanceof AguiEvent.ToolCallStart); + + assertTrue(hasThinkingStart, "Should have thinking message"); + assertTrue(hasToolStart, "Should have tool call"); + } + + @Test + void testRunWithStreamingThinkingBlockLastEvent() { + // Test streaming with last event (isLast=true) + Msg thinkingChunk1 = + Msg.builder() + .id("msg-thinking") + .role(MsgRole.ASSISTANT) + .content(ThinkingBlock.builder().thinking("First thought").build()) + .build(); + + Msg thinkingChunk2 = + Msg.builder() + .id("msg-thinking") + .role(MsgRole.ASSISTANT) + .content(ThinkingBlock.builder().thinking("Second thought").build()) + .build(); + + Event event1 = new Event(EventType.REASONING, thinkingChunk1, false); + Event event2 = new Event(EventType.REASONING, thinkingChunk2, true); // Last event + + 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", "Hi"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Should have TextMessageContent for first chunk + long contentCount = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageContent) + .map(e -> (AguiEvent.TextMessageContent) e) + .filter(e -> e.messageId().endsWith("-thinking")) + .count(); + assertEquals(1, contentCount, "Should have 1 content event for first chunk"); + + // Should have TextMessageEnd for last event + boolean hasEnd = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageEnd) + .map(e -> (AguiEvent.TextMessageEnd) e) + .anyMatch(e -> e.messageId().endsWith("-thinking")); + assertTrue(hasEnd, "Should have TextMessageEnd for last event"); + } + + @Test + void testRunWithNullThinkingBlock() { + // ThinkingBlock with null thinking should be converted to empty string and skipped + Msg reasoningMsg = + Msg.builder() + .id("msg-r1") + .role(MsgRole.ASSISTANT) + .content(ThinkingBlock.builder().thinking(null).build()) + .build(); + + Event reasoningEvent = new Event(EventType.REASONING, reasoningMsg, false); + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(reasoningEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) + .build(); + + List events = adapter.run(input).collectList().block(); + + assertNotNull(events); + + // Should NOT have any thinking message events for null/empty thinking + boolean hasThinkingStart = + events.stream() + .filter(e -> e instanceof AguiEvent.TextMessageStart) + .map(e -> (AguiEvent.TextMessageStart) e) + .anyMatch(e -> e.messageId().endsWith("-thinking")); + + assertTrue(!hasThinkingStart, "Should NOT have TextMessageStart for null thinking"); + } } From 9158bf8763362500b708a154937c769937a1d345 Mon Sep 17 00:00:00 2001 From: Karson To Date: Fri, 16 Jan 2026 14:29:36 +0800 Subject: [PATCH 3/4] ref https://docs.ag-ui.com/drafts/reasoning --- .../agui/config/AgentConfiguration.java | 2 +- .../agui/src/main/resources/application.yml | 1 + .../agui/src/main/resources/static/index.html | 50 +++- .../main/resources/static/js/agui-client.js | 20 ++ .../core/agui/adapter/AguiAdapterConfig.java | 31 ++ .../core/agui/adapter/AguiAgentAdapter.java | 102 +++++-- .../agentscope/core/agui/event/AguiEvent.java | 235 ++++++++++++++- .../core/agui/event/AguiEventType.java | 32 ++- .../agui/adapter/AguiAdapterConfigTest.java | 3 + .../agui/adapter/AguiAgentAdapterTest.java | 270 ++++++++++-------- .../boot/agui/common/AguiProperties.java | 18 ++ .../AgentscopeAguiMvcAutoConfiguration.java | 1 + ...gentscopeAguiWebFluxAutoConfiguration.java | 1 + 13 files changed, 611 insertions(+), 155 deletions(-) diff --git a/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java index cd501e6f6..5003ec051 100644 --- a/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java +++ b/agentscope-examples/agui/src/main/java/io/agentscope/examples/agui/config/AgentConfiguration.java @@ -87,7 +87,7 @@ private Agent createDefaultAgent() { .model( DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-plus").stream( true) - .enableThinking(true) + .enableThinking(false) .formatter(new DashScopeChatFormatter()) .build()) .toolkit(toolkit) diff --git a/agentscope-examples/agui/src/main/resources/application.yml b/agentscope-examples/agui/src/main/resources/application.yml index 4cdf53c0e..8443b62a7 100644 --- a/agentscope-examples/agui/src/main/resources/application.yml +++ b/agentscope-examples/agui/src/main/resources/application.yml @@ -44,6 +44,7 @@ agentscope: server-side-memory: true max-thread-sessions: 1000 session-timeout-minutes: 30 + enable-reasoning: false # Logging logging: diff --git a/agentscope-examples/agui/src/main/resources/static/index.html b/agentscope-examples/agui/src/main/resources/static/index.html index c20e15636..97c72c425 100644 --- a/agentscope-examples/agui/src/main/resources/static/index.html +++ b/agentscope-examples/agui/src/main/resources/static/index.html @@ -117,10 +117,19 @@ .message.tool { background: rgba(255, 158, 100, 0.1); border-left: 3px solid var(--accent-orange); - margin: 0 60px; + margin: 0 60px 16px 60px; font-size: 0.85rem; } + .message.reasoning { + background: rgba(158, 206, 106, 0.08); + border-left: 3px solid var(--accent-green); + margin: 0 60px 16px 60px; + font-size: 0.85rem; + font-style: italic; + opacity: 0.9; + } + .message.error { background: rgba(247, 118, 142, 0.1); border-left: 3px solid var(--accent-red); @@ -137,6 +146,7 @@ .message.user .message-role { color: var(--text-secondary); } .message.assistant .message-role { color: var(--accent-blue); } .message.tool .message-role { color: var(--accent-orange); } + .message.reasoning .message-role { color: var(--accent-green); } .message-content { white-space: pre-wrap; @@ -327,6 +337,14 @@

AgentScope AG-UI Demo

return; } + if (append && role === 'reasoning' && currentReasoningDiv) { + // Append to current reasoning message + const contentEl = currentReasoningDiv.querySelector('.message-content'); + contentEl.textContent += content; + messages.scrollTop = messages.scrollHeight; + return; + } + const div = document.createElement('div'); div.className = `message ${role}`; div.innerHTML = ` @@ -338,6 +356,8 @@

AgentScope AG-UI Demo

if (role === 'assistant') { currentAssistantDiv = div; + } else if (role === 'reasoning') { + currentReasoningDiv = div; } } @@ -380,6 +400,9 @@

AgentScope AG-UI Demo

let assistantContent = ''; let currentMessageId = null; + let reasoningContent = ''; + let currentReasoningMessageId = null; + let currentReasoningDiv = null; try { await client.run({ @@ -390,6 +413,29 @@

AgentScope AG-UI Demo

onRunStarted: () => { console.log('Run started'); currentAssistantDiv = null; + currentReasoningDiv = null; + reasoningContent = ''; + }, + onReasoningMessageStart: (messageId, role) => { + console.log('Reasoning message start:', messageId, role); + hideTypingIndicator(); + currentReasoningMessageId = messageId; + reasoningContent = ''; + currentReasoningDiv = null; + }, + onReasoningContent: (delta, messageId) => { + console.log('Reasoning content delta:', delta); + if (reasoningContent === '') { + appendMessage('reasoning', delta); + } else { + appendMessage('reasoning', delta, true); + } + reasoningContent += delta; + }, + onReasoningMessageEnd: (messageId) => { + console.log('Reasoning message end:', messageId); + currentReasoningDiv = null; + reasoningContent = ''; }, onTextMessageStart: (messageId, role) => { console.log('Text message start:', messageId, role); @@ -463,6 +509,8 @@

AgentScope AG-UI Demo

stopBtn.style.display = 'none'; hideTypingIndicator(); currentAssistantDiv = null; + currentReasoningDiv = null; + reasoningContent = ''; if (statusText.textContent === 'Running...') { setStatus('ready', 'Ready'); } diff --git a/agentscope-examples/agui/src/main/resources/static/js/agui-client.js b/agentscope-examples/agui/src/main/resources/static/js/agui-client.js index d094df577..58dc260d5 100644 --- a/agentscope-examples/agui/src/main/resources/static/js/agui-client.js +++ b/agentscope-examples/agui/src/main/resources/static/js/agui-client.js @@ -27,6 +27,7 @@ * messages: [{ id: 'msg-1', role: 'user', content: 'Hello!' }] * }, { * onTextContent: (delta) => console.log(delta), + * onReasoningContent: (delta) => console.log('Reasoning:', delta), * onRunFinished: () => console.log('Done') * }); */ @@ -71,6 +72,9 @@ class AguiClient { * @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 = {}) { @@ -220,6 +224,22 @@ class AguiClient { 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; 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 29ca0306f..8870e2689 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 @@ -29,6 +29,7 @@ public class AguiAdapterConfig { private final ToolMergeMode toolMergeMode; private final boolean emitStateEvents; private final boolean emitToolCallArgs; + private final boolean enableReasoning; private final Duration runTimeout; private final String defaultAgentId; @@ -36,6 +37,7 @@ private AguiAdapterConfig(Builder builder) { this.toolMergeMode = builder.toolMergeMode; this.emitStateEvents = builder.emitStateEvents; this.emitToolCallArgs = builder.emitToolCallArgs; + this.enableReasoning = builder.enableReasoning; this.runTimeout = builder.runTimeout; this.defaultAgentId = builder.defaultAgentId; } @@ -67,6 +69,19 @@ public boolean isEmitToolCallArgs() { return emitToolCallArgs; } + /** + * Check if reasoning/thinking content should be emitted. + * + *

When enabled, ThinkingBlock content will be converted to REASONING_* events + * according to the AG-UI Reasoning draft specification. When disabled (default), + * ThinkingBlock content is ignored and no reasoning events are emitted. + * + * @return true if reasoning events should be emitted + */ + public boolean isEnableReasoning() { + return enableReasoning; + } + /** * Get the run timeout duration. * @@ -111,6 +126,7 @@ public static class Builder { private ToolMergeMode toolMergeMode = ToolMergeMode.MERGE_FRONTEND_PRIORITY; private boolean emitStateEvents = true; private boolean emitToolCallArgs = true; + private boolean enableReasoning = false; private Duration runTimeout = Duration.ofMinutes(10); private String defaultAgentId; @@ -147,6 +163,21 @@ public Builder emitToolCallArgs(boolean emitToolCallArgs) { return this; } + /** + * Set whether to enable reasoning/thinking content output. + * + *

When enabled, ThinkingBlock content will be converted to REASONING_* events + * according to the AG-UI Reasoning draft specification. Default is false to ensure + * backward compatibility and privacy compliance. + * + * @param enableReasoning true to enable reasoning events + * @return This builder + */ + public Builder enableReasoning(boolean enableReasoning) { + this.enableReasoning = enableReasoning; + 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 937cba77c..175e120be 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 @@ -47,10 +47,18 @@ * *

Event Mapping: *

    - *
  • AgentScope REASONING events → AG-UI TEXT_MESSAGE_* events
  • + *
  • AgentScope REASONING events → AG-UI TEXT_MESSAGE_* events (for TextBlock)
  • + *
  • AgentScope REASONING events → AG-UI REASONING_* events (for ThinkingBlock, when enabled)
  • *
  • AgentScope TOOL_RESULT events → AG-UI TOOL_CALL_END events
  • *
  • ToolUseBlock content → AG-UI TOOL_CALL_START events
  • *
+ * + *

Reasoning Support: + *

    + *
  • ThinkingBlock content is converted to REASONING_* events according to AG-UI Reasoning draft
  • + *
  • Reasoning output is disabled by default (enableReasoning=false) for backward compatibility
  • + *
  • Set enableReasoning=true in AguiAdapterConfig to enable reasoning events
  • + *
*/ public class AguiAgentAdapter { @@ -158,38 +166,40 @@ private List convertEvent(Event event, EventConversionState state) { } } } else if (block instanceof ThinkingBlock thinkingBlock) { - // Handle thinking blocks - convert to text messages with special messageId - String thinking = thinkingBlock.getThinking(); - if (thinking != null && !thinking.isEmpty()) { - String thinkingMessageId = msg.getId() + "-thinking"; - - // Start message if not started - if (!state.hasStartedMessage(thinkingMessageId)) { - events.add( - new AguiEvent.TextMessageStart( - state.threadId, - state.runId, - thinkingMessageId, - "assistant")); - state.startMessage(thinkingMessageId); - } - - if (!event.isLast()) { - // In incremental mode, thinking is already the delta - events.add( - new AguiEvent.TextMessageContent( - state.threadId, - state.runId, - thinkingMessageId, - thinking)); - } else { - // End message if this is the last event - events.add( - new AguiEvent.TextMessageEnd( - state.threadId, state.runId, thinkingMessageId)); - state.endMessage(thinkingMessageId); + // 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, + "assistant")); + 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()) { @@ -276,6 +286,14 @@ private Flux finishRun(EventConversionState state) { } } + // 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)); @@ -334,6 +352,8 @@ private static class EventConversionState { 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; EventConversionState(String threadId, String runId) { @@ -392,5 +412,25 @@ boolean hasEndedToolCall(String toolCallId) { Set getStartedToolCalls() { return startedToolCalls; } + + boolean hasStartedReasoningMessage(String messageId) { + return startedReasoningMessages.contains(messageId); + } + + void startReasoningMessage(String messageId) { + startedReasoningMessages.add(messageId); + } + + void endReasoningMessage(String messageId) { + endedReasoningMessages.add(messageId); + } + + boolean hasEndedReasoningMessage(String messageId) { + return endedReasoningMessages.contains(messageId); + } + + Set getStartedReasoningMessages() { + return startedReasoningMessages; + } } } diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEvent.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEvent.java index dbc335a66..0cf34a775 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEvent.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEvent.java @@ -48,7 +48,19 @@ @JsonSubTypes.Type(value = AguiEvent.ToolCallResult.class, name = "TOOL_CALL_RESULT"), @JsonSubTypes.Type(value = AguiEvent.StateSnapshot.class, name = "STATE_SNAPSHOT"), @JsonSubTypes.Type(value = AguiEvent.StateDelta.class, name = "STATE_DELTA"), - @JsonSubTypes.Type(value = AguiEvent.Raw.class, name = "RAW") + @JsonSubTypes.Type(value = AguiEvent.Raw.class, name = "RAW"), + @JsonSubTypes.Type(value = AguiEvent.ReasoningStart.class, name = "REASONING_START"), + @JsonSubTypes.Type( + value = AguiEvent.ReasoningMessageStart.class, + name = "REASONING_MESSAGE_START"), + @JsonSubTypes.Type( + value = AguiEvent.ReasoningMessageContent.class, + name = "REASONING_MESSAGE_CONTENT"), + @JsonSubTypes.Type(value = AguiEvent.ReasoningMessageEnd.class, name = "REASONING_MESSAGE_END"), + @JsonSubTypes.Type( + value = AguiEvent.ReasoningMessageChunk.class, + name = "REASONING_MESSAGE_CHUNK"), + @JsonSubTypes.Type(value = AguiEvent.ReasoningEnd.class, name = "REASONING_END") }) public sealed interface AguiEvent permits AguiEvent.RunStarted, @@ -62,7 +74,13 @@ public sealed interface AguiEvent AguiEvent.ToolCallResult, AguiEvent.StateSnapshot, AguiEvent.StateDelta, - AguiEvent.Raw { + AguiEvent.Raw, + AguiEvent.ReasoningStart, + AguiEvent.ReasoningMessageStart, + AguiEvent.ReasoningMessageContent, + AguiEvent.ReasoningMessageEnd, + AguiEvent.ReasoningMessageChunk, + AguiEvent.ReasoningEnd { /** * Get the event type. @@ -512,6 +530,219 @@ public String getRunId() { } } + /** + * Event indicating the start of a reasoning/thinking phase. This event is emitted + * when the agent begins its internal reasoning process. + * + *

According to AG-UI Reasoning draft specification. + */ + record ReasoningStart(String threadId, String runId, String messageId, String encryptedContent) + implements AguiEvent { + + @JsonCreator + public ReasoningStart( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("messageId") String messageId, + @JsonProperty("encryptedContent") String encryptedContent) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.messageId = Objects.requireNonNull(messageId, "messageId cannot be null"); + this.encryptedContent = encryptedContent; // Optional + } + + @Override + public AguiEventType getType() { + return AguiEventType.REASONING_START; + } + + @Override + public String getThreadId() { + return threadId; + } + + @Override + public String getRunId() { + return runId; + } + } + + /** + * Event signaling the start of a reasoning message. + * + *

According to AG-UI Reasoning draft specification. + */ + record ReasoningMessageStart(String threadId, String runId, String messageId, String role) + implements AguiEvent { + + @JsonCreator + public ReasoningMessageStart( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("messageId") String messageId, + @JsonProperty("role") String role) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.messageId = Objects.requireNonNull(messageId, "messageId cannot be null"); + this.role = Objects.requireNonNull(role, "role cannot be null"); + } + + @Override + public AguiEventType getType() { + return AguiEventType.REASONING_MESSAGE_START; + } + + @Override + public String getThreadId() { + return threadId; + } + + @Override + public String getRunId() { + return runId; + } + } + + /** + * Event containing a chunk of content in a streaming reasoning message. + * + *

According to AG-UI Reasoning draft specification. + */ + record ReasoningMessageContent(String threadId, String runId, String messageId, String delta) + implements AguiEvent { + + @JsonCreator + public ReasoningMessageContent( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("messageId") String messageId, + @JsonProperty("delta") String delta) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.messageId = Objects.requireNonNull(messageId, "messageId cannot be null"); + this.delta = Objects.requireNonNull(delta, "delta cannot be null"); + } + + @Override + public AguiEventType getType() { + return AguiEventType.REASONING_MESSAGE_CONTENT; + } + + @Override + public String getThreadId() { + return threadId; + } + + @Override + public String getRunId() { + return runId; + } + } + + /** + * Event signaling the end of a reasoning message. + * + *

According to AG-UI Reasoning draft specification. + */ + record ReasoningMessageEnd(String threadId, String runId, String messageId) + implements AguiEvent { + + @JsonCreator + public ReasoningMessageEnd( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("messageId") String messageId) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.messageId = Objects.requireNonNull(messageId, "messageId cannot be null"); + } + + @Override + public AguiEventType getType() { + return AguiEventType.REASONING_MESSAGE_END; + } + + @Override + public String getThreadId() { + return threadId; + } + + @Override + public String getRunId() { + return runId; + } + } + + /** + * A convenience event to auto start/close reasoning messages. + * + *

According to AG-UI Reasoning draft specification. + */ + record ReasoningMessageChunk(String threadId, String runId, String messageId, String delta) + implements AguiEvent { + + @JsonCreator + public ReasoningMessageChunk( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("messageId") String messageId, + @JsonProperty("delta") String delta) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.messageId = messageId; // Optional + this.delta = delta; // Optional + } + + @Override + public AguiEventType getType() { + return AguiEventType.REASONING_MESSAGE_CHUNK; + } + + @Override + public String getThreadId() { + return threadId; + } + + @Override + public String getRunId() { + return runId; + } + } + + /** + * Event indicating the end of a reasoning/thinking phase. This event is emitted + * when the agent has finished its internal reasoning process. + * + *

According to AG-UI Reasoning draft specification. + */ + record ReasoningEnd(String threadId, String runId, String messageId) implements AguiEvent { + + @JsonCreator + public ReasoningEnd( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("messageId") String messageId) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.messageId = Objects.requireNonNull(messageId, "messageId cannot be null"); + } + + @Override + public AguiEventType getType() { + return AguiEventType.REASONING_END; + } + + @Override + public String getThreadId() { + return threadId; + } + + @Override + public String getRunId() { + return runId; + } + } + /** * Represents a JSON Patch operation (RFC 6902). Used in {@link StateDelta} * events for diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEventType.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEventType.java index c6d532eeb..60cb17ff0 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEventType.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/event/AguiEventType.java @@ -77,5 +77,35 @@ public enum AguiEventType { /** * A raw event with custom data. */ - RAW + RAW, + + /** + * Indicates the start of a reasoning/thinking phase. + */ + REASONING_START, + + /** + * Signals the start of a reasoning message. + */ + REASONING_MESSAGE_START, + + /** + * Contains a chunk of content in a streaming reasoning message. + */ + REASONING_MESSAGE_CONTENT, + + /** + * Signals the end of a reasoning message. + */ + REASONING_MESSAGE_END, + + /** + * A convenience event to auto start/close reasoning messages. + */ + REASONING_MESSAGE_CHUNK, + + /** + * Indicates the end of a reasoning/thinking phase. + */ + REASONING_END } 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 74c0fb0f1..f33eb70ff 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 @@ -38,6 +38,7 @@ void testDefaultConfig() { assertEquals(ToolMergeMode.MERGE_FRONTEND_PRIORITY, config.getToolMergeMode()); assertTrue(config.isEmitStateEvents()); assertTrue(config.isEmitToolCallArgs()); + assertFalse(config.isEnableReasoning()); // Default should be false assertEquals(Duration.ofMinutes(10), config.getRunTimeout()); assertNull(config.getDefaultAgentId()); } @@ -131,6 +132,7 @@ void testBuilderFullConfiguration() { .toolMergeMode(ToolMergeMode.AGENT_ONLY) .emitStateEvents(false) .emitToolCallArgs(false) + .enableReasoning(true) .runTimeout(Duration.ofHours(1)) .defaultAgentId("my-agent") .build(); @@ -138,6 +140,7 @@ void testBuilderFullConfiguration() { assertEquals(ToolMergeMode.AGENT_ONLY, config.getToolMergeMode()); assertFalse(config.isEmitStateEvents()); assertFalse(config.isEmitToolCallArgs()); + assertTrue(config.isEnableReasoning()); assertEquals(Duration.ofHours(1), config.getRunTimeout()); assertEquals("my-agent", config.getDefaultAgentId()); } diff --git a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java index 1bf3c6723..760300bed 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 @@ -511,7 +511,8 @@ void testReactiveStreamCompletion() { } @Test - void testRunWithThinkingBlockEvent() { + void testRunWithThinkingBlockDefaultDisabled() { + // Test that reasoning is disabled by default Msg reasoningMsg = Msg.builder() .id("msg-r1") @@ -537,35 +538,81 @@ void testRunWithThinkingBlockEvent() { assertNotNull(events); - // Find thinking message events - AguiEvent.TextMessageStart thinkingStart = + // Should NOT have any reasoning events when disabled (default) + boolean hasReasoningMessageStart = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageStart); + boolean hasReasoningMessageContent = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageContent); + + assertTrue( + !hasReasoningMessageStart, "Should NOT have ReasoningMessageStart when disabled"); + assertTrue( + !hasReasoningMessageContent, + "Should NOT have ReasoningMessageContent when disabled"); + } + + @Test + void testRunWithThinkingBlockEvent() { + // Test reasoning events when enabled + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + + Msg reasoningMsg = + Msg.builder() + .id("msg-r1") + .role(MsgRole.ASSISTANT) + .content( + ThinkingBlock.builder() + .thinking("Let me think about this problem step by step...") + .build()) + .build(); + + Event reasoningEvent = new Event(EventType.REASONING, reasoningMsg, false); + when(mockAgent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(reasoningEvent)); + + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-1") + .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) + .build(); + + List events = adapterWithReasoning.run(input).collectList().block(); + + assertNotNull(events); + + // Find reasoning events + AguiEvent.ReasoningMessageStart reasoningMessageStart = events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .filter(e -> e.messageId().endsWith("-thinking")) + .filter(e -> e instanceof AguiEvent.ReasoningMessageStart) + .map(e -> (AguiEvent.ReasoningMessageStart) e) .findFirst() .orElse(null); - assertNotNull(thinkingStart, "Should have TextMessageStart for thinking"); - assertEquals("msg-r1-thinking", thinkingStart.messageId()); - assertEquals("assistant", thinkingStart.role()); + assertNotNull(reasoningMessageStart, "Should have ReasoningMessageStart"); + assertEquals("msg-r1", reasoningMessageStart.messageId()); + assertEquals("assistant", reasoningMessageStart.role()); - AguiEvent.TextMessageContent thinkingContent = + AguiEvent.ReasoningMessageContent reasoningMessageContent = events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageContent) - .map(e -> (AguiEvent.TextMessageContent) e) - .filter(e -> e.messageId().endsWith("-thinking")) + .filter(e -> e instanceof AguiEvent.ReasoningMessageContent) + .map(e -> (AguiEvent.ReasoningMessageContent) e) .findFirst() .orElse(null); - assertNotNull(thinkingContent, "Should have TextMessageContent for thinking"); + assertNotNull(reasoningMessageContent, "Should have ReasoningMessageContent"); assertTrue( - thinkingContent.delta().contains("think about this problem"), + reasoningMessageContent.delta().contains("think about this problem"), "Should contain thinking content"); } @Test void testRunWithStreamingThinkingBlockEvents() { + // Test streaming reasoning events when enabled + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + // Simulate streaming thinking: multiple events with same message ID Msg thinkingChunk1 = Msg.builder() @@ -594,35 +641,31 @@ void testRunWithStreamingThinkingBlockEvents() { .messages(List.of(AguiMessage.userMessage("msg-1", "Hi"))) .build(); - List events = adapter.run(input).collectList().block(); + List events = adapterWithReasoning.run(input).collectList().block(); assertNotNull(events); - // Count TextMessageContent events for thinking - should have 2 (one for each chunk) - long thinkingContentCount = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageContent) - .map(e -> (AguiEvent.TextMessageContent) e) - .filter(e -> e.messageId().endsWith("-thinking")) - .count(); + // Count ReasoningMessageContent events - should have 2 (one for each chunk) + long reasoningMessageContentCount = + events.stream().filter(e -> e instanceof AguiEvent.ReasoningMessageContent).count(); assertEquals( - 2, thinkingContentCount, "Should have 2 content events for streaming thinking"); + 2, + reasoningMessageContentCount, + "Should have 2 reasoning message content events for streaming"); - // Should only have 1 TextMessageStart for thinking (same message ID) - long thinkingStartCount = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .filter(e -> e.messageId().endsWith("-thinking")) - .count(); + // Should only have 1 ReasoningStart (same reasoning ID) + long reasoningStartCount = + events.stream().filter(e -> e instanceof AguiEvent.ReasoningStart).count(); assertEquals( - 1, - thinkingStartCount, - "Should have only 1 start event for same thinking message ID"); + 1, reasoningStartCount, "Should have only 1 start event for same reasoning ID"); } @Test void testRunWithThinkingAndTextMixedContent() { + // Test reasoning and text mixed content when enabled + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + // Message with both thinking and text Msg mixedMsg = Msg.builder() @@ -649,43 +692,34 @@ void testRunWithThinkingAndTextMixedContent() { .messages(List.of(AguiMessage.userMessage("msg-1", "Question?"))) .build(); - List events = adapter.run(input).collectList().block(); + List events = adapterWithReasoning.run(input).collectList().block(); assertNotNull(events); - // Should have thinking message events - boolean hasThinkingStart = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .anyMatch(e -> e.messageId().endsWith("-thinking")); - boolean hasThinkingContent = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageContent) - .map(e -> (AguiEvent.TextMessageContent) e) - .anyMatch(e -> e.messageId().endsWith("-thinking")); + // Should have reasoning events + boolean hasReasoningMessageStart = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageStart); + boolean hasReasoningMessageContent = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageContent); // Should have regular text message events boolean hasTextStart = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .anyMatch(e -> !e.messageId().endsWith("-thinking")); + events.stream().anyMatch(e -> e instanceof AguiEvent.TextMessageStart); boolean hasTextContent = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageContent) - .map(e -> (AguiEvent.TextMessageContent) e) - .anyMatch(e -> !e.messageId().endsWith("-thinking")); + events.stream().anyMatch(e -> e instanceof AguiEvent.TextMessageContent); - assertTrue(hasThinkingStart, "Should have TextMessageStart for thinking"); - assertTrue(hasThinkingContent, "Should have TextMessageContent for thinking"); + assertTrue(hasReasoningMessageStart, "Should have ReasoningMessageStart"); + assertTrue(hasReasoningMessageContent, "Should have ReasoningMessageContent"); assertTrue(hasTextStart, "Should have TextMessageStart for text"); assertTrue(hasTextContent, "Should have TextMessageContent for text"); } @Test void testRunWithEmptyThinkingBlock() { - // Empty thinking block should be skipped + // Empty thinking block should be skipped even when enabled + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + Msg reasoningMsg = Msg.builder() .id("msg-r1") @@ -704,23 +738,25 @@ void testRunWithEmptyThinkingBlock() { .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) .build(); - List events = adapter.run(input).collectList().block(); + List events = adapterWithReasoning.run(input).collectList().block(); assertNotNull(events); - // Should NOT have any thinking message events for empty thinking - boolean hasThinkingStart = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .anyMatch(e -> e.messageId().endsWith("-thinking")); + // Should NOT have any reasoning events for empty thinking + boolean hasReasoningMessageStart = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageStart); - assertTrue(!hasThinkingStart, "Should NOT have TextMessageStart for empty thinking"); + assertTrue( + !hasReasoningMessageStart, + "Should NOT have ReasoningMessageStart for empty thinking"); } @Test void testRunWithThinkingBlockLastEvent() { - // Test the isLast() == true branch for ThinkingBlock + // Test the isLast() == true branch for ThinkingBlock when enabled + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + Msg reasoningMsg = Msg.builder() .id("msg-r1") @@ -739,44 +775,42 @@ void testRunWithThinkingBlockLastEvent() { .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) .build(); - List events = adapter.run(input).collectList().block(); + List events = adapterWithReasoning.run(input).collectList().block(); assertNotNull(events); - // Should have TextMessageStart and TextMessageEnd (not TextMessageContent) - AguiEvent.TextMessageStart thinkingStart = + // Should have ReasoningMessageStart and ReasoningMessageEnd + AguiEvent.ReasoningMessageStart reasoningMessageStart = events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .filter(e -> e.messageId().endsWith("-thinking")) + .filter(e -> e instanceof AguiEvent.ReasoningMessageStart) + .map(e -> (AguiEvent.ReasoningMessageStart) e) .findFirst() .orElse(null); - assertNotNull(thinkingStart, "Should have TextMessageStart for thinking"); + assertNotNull(reasoningMessageStart, "Should have ReasoningMessageStart"); - AguiEvent.TextMessageEnd thinkingEnd = + AguiEvent.ReasoningMessageEnd reasoningMessageEnd = events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageEnd) - .map(e -> (AguiEvent.TextMessageEnd) e) - .filter(e -> e.messageId().endsWith("-thinking")) + .filter(e -> e instanceof AguiEvent.ReasoningMessageEnd) + .map(e -> (AguiEvent.ReasoningMessageEnd) e) .findFirst() .orElse(null); - assertNotNull(thinkingEnd, "Should have TextMessageEnd for thinking when isLast=true"); + assertNotNull(reasoningMessageEnd, "Should have ReasoningMessageEnd when isLast=true"); - // Should NOT have TextMessageContent when isLast=true + // Should NOT have ReasoningMessageContent when isLast=true (content is empty) boolean hasContent = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageContent) - .map(e -> (AguiEvent.TextMessageContent) e) - .anyMatch(e -> e.messageId().endsWith("-thinking")); + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageContent); - assertTrue(!hasContent, "Should NOT have TextMessageContent when isLast=true"); + assertTrue(!hasContent, "Should NOT have ReasoningMessageContent when isLast=true"); } @Test void testRunWithThinkingAndToolCallMixed() { - // Test thinking content mixed with tool call + // Test thinking content mixed with tool call when enabled + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + Msg mixedMsg = Msg.builder() .id("msg-mixed") @@ -804,27 +838,27 @@ void testRunWithThinkingAndToolCallMixed() { .messages(List.of(AguiMessage.userMessage("msg-1", "Weather?"))) .build(); - List events = adapter.run(input).collectList().block(); + List events = adapterWithReasoning.run(input).collectList().block(); assertNotNull(events); - // Should have thinking events - boolean hasThinkingStart = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .anyMatch(e -> e.messageId().endsWith("-thinking")); + // Should have reasoning events + boolean hasReasoningMessageStart = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageStart); // Should have tool call events boolean hasToolStart = events.stream().anyMatch(e -> e instanceof AguiEvent.ToolCallStart); - assertTrue(hasThinkingStart, "Should have thinking message"); + assertTrue(hasReasoningMessageStart, "Should have reasoning message start event"); assertTrue(hasToolStart, "Should have tool call"); } @Test void testRunWithStreamingThinkingBlockLastEvent() { - // Test streaming with last event (isLast=true) + // Test streaming with last event (isLast=true) when enabled + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + Msg thinkingChunk1 = Msg.builder() .id("msg-thinking") @@ -852,31 +886,30 @@ void testRunWithStreamingThinkingBlockLastEvent() { .messages(List.of(AguiMessage.userMessage("msg-1", "Hi"))) .build(); - List events = adapter.run(input).collectList().block(); + List events = adapterWithReasoning.run(input).collectList().block(); assertNotNull(events); - // Should have TextMessageContent for first chunk - long contentCount = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageContent) - .map(e -> (AguiEvent.TextMessageContent) e) - .filter(e -> e.messageId().endsWith("-thinking")) - .count(); - assertEquals(1, contentCount, "Should have 1 content event for first chunk"); - - // Should have TextMessageEnd for last event - boolean hasEnd = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageEnd) - .map(e -> (AguiEvent.TextMessageEnd) e) - .anyMatch(e -> e.messageId().endsWith("-thinking")); - assertTrue(hasEnd, "Should have TextMessageEnd for last event"); + // Should have ReasoningMessageContent for first chunk + long messageContentCount = + events.stream().filter(e -> e instanceof AguiEvent.ReasoningMessageContent).count(); + assertEquals( + 1, + messageContentCount, + "Should have 1 reasoning message content event for first chunk"); + + // Should have ReasoningMessageEnd for last event + boolean hasMessageEnd = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageEnd); + assertTrue(hasMessageEnd, "Should have ReasoningMessageEnd for last event"); } @Test void testRunWithNullThinkingBlock() { // ThinkingBlock with null thinking should be converted to empty string and skipped + AguiAdapterConfig config = AguiAdapterConfig.builder().enableReasoning(true).build(); + AguiAgentAdapter adapterWithReasoning = new AguiAgentAdapter(mockAgent, config); + Msg reasoningMsg = Msg.builder() .id("msg-r1") @@ -895,17 +928,16 @@ void testRunWithNullThinkingBlock() { .messages(List.of(AguiMessage.userMessage("msg-1", "Hello"))) .build(); - List events = adapter.run(input).collectList().block(); + List events = adapterWithReasoning.run(input).collectList().block(); assertNotNull(events); - // Should NOT have any thinking message events for null/empty thinking - boolean hasThinkingStart = - events.stream() - .filter(e -> e instanceof AguiEvent.TextMessageStart) - .map(e -> (AguiEvent.TextMessageStart) e) - .anyMatch(e -> e.messageId().endsWith("-thinking")); + // Should NOT have any reasoning events for null/empty thinking + boolean hasReasoningMessageStart = + events.stream().anyMatch(e -> e instanceof AguiEvent.ReasoningMessageStart); - assertTrue(!hasThinkingStart, "Should NOT have TextMessageStart for null thinking"); + assertTrue( + !hasReasoningMessageStart, + "Should NOT have ReasoningMessageStart for null thinking"); } } 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 4ea2226d3..a2edda5af 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 @@ -37,6 +37,7 @@ * default-agent-id: default * agent-id-header: X-Agent-Id * enable-path-routing: true + * enable-reasoning: false * */ @ConfigurationProperties(prefix = "agentscope.agui") @@ -63,6 +64,15 @@ public class AguiProperties { /** Whether to emit tool call argument events. */ private boolean emitToolCallArgs = true; + /** + * Whether to enable reasoning/thinking content output. + * + *

When enabled, ThinkingBlock content will be converted to REASONING_* events + * according to the AG-UI Reasoning draft specification. Default is false to ensure + * backward compatibility and privacy compliance. + */ + private boolean enableReasoning = false; + /** Default agent ID to use when not specified in the request. */ private String defaultAgentId = "default"; @@ -158,6 +168,14 @@ public void setEmitToolCallArgs(boolean emitToolCallArgs) { this.emitToolCallArgs = emitToolCallArgs; } + public boolean isEnableReasoning() { + return enableReasoning; + } + + public void setEnableReasoning(boolean enableReasoning) { + this.enableReasoning = enableReasoning; + } + public String getDefaultAgentId() { return defaultAgentId; } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java index 05cdc4232..250ecd3b1 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 @@ -89,6 +89,7 @@ public AguiMvcController aguiMvcController( .runTimeout(props.getRunTimeout()) .emitStateEvents(props.isEmitStateEvents()) .emitToolCallArgs(props.isEmitToolCallArgs()) + .enableReasoning(props.isEnableReasoning()) .defaultAgentId(props.getDefaultAgentId()) .build(); 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 882fb933e..09c21082b 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 @@ -92,6 +92,7 @@ public AguiWebFluxHandler aguiWebFluxHandler( .runTimeout(props.getRunTimeout()) .emitStateEvents(props.isEmitStateEvents()) .emitToolCallArgs(props.isEmitToolCallArgs()) + .enableReasoning(props.isEnableReasoning()) .defaultAgentId(props.getDefaultAgentId()) .build(); From 76cb7409879dad66900ee602508fbfbeab26d3b7 Mon Sep 17 00:00:00 2001 From: Karson To Date: Fri, 16 Jan 2026 16:40:10 +0800 Subject: [PATCH 4/4] test case --- .../core/agui/adapter/AguiAgentAdapterTest.java | 10 ++++++---- .../io/agentscope/core/agui/event/AguiEventTest.java | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) 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 760300bed..21a62ac66 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 @@ -653,11 +653,13 @@ void testRunWithStreamingThinkingBlockEvents() { reasoningMessageContentCount, "Should have 2 reasoning message content events for streaming"); - // Should only have 1 ReasoningStart (same reasoning ID) - long reasoningStartCount = - events.stream().filter(e -> e instanceof AguiEvent.ReasoningStart).count(); + // Should only have 1 ReasoningMessageStart (same message ID) + long reasoningMessageStartCount = + events.stream().filter(e -> e instanceof AguiEvent.ReasoningMessageStart).count(); assertEquals( - 1, reasoningStartCount, "Should have only 1 start event for same reasoning ID"); + 1, + reasoningMessageStartCount, + "Should have only 1 start event for same reasoning message ID"); } @Test diff --git a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/event/AguiEventTest.java b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/event/AguiEventTest.java index ff50da789..e47dbc2e4 100644 --- a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/event/AguiEventTest.java +++ b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/event/AguiEventTest.java @@ -758,7 +758,7 @@ void testAllEventTypesExist() { @Test void testEventTypeCount() { - assertEquals(12, AguiEventType.values().length); + assertEquals(18, AguiEventType.values().length); } @Test