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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@
package io.agentscope.core.studio;

import io.agentscope.core.agent.AgentBase;
import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.agent.StreamableAgent;
import io.agentscope.core.message.Msg;
import io.agentscope.core.tracing.TracerRegistry;
import io.agentscope.core.tracing.telemetry.TelemetryTracer;
import java.net.URI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
Expand Down Expand Up @@ -121,6 +126,32 @@ public static void shutdown() {
wsClient = null;
}

/**
* Stream agent events to Studio for real-time visualization and final message persistence.
*
* <p>This method invokes {@link StreamableAgent#stream(Msg, StreamOptions)} on the provided
* agent to obtain a {@link Flux} of {@link Event} instances, then forwards that stream to
* Studio via a {@link StudioStreamingBridge}. The bridge:
* @param agent the {@link StreamableAgent} to run; must not be {@code null}
* @param input the input {@link Msg} for this invocation (may be {@code null} for agents that
* do not require an initial message)
* @param options optional {@link StreamOptions} controlling the agent's streaming behavior;
* when {@code null}, {@link StreamOptions#defaults()} is used
* @return a {@link Mono} that completes when the event stream has been fully forwarded to
* Studio and, if a final message exists, persisted; if Studio is not initialized,
* this returns a {@link Mono#error(Throwable)} with {@link IllegalStateException}
*/
public static Mono<Void> streamToStudio(
StreamableAgent agent, Msg input, StreamOptions options) {
if (!isInitialized() || client == null || wsClient == null) {
return Mono.error(new IllegalStateException("Studio is not initialized"));
}
StreamOptions effective = options != null ? options : StreamOptions.defaults();
Flux<Event> events = agent.stream(input, effective);
StudioStreamingBridge bridge = new StudioStreamingBridge(client, wsClient);
return bridge.forwardToStudio(events);
}
Comment on lines +129 to +153

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streamToStudio(...) has minimal Javadoc and is missing standard tags/behavioral notes (params, return, error conditions) that other public methods in StudioManager include. Please expand the Javadoc to describe parameters (agent/input/options), what events are forwarded, and what the returned Mono represents.

Copilot uses AI. Check for mistakes.

/**
* Builder for configuring and initializing Studio integration.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/*
* 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.studio;

import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.message.Msg;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
* Bridges agent streaming events to AgentScope Studio.
*
* <p>This component subscribes to a {@link reactor.core.publisher.Flux} of
* {@link io.agentscope.core.agent.Event} instances produced by a
* {@code StreamableAgent} and forwards them to Studio using both the HTTP
* {@link StudioClient} and the WebSocket-based {@link StudioWebSocketClient}.
*
* <p>The bridge forwards REASONING, TOOL_RESULT, and SUMMARY events directly to the
* Studio frontend via {@link StudioWebSocketClient#sendStreamEvent(Event)} so that
* intermediate and final reasoning/tool outputs are visible in real time. Non-terminal
* {@link EventType#AGENT_RESULT} events are also streamed as incremental chunks.
*
* <p>The final {@link EventType#AGENT_RESULT} event with {@code isLast == true} is treated as
* a control signal: its {@link Msg} payload is cached for persistence and it triggers
* {@link StudioWebSocketClient#sendStreamCompleted()} but is not forwarded again as a
* streaming chunk. After the stream completes, the selected final {@link Msg} (terminal
* AGENT_RESULT if present, otherwise the last non-terminal AGENT_RESULT) is persisted once
* via {@link StudioClient#pushMessage(Msg)}.
*/
public class StudioStreamingBridge {

private static final Logger logger = LoggerFactory.getLogger(StudioStreamingBridge.class);

private final StudioClient studioClient;
private final StudioWebSocketClient webSocketClient;

/**
* Creates a new streaming bridge for forwarding agent events to Studio.
*
* @param studioClient HTTP client used to persist the final agent message to Studio
* @param webSocketClient WebSocket client used to stream incremental events and
* completion signals to the Studio frontend
*/
public StudioStreamingBridge(StudioClient studioClient, StudioWebSocketClient webSocketClient) {
this.studioClient = Objects.requireNonNull(studioClient, "studioClient must not be null");
this.webSocketClient =
Objects.requireNonNull(webSocketClient, "webSocketClient must not be null");
}
Comment on lines +47 to +65

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class introduces a new public API but lacks class-level Javadoc and constructor Javadoc, while other public Studio integration classes document purpose/usage and public constructors. Please add class-level Javadoc (what is bridged, which events are forwarded, and how completion is signaled) and document the public constructor parameters.

Copilot uses AI. Check for mistakes.

/**
* Forwards a stream of agent events to Studio for real-time visualization and
* final message persistence.
*
* <ol>
* <li>All REASONING, TOOL_RESULT, and SUMMARY events are forwarded to the frontend via
* {@link StudioWebSocketClient#sendStreamEvent(Event)} regardless of the {@code isLast}
* flag so that final reasoning/tool outputs remain visible even when emitted as
* terminal events.</li>
* <li>Non-terminal AGENT_RESULT events ({@code isLast == false}) are also forwarded as
* streaming chunks.</li>
* <li>The terminal AGENT_RESULT event with {@code isLast == true} is used to:
* <ul>
* <li>cache the final {@link Msg} for persistence, and</li>
* <li>trigger {@link StudioWebSocketClient#sendStreamCompleted()}.</li>
* </ul>
* It is not sent again as a streaming chunk.</li>
* <li>If a final {@link Msg} exists, it is persisted once via
* {@link StudioClient#pushMessage(Msg)} after the stream completes.</li>
* </ol>
*
* <p>Note: {@code isLast} is treated purely as a control flag for AGENT_RESULT and does not
* generate additional visible content.</p>
*/
public Mono<Void> forwardToStudio(Flux<Event> eventFlux) {
return Mono.defer(
() -> {
FinalMsgHolder holder = new FinalMsgHolder();

return eventFlux
.doOnNext(event -> handleEvent(event, holder))
.doOnError(
ex ->
logger.error(
"Error occurred during streaming to Studio",
ex))
.doFinally(
signalType -> {
if (!holder.completedSignalSent) {
try {
webSocketClient.sendStreamCompleted();
} catch (Exception e) {
logger.error(
"Failed to send streamCompleted in"
+ " doFinally",
e);
}
holder.completedSignalSent = true;
}
})
.then(
Mono.defer(
() -> {
Msg finalMsg = holder.getEffectiveFinalMsg();
if (finalMsg == null) {
logger.debug(
"No final Msg determined from stream;"
+ " skip pushMessage");
return Mono.empty();
}
return studioClient.pushMessage(finalMsg);
}));
});
}

/**
* Handles individual events from the stream.
*/
private void handleEvent(Event event, FinalMsgHolder holder) {
if (event == null) {
return;
}

EventType type = event.getType();
Msg msg = event.getMessage();
boolean isLast = event.isLast();

if (type == EventType.AGENT_RESULT && msg != null) {
holder.lastAgentResultMsg = msg;
}

if (type == EventType.AGENT_RESULT && isLast) {
if (msg != null) {
holder.terminalAgentResultMsg = msg;
}
if (!holder.completedSignalSent) {
webSocketClient.sendStreamCompleted();
holder.completedSignalSent = true;
}
return;
}

if (type == EventType.AGENT_RESULT && !isLast) {
webSocketClient.sendStreamEvent(event);
return;
}

if (type == EventType.REASONING
|| type == EventType.TOOL_RESULT
|| type == EventType.SUMMARY) {
webSocketClient.sendStreamEvent(event);
}
Comment on lines +159 to +168

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleEvent currently forwards REASONING / TOOL_RESULT / SUMMARY only when isLast == false. However, the core streaming implementation emits the final reasoning/tool messages with isLast == true (and may emit no chunks at all depending on StreamOptions), so these outputs can be dropped entirely. Consider forwarding these event types regardless of isLast, and reserve the special terminal-handling logic for the final AGENT_RESULT that ends the stream.

Copilot uses AI. Check for mistakes.
}

private static class FinalMsgHolder {
private Msg terminalAgentResultMsg;
private Msg lastAgentResultMsg;
private boolean completedSignalSent;

Msg getEffectiveFinalMsg() {
if (terminalAgentResultMsg != null) {
return terminalAgentResultMsg;
}
if (lastAgentResultMsg != null) {
return lastAgentResultMsg;
}
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
*/
package io.agentscope.core.studio;

import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.EventType;
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.util.JsonUtils;
import io.socket.client.IO;
import io.socket.client.Socket;
Expand Down Expand Up @@ -395,4 +401,127 @@ public Map<String, Object> getStructuredInput() {
return structuredInput;
}
}

/**
* Sends a stream event to Studio in real-time.
*
* <p>This method delivers best-effort, non-guaranteed notifications over the
* existing Socket.IO connection. If the underlying WebSocket is {@code null}
* or not connected, the method logs a warning and returns without throwing
* an exception and without attempting any reconnection.
*
* @param event the agent event to forward to Studio; if {@code null}, the
* call is ignored and no payload is emitted
*/
public void sendStreamEvent(Event event) {
if (socket == null || !socket.connected()) {
logger.warn("Attempted to send stream event while WebSocket is not connected");
return;
Comment on lines +405 to +419

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendStreamEvent/sendStreamCompleted Javadocs don’t follow the conventions used in this module (missing @param/@return details and behavioral notes such as what happens when the socket is disconnected). Please expand the Javadoc to include parameter description(s) and clarify whether these calls are best-effort or guaranteed delivery.

Copilot uses AI. Check for mistakes.
}
if (event == null) {
return;
}

try {
JSONObject payload = new JSONObject();
EventType type = event.getType();
payload.put("eventType", type != null ? type.name() : "UNKNOWN");
payload.put("isLast", event.isLast());

Msg msg = event.getMessage();
if (msg != null) {
String text = extractTextFromMsg(msg);
if (text != null) {
payload.put("text", text);
}
if (msg.getRole() != null) {
payload.put("role", msg.getRole().name());
}
if (msg.getName() != null) {
payload.put("name", msg.getName());
}
if (msg.getMetadata() != null) {
String metadataJson = JsonUtils.getJsonCodec().toJson(msg.getMetadata());
payload.put("metadata", new JSONObject(metadataJson));
}
}

socket.emit("studioStreamEvent", payload);
} catch (Exception e) {
logger.error("Failed to send stream event to Studio", e);
}
}

/**
* Sends a stream completed event to Studio.
*
* <p>This method delivers a best-effort completion signal over the
* existing Socket.IO connection. If the underlying WebSocket is {@code null}
* or not connected, the method logs a warning and returns without throwing
* an exception and without attempting any reconnection.
*/
public void sendStreamCompleted() {
if (socket == null || !socket.connected()) {
logger.warn("Attempted to send streamCompleted while WebSocket is not connected");
return;
}
try {
JSONObject payload = new JSONObject();
payload.put("status", "completed");
socket.emit("studioStreamCompleted", payload);
} catch (Exception e) {
logger.error("Failed to send streamCompleted event to Studio", e);
}
}

/**
* Extracts text content from a Msg for streaming to Studio.
*/
private String extractTextFromMsg(Msg msg) {
if (msg == null || msg.getContent() == null || msg.getContent().isEmpty()) {
return null;
}

StringBuilder builder = new StringBuilder();
for (Object block : msg.getContent()) {
if (!(block instanceof ContentBlock)) {
continue;
}
if (block instanceof TextBlock) {
TextBlock tb = (TextBlock) block;
if (tb.getText() != null && !tb.getText().isEmpty()) {
appendWithNewline(builder, tb.getText());
}
} else if (block instanceof ThinkingBlock) {
ThinkingBlock tb = (ThinkingBlock) block;
if (tb.getThinking() != null && !tb.getThinking().isEmpty()) {
appendWithNewline(builder, tb.getThinking());
}
} else if (block instanceof ToolResultBlock) {
ToolResultBlock trb = (ToolResultBlock) block;
for (ContentBlock outputBlock : trb.getOutput()) {
if (outputBlock instanceof TextBlock) {
TextBlock otb = (TextBlock) outputBlock;
if (otb.getText() != null && !otb.getText().isEmpty()) {
appendWithNewline(builder, otb.getText());
}
} else if (outputBlock instanceof ThinkingBlock) {
ThinkingBlock otb = (ThinkingBlock) outputBlock;
if (otb.getThinking() != null && !otb.getThinking().isEmpty()) {
appendWithNewline(builder, otb.getThinking());
}
}
}
}
}
Comment on lines +480 to +516

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractTextFromMsg only reads the first content block and only supports TextBlock. In actual streaming events, reasoning commonly uses ThinkingBlock and tool results use ToolResultBlock (with text nested in its output blocks), so Studio will often receive stream events with no text field. Update extraction to handle the relevant block types (and consider concatenating multiple text-bearing blocks) so streamed reasoning/tool output is visible.

Copilot uses AI. Check for mistakes.

return builder.length() > 0 ? builder.toString() : null;
}

private void appendWithNewline(StringBuilder builder, String text) {
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(text);
}
}
Loading
Loading