diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 5ffca7e75b..c716df9720 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -2810,7 +2810,8 @@ private Mono acting(int iter) { MiddlewareBase::onActing, actingCore) .apply(new ActingInput(toolCalls)); - return stream.doOnNext( + return stream.doOnNext(this::publishEvent) + .doOnNext( ev -> { if (ev instanceof RequestStopEvent rs) { actingStopRequested.compareAndSet(null, rs); @@ -2950,8 +2951,7 @@ Flux actingStream( new RequestStopEvent( "permission asking", GenerateReason.PERMISSION_ASKING)); - }) - .doOnNext(this::publishEvent); + }); } /** @@ -3911,7 +3911,8 @@ private Mono emitAllToolsDeniedThroughMiddleware( core) .apply(new ActingInput(deniedToolCalls)); - return stream.doOnNext( + return stream.doOnNext(this::publishEvent) + .doOnNext( ev -> { if (ev instanceof RequestStopEvent rs) { stopRef.compareAndSet(null, rs); diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java index 98a594ae8b..7b3f6a9ebd 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java @@ -23,6 +23,22 @@ */ public class AgentEndEvent extends AgentEvent { + /** + * Metadata key describing whether a synthesized invocation end succeeded, failed, or cancelled. + * + *

It is written by the producer that synthesizes an end on behalf of a forwarded invocation — + * for example the harness spawn tool, which tags every subagent end with {@link #OUTCOME_SUCCESS}, + * {@link #OUTCOME_ERROR} or {@link #OUTCOME_CANCELLED}. {@link + * AgentEventStreams#withTextOutputDisposition} only treats a subagent end as a normal completion + * when this key is present and set to {@link #OUTCOME_SUCCESS}, so a producer that synthesizes + * subagent ends should set it to keep those replies classifiable. + */ + public static final String METADATA_INVOCATION_OUTCOME = "invocationOutcome"; + + public static final String OUTCOME_SUCCESS = "success"; + public static final String OUTCOME_ERROR = "error"; + public static final String OUTCOME_CANCELLED = "cancelled"; + private final String replyId; @JsonCreator diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java index 041dab783a..7efd313e4f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java @@ -29,6 +29,10 @@ * *

Each event carries a unique ID, creation timestamp, and type discriminator. * Events are emitted during agent execution and can be consumed via reactive streams. + * + *

The {@code type} discriminator resolves through the types registered below, and that set grows + * as the library evolves. A consumer can only deserialize the type ids its own revision knows about, + * so it must not assume it can read back everything a newer producer is able to emit. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) @@ -37,6 +41,8 @@ @JsonSubTypes.Type(value = AgentStartEvent.class, name = "AGENT_START"), @JsonSubTypes.Type(value = AgentEndEvent.class, name = "AGENT_END"), @JsonSubTypes.Type(value = AgentResultEvent.class, name = "AGENT_RESULT"), + // Derived event added after the initial event set: older readers cannot resolve this id. + @JsonSubTypes.Type(value = TextOutputDispositionEvent.class, name = "TEXT_OUTPUT_DISPOSITION"), @JsonSubTypes.Type(value = ModelCallStartEvent.class, name = "MODEL_CALL_START"), @JsonSubTypes.Type(value = ModelCallEndEvent.class, name = "MODEL_CALL_END"), @JsonSubTypes.Type(value = TextBlockStartEvent.class, name = "TEXT_BLOCK_START"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java new file mode 100644 index 0000000000..80aa763d0a --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java @@ -0,0 +1,254 @@ +/* + * 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.event; + +import io.agentscope.core.internal.stream.ReplyLifecycleTracker; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.Observation; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.ReplySnapshot; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.SourceKey; +import io.agentscope.core.message.GenerateReason; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +/** Utilities for deriving optional lifecycle signals from an {@link AgentEvent} stream. */ +public final class AgentEventStreams { + + private AgentEventStreams() {} + + /** + * Adds text output disposition events without changing the source stream itself. + * + *

State is isolated per subscription and per {@code source + taskId}. The authoritative + * invocation result remains {@link AgentResultEvent}; a terminal disposition only closes the + * last visible reply before a normally completed {@link AgentEndEvent}. + * + *

The wrapper is purely additive and never fails the source stream. An {@link + * AgentEndEvent} is forwarded as soon as it is observed, preceded by the derived terminal + * disposition when one applies, and any event that arrives after the end of a source is + * forwarded unchanged rather than rejected. Per-source bookkeeping is reclaimed when the source + * ends, so a subscription that fans out to many subagents does not retain them. + * + *

A top-level terminal requires an authoritative {@link AgentResultEvent} for the same + * source. A subagent terminal additionally requires a synthesized {@link AgentEndEvent} that + * explicitly reports {@link AgentEndEvent#OUTCOME_SUCCESS}: an unknown or abnormal outcome never + * closes the reply. + * + * @param source source event stream + * @return a deferred stream containing the original events and derived disposition events + */ + public static Flux withTextOutputDisposition(Flux source) { + Objects.requireNonNull(source, "source"); + return Flux.defer(() -> new DispositionAnnotator().apply(source)); + } + + private static final Logger log = LoggerFactory.getLogger(AgentEventStreams.class); + + static final class DispositionAnnotator { + + private final ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + private final Map authoritativeResults = new LinkedHashMap<>(); + private final Set endedSources = new HashSet<>(); + + Flux apply(Flux source) { + return source.concatMap(event -> Flux.fromIterable(process(event)), 1); + } + + /** + * Number of sources whose per-subscription bookkeeping is still retained. Package private so + * regression tests can assert that ended sources are reclaimed. + */ + int retainedSourceCount() { + return endedSources.size() + authoritativeResults.size() + tracker.trackedSourceCount(); + } + + private List process(AgentEvent event) { + SourceKey sourceKey = tracker.sourceKey(event); + if (endedSources.contains(sourceKey)) { + if (event instanceof ModelCallStartEvent) { + // A new model call on a closed source starts a fresh invocation. + endedSources.remove(sourceKey); + } else { + // A disposition is an opt-in, additive signal, so an ordering anomaly must + // never fail the wrapped stream: forward the event without classifying it. + log.warn( + "Received {} after AgentEndEvent for source {}; forwarding it without " + + "a disposition classification", + event.getType(), + sourceKey); + return List.of(event); + } + } + + Observation observation = tracker.observe(event); + return switch (observation.kind()) { + case MODEL_CALL_START -> onModelCallStart(event, observation); + case TOOL_CALL_START -> onToolCallStart(event, observation); + case TEXT_BLOCK_END -> onTextBlockEnd(event, observation); + case AGENT_RESULT -> onAgentResult((AgentResultEvent) event, observation); + case AGENT_END -> onAgentEnd((AgentEndEvent) event, observation); + default -> List.of(event); + }; + } + + private List onAgentResult(AgentResultEvent event, Observation observation) { + // Only the top-level terminal consults the authoritative result; a subagent terminal is + // derived from its own end event. Recording child results would retain every subagent + // that never reports an end for the life of the subscription. + if (observation.sourceKey().isTopLevel()) { + authoritativeResults.put(observation.sourceKey(), event); + } + return List.of(event); + } + + private List onModelCallStart(AgentEvent event, Observation observation) { + ReplySnapshot previous = observation.before(); + if (hasUnclassifiedText(previous)) { + return List.of( + disposition( + previous.replyId(), + TextOutputDisposition.INTERMEDIATE, + null, + event), + event); + } + return List.of(event); + } + + private List onToolCallStart(AgentEvent event, Observation observation) { + ReplySnapshot current = observation.after(); + if (observation.currentReplyEvent() && hasUnclassifiedText(current)) { + tracker.markDispositionEmitted(observation.sourceKey()); + return List.of( + disposition( + current.replyId(), TextOutputDisposition.INTERMEDIATE, null, event), + event); + } + return List.of(event); + } + + private List onTextBlockEnd(AgentEvent event, Observation observation) { + ReplySnapshot current = observation.after(); + if (observation.currentReplyEvent() + && current.toolCallSeen() + && hasUnclassifiedText(current)) { + tracker.markDispositionEmitted(observation.sourceKey()); + return List.of( + event, + disposition( + current.replyId(), + TextOutputDisposition.INTERMEDIATE, + null, + event)); + } + return List.of(event); + } + + private List onAgentEnd(AgentEndEvent event, Observation observation) { + SourceKey sourceKey = observation.sourceKey(); + + ReplySnapshot current = observation.after(); + AgentResultEvent result = authoritativeResults.remove(sourceKey); + List output = new ArrayList<>(2); + if (hasUnclassifiedText(current)) { + if (sourceKey.isTopLevel()) { + // ReActAgent emits the authoritative result immediately before the end event, + // so the top-level terminal disposition can be derived inline as well. + if (!isAbnormalEnd(event) && result != null && result.getResult() != null) { + output.add( + disposition( + current.replyId(), + TextOutputDisposition.TERMINAL, + result.getResult().getGenerateReason(), + event)); + tracker.markDispositionEmitted(sourceKey); + } + } else if (isSuccessfulChildEnd(event)) { + // A synthesized child end only closes the reply when it explicitly reports + // success, so an unknown or abnormal outcome never yields a terminal. + output.add( + disposition( + current.replyId(), + TextOutputDisposition.TERMINAL, + null, + event)); + tracker.markDispositionEmitted(sourceKey); + } + } + output.add(event); + tracker.clearSource(sourceKey); + if (sourceKey.isTopLevel()) { + // The top-level key is reused by the next user turn, so one tombstone is enough: + // the next model call reopens it and trailing events stay gated until then. + endedSources.add(sourceKey); + } else { + // Child keys are minted per invocation, so a tombstone each would retain every + // subagent this subscription has ever seen. The reply state is reclaimed above and + // a reused key simply starts a fresh source. + endedSources.remove(sourceKey); + } + return output; + } + + private static Object invocationOutcome(AgentEndEvent end) { + return end.getMetadata() == null + ? null + : end.getMetadata().get(AgentEndEvent.METADATA_INVOCATION_OUTCOME); + } + + private static boolean isSuccessfulChildEnd(AgentEndEvent end) { + Object outcome = invocationOutcome(end); + return outcome != null && AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString()); + } + + private static boolean isAbnormalEnd(AgentEndEvent end) { + Object outcome = invocationOutcome(end); + return outcome != null && !AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString()); + } + + private static boolean hasUnclassifiedText(ReplySnapshot snapshot) { + return snapshot.replyId() != null + && snapshot.textSeen() + && !snapshot.dispositionEmitted(); + } + + private static TextOutputDispositionEvent disposition( + String replyId, + TextOutputDisposition disposition, + GenerateReason generateReason, + AgentEvent trigger) { + TextOutputDispositionEvent event = + new TextOutputDispositionEvent(replyId, disposition, generateReason); + event.withSource(trigger.getSource()); + // Carry the correlation keys only: metadata on the trigger describes the triggering + // invocation (for example its outcome), not this derived classification. + if (trigger.getMetadata() != null) { + Object taskId = trigger.getMetadata().get(AgentEvent.METADATA_TASK_ID); + if (taskId != null) { + event.withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId); + } + } + return event; + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java index 69cd2f046f..ce51a1483d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java @@ -43,6 +43,7 @@ public enum AgentEventType { @JsonAlias({"RUN_FINISHED", "REPLY_END"}) AGENT_END("AGENT_END"), AGENT_RESULT("AGENT_RESULT"), + TEXT_OUTPUT_DISPOSITION("TEXT_OUTPUT_DISPOSITION"), @JsonAlias({"MODEL_CALL_STARTED"}) MODEL_CALL_START("MODEL_CALL_START"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDisposition.java b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDisposition.java new file mode 100644 index 0000000000..23430ddcee --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDisposition.java @@ -0,0 +1,22 @@ +/* + * 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.event; + +/** Describes whether a streamed model reply is intermediate or terminal for an invocation. */ +public enum TextOutputDisposition { + INTERMEDIATE, + TERMINAL +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java new file mode 100644 index 0000000000..1559419e93 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.event; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.agentscope.core.message.GenerateReason; +import java.util.Objects; + +/** + * Classifies an already-streamed model reply as intermediate or terminal for its agent invocation. + * + *

A terminal disposition is a lifecycle signal, not an authoritative final answer. Consumers + * must use {@link AgentResultEvent} for the invocation result. + * + *

Serialization compatibility. This type was added after the initial event set, + * so it is registered additively in {@link AgentEvent}'s type discriminator. A consumer built against + * an older revision cannot resolve the {@code TEXT_OUTPUT_DISPOSITION} type id and will fail to + * deserialize it. Producers therefore must not persist or replay these derived events into an event + * log that older consumers read back: drop them before persistence, or require the reader to be at + * least as new as the writer. + */ +public final class TextOutputDispositionEvent extends AgentEvent { + + private final String replyId; + private final TextOutputDisposition disposition; + private final GenerateReason generateReason; + + public TextOutputDispositionEvent( + String replyId, TextOutputDisposition disposition, GenerateReason generateReason) { + this.replyId = replyId; + this.disposition = Objects.requireNonNull(disposition, "disposition"); + this.generateReason = generateReason; + } + + @JsonCreator + public TextOutputDispositionEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("replyId") String replyId, + @JsonProperty("disposition") TextOutputDisposition disposition, + @JsonProperty("generateReason") GenerateReason generateReason) { + super(id, createdAt); + this.replyId = replyId; + this.disposition = Objects.requireNonNull(disposition, "disposition"); + this.generateReason = generateReason; + } + + @Override + public AgentEventType getType() { + return AgentEventType.TEXT_OUTPUT_DISPOSITION; + } + + public String getReplyId() { + return replyId; + } + + public TextOutputDisposition getDisposition() { + return disposition; + } + + public GenerateReason getGenerateReason() { + return generateReason; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java new file mode 100644 index 0000000000..d237d4bb36 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java @@ -0,0 +1,288 @@ +/* + * 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.internal.stream; + +import io.agentscope.core.event.AgentEndEvent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentResultEvent; +import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.ModelCallStartEvent; +import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextBlockEndEvent; +import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.ToolCallStartEvent; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Internal state tracker shared by stream annotators and middleware that reason about model replies. + * + *

This type tracks only reply lifecycle: which reply is current for a source, whether visible text + * or a tool call has been seen for it, and whether a disposition has already been emitted. Correlating + * the authoritative {@link AgentResultEvent} with a source is the caller's responsibility. + * + *

It is public only so internal components in different packages (for example {@code + * io.agentscope.core.middleware}) can share one set of reply/source correlation rules. It is not part + * of the supported API surface and may change without notice; do not use it outside this project. + */ +public final class ReplyLifecycleTracker { + + static final int MAX_TRACKED_CHILD_SOURCES = 4096; + + public enum EventKind { + MODEL_CALL_START, + MODEL_CALL_END, + TEXT_BLOCK_START, + TEXT_BLOCK_DELTA, + TEXT_BLOCK_END, + TOOL_CALL_START, + AGENT_RESULT, + AGENT_END, + OTHER + } + + public record SourceKey(String source, String taskId) { + + public SourceKey { + source = source == null ? "" : source; + taskId = taskId == null ? "" : taskId; + } + + public static SourceKey topLevel() { + return new SourceKey("", ""); + } + + public boolean isTopLevel() { + return source.isEmpty() && taskId.isEmpty(); + } + } + + public record ReplySnapshot( + String replyId, boolean textSeen, boolean toolCallSeen, boolean dispositionEmitted) {} + + public record Observation( + SourceKey sourceKey, + EventKind kind, + String eventReplyId, + boolean currentReplyEvent, + ReplySnapshot before, + ReplySnapshot after) {} + + // Keep the active top-level reply outside the child cap so fan-out cannot evict it. + private ReplyState topLevelState; + + private final Map childStates = + new LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_TRACKED_CHILD_SOURCES; + } + }; + + public SourceKey sourceKey(AgentEvent event) { + Objects.requireNonNull(event, "event"); + Object taskId = + event.getMetadata() == null + ? null + : event.getMetadata().get(AgentEvent.METADATA_TASK_ID); + return new SourceKey(event.getSource(), taskId == null ? null : taskId.toString()); + } + + public Observation observe(AgentEvent event) { + Objects.requireNonNull(event, "event"); + SourceKey sourceKey = sourceKey(event); + ReplyState state = state(sourceKey); + ReplySnapshot before = state != null ? state.snapshot() : ReplyState.emptySnapshot(); + EventKind kind = eventKind(event); + String eventReplyId = replyId(event); + boolean currentReplyEvent = + state != null + && eventReplyId != null + && Objects.equals(state.replyId, eventReplyId); + + switch (kind) { + case MODEL_CALL_START -> { + if (state == null) { + state = new ReplyState(); + storeState(sourceKey, state); + } + state.replyId = eventReplyId; + state.textSeen = false; + state.toolCallSeen = false; + state.dispositionEmitted = false; + currentReplyEvent = true; + } + case TEXT_BLOCK_DELTA -> { + if (state != null + && currentReplyEvent + && event instanceof TextBlockDeltaEvent delta + && delta.getDelta() != null + && !delta.getDelta().isEmpty()) { + state.textSeen = true; + } + } + case TOOL_CALL_START -> { + if (state != null && currentReplyEvent) { + state.toolCallSeen = true; + } + } + default -> { + // The remaining event kinds do not mutate shared reply state. + } + } + + return new Observation( + sourceKey, + kind, + eventReplyId, + currentReplyEvent, + before, + state != null ? state.snapshot() : ReplyState.emptySnapshot()); + } + + public ReplySnapshot snapshot(SourceKey sourceKey) { + ReplyState state = state(sourceKey); + return state == null ? ReplyState.emptySnapshot() : state.snapshot(); + } + + public void markDispositionEmitted(SourceKey sourceKey) { + ReplyState state = state(sourceKey); + if (state != null) { + state.dispositionEmitted = true; + } + } + + /** + * Number of sources currently holding reply state. + * + *

The active top-level reply is tracked outside the bounded child-source map, so this count + * can exceed {@link #MAX_TRACKED_CHILD_SOURCES} by one. + * + *

Public only so internal stream annotators can account for all retained bookkeeping. Not part + * of the supported API surface. + */ + public int trackedSourceCount() { + return (topLevelState != null ? 1 : 0) + childStates.size(); + } + + public void clearReply(SourceKey sourceKey) { + ReplyState state = state(sourceKey); + if (state != null) { + state.replyId = null; + state.textSeen = false; + state.toolCallSeen = false; + state.dispositionEmitted = false; + } + } + + public void clearSource(SourceKey sourceKey) { + if (sourceKey != null && sourceKey.isTopLevel()) { + topLevelState = null; + } else { + childStates.remove(sourceKey); + } + } + + public void clear() { + topLevelState = null; + childStates.clear(); + } + + private ReplyState state(SourceKey sourceKey) { + if (sourceKey == null) { + return null; + } + return sourceKey.isTopLevel() ? topLevelState : childStates.get(sourceKey); + } + + private void storeState(SourceKey sourceKey, ReplyState state) { + Objects.requireNonNull(sourceKey, "sourceKey"); + if (sourceKey.isTopLevel()) { + topLevelState = state; + } else { + childStates.put(sourceKey, state); + } + } + + private static EventKind eventKind(AgentEvent event) { + if (event instanceof ModelCallStartEvent) { + return EventKind.MODEL_CALL_START; + } + if (event instanceof ModelCallEndEvent) { + return EventKind.MODEL_CALL_END; + } + if (event instanceof TextBlockStartEvent) { + return EventKind.TEXT_BLOCK_START; + } + if (event instanceof TextBlockDeltaEvent) { + return EventKind.TEXT_BLOCK_DELTA; + } + if (event instanceof TextBlockEndEvent) { + return EventKind.TEXT_BLOCK_END; + } + if (event instanceof ToolCallStartEvent) { + return EventKind.TOOL_CALL_START; + } + if (event instanceof AgentResultEvent) { + return EventKind.AGENT_RESULT; + } + if (event instanceof AgentEndEvent) { + return EventKind.AGENT_END; + } + return EventKind.OTHER; + } + + private static String replyId(AgentEvent event) { + if (event instanceof ModelCallStartEvent modelStart) { + return modelStart.getReplyId(); + } + if (event instanceof ModelCallEndEvent modelEnd) { + return modelEnd.getReplyId(); + } + if (event instanceof TextBlockStartEvent textStart) { + return textStart.getReplyId(); + } + if (event instanceof TextBlockDeltaEvent textDelta) { + return textDelta.getReplyId(); + } + if (event instanceof TextBlockEndEvent textEnd) { + return textEnd.getReplyId(); + } + if (event instanceof ToolCallStartEvent toolStart) { + return toolStart.getReplyId(); + } + if (event instanceof AgentEndEvent agentEnd) { + return agentEnd.getReplyId(); + } + return null; + } + + private static final class ReplyState { + private String replyId; + private boolean textSeen; + private boolean toolCallSeen; + private boolean dispositionEmitted; + + private ReplySnapshot snapshot() { + return new ReplySnapshot(replyId, textSeen, toolCallSeen, dispositionEmitted); + } + + private static ReplySnapshot emptySnapshot() { + return new ReplySnapshot(null, false, false, false); + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java b/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java index 422bb3df8a..a0b367973c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java +++ b/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java @@ -24,9 +24,10 @@ import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; import io.agentscope.core.event.ToolCallStartEvent; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.Observation; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.function.Function; import reactor.core.publisher.Flux; @@ -66,37 +67,34 @@ private static boolean isTextBlockEvent(AgentEvent event) { private static final class RoundState { private final List bufferedTextEvents = new ArrayList<>(); - private String replyId; - private boolean toolCallSeen; + private final ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); private Flux handle(AgentEvent event) { - if (event instanceof ModelCallStartEvent start) { - replyId = start.getReplyId(); - toolCallSeen = false; + Observation observation = tracker.observe(event); + + if (event instanceof ModelCallStartEvent) { bufferedTextEvents.clear(); return Flux.just(event); } if (isTextBlockEvent(event)) { - if (isCurrentReply(event) && !toolCallSeen) { + if (observation.currentReplyEvent() && !observation.after().toolCallSeen()) { bufferedTextEvents.add(event); return Flux.empty(); } - if (isCurrentReply(event)) { + if (observation.currentReplyEvent()) { return Flux.empty(); } return Flux.just(event); } - if (event instanceof ToolCallStartEvent toolCall - && Objects.equals(replyId, toolCall.getReplyId())) { - toolCallSeen = true; + if (event instanceof ToolCallStartEvent && observation.currentReplyEvent()) { bufferedTextEvents.clear(); return Flux.just(event); } - if (event instanceof ModelCallEndEvent end && isCurrentReply(end)) { - if (toolCallSeen) { + if (event instanceof ModelCallEndEvent && observation.currentReplyEvent()) { + if (observation.after().toolCallSeen()) { clear(); return Flux.just(event); } @@ -110,31 +108,9 @@ private Flux handle(AgentEvent event) { return Flux.just(event); } - private boolean isCurrentReply(AgentEvent event) { - String eventReplyId = getReplyId(event); - return replyId != null && Objects.equals(replyId, eventReplyId); - } - - private static String getReplyId(AgentEvent event) { - if (event instanceof TextBlockStartEvent textStart) { - return textStart.getReplyId(); - } - if (event instanceof TextBlockDeltaEvent textDelta) { - return textDelta.getReplyId(); - } - if (event instanceof TextBlockEndEvent textEnd) { - return textEnd.getReplyId(); - } - if (event instanceof ModelCallEndEvent modelEnd) { - return modelEnd.getReplyId(); - } - return null; - } - private void clear() { bufferedTextEvents.clear(); - replyId = null; - toolCallSeen = false; + tracker.clear(); } } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java index ad708896a4..b8938a60c0 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java @@ -26,6 +26,7 @@ import io.agentscope.core.event.RequestStopEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.event.ToolResultStartEvent; import io.agentscope.core.event.UserConfirmResultEvent; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.GenerateReason; @@ -36,6 +37,8 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultState; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.ChatModelBase; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; @@ -49,7 +52,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import java.util.function.Supplier; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -173,6 +178,34 @@ public Mono callAsync(ToolCallParam param) { } } + private static final class DenyingTool extends ToolBase { + DenyingTool(String name) { + super(name, "auto-deny", schemaFor(), true, true, false, null, false, false); + } + + private static Map schemaFor() { + Map schema = new HashMap<>(); + schema.put("type", "object"); + Map props = new HashMap<>(); + Map q = new HashMap<>(); + q.put("type", "string"); + props.put("query", q); + schema.put("properties", props); + return schema; + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + return Mono.just(PermissionDecision.deny("denied by test rule")); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.just(ToolResultBlock.text("must not run")); + } + } + private static Toolkit toolkitWith(ToolBase... tools) { Toolkit tk = new Toolkit(); for (ToolBase t : tools) { @@ -656,4 +689,78 @@ void allowingToolBypassesHitlEntirely() { (ToolResultEndEvent) events.get(indexOf(events, ToolResultEndEvent.class)); assertEquals(ToolResultState.SUCCESS, end.getState()); } + + @Test + void deniedToolResultEventsStayPublishedThroughActingMiddleware() { + List observedByMiddleware = new CopyOnWriteArrayList<>(); + MiddlewareBase recordingMiddleware = + new MiddlewareBase() { + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + return next.apply(input).doOnNext(observedByMiddleware::add); + } + }; + ChatModelBase model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "blocked", "x")), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkitWith(new DenyingTool("blocked"))) + .middleware(recordingMiddleware) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertEquals( + 1, + countOf(events, ToolResultStartEvent.class), + "the denied tool result start must reach the published stream exactly once"); + assertEquals(1, countOf(events, ToolResultEndEvent.class)); + ToolResultEndEvent end = + (ToolResultEndEvent) events.get(indexOf(events, ToolResultEndEvent.class)); + assertEquals("tc1", end.getToolCallId()); + assertEquals(ToolResultState.DENIED, end.getState()); + assertTrue( + observedByMiddleware.stream().anyMatch(ToolResultEndEvent.class::isInstance), + "the acting middleware must observe the same denied result the stream publishes"); + } + + @Test + void askingToolStopPathStaysPublishedThroughActingMiddleware() { + MiddlewareBase passThroughMiddleware = + new MiddlewareBase() { + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + return next.apply(input); + } + }; + ChatModelBase model = + new ScriptedModel(List.of(() -> Flux.just(toolUseResponse("tc1", "ask", "x")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkitWith(new AskingTool("ask"))) + .middleware(passThroughMiddleware) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertEquals(1, countOf(events, RequireUserConfirmEvent.class)); + assertEquals(1, countOf(events, RequestStopEvent.class)); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java index 0b1b4844bc..dcb47ff900 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java @@ -30,6 +30,8 @@ import io.agentscope.core.event.ModelCallStartEvent; import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.ToolCallStartEvent; +import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.event.ToolResultStartEvent; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; @@ -327,6 +329,45 @@ public Flux onReasoning( "core model events must not be published twice"); } + @Test + void actingMiddlewareEventsAreForwardedExactlyOnce() { + MiddlewareBase hintMiddleware = + new MiddlewareBase() { + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + HintBlockEvent hint = + new HintBlockEvent( + "reply-acting-hint", + "block-acting-hint", + "tool-middleware", + "completed"); + return next.apply(input).concatWithValues(hint); + } + }; + ToolThenFinalModel model = new ToolThenFinalModel(); + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(new LookupTool()); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .sysPrompt("hello-system") + .model(model) + .toolkit(toolkit) + .middleware(hintMiddleware) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertEquals(1, events.stream().filter(HintBlockEvent.class::isInstance).count()); + assertEquals(1, events.stream().filter(ToolResultStartEvent.class::isInstance).count()); + assertEquals(1, events.stream().filter(ToolResultEndEvent.class::isInstance).count()); + } + @Test void onionOrderingFollowsRegistrationForReplyHook() { List trace = new CopyOnWriteArrayList<>(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java index 2aa91f88f8..d7a6321ea5 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java @@ -16,12 +16,14 @@ package io.agentscope.core.agent; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.ReActAgent; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventType; import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.ExceedMaxItersEvent; @@ -32,6 +34,7 @@ import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ThinkingBlockEndEvent; import io.agentscope.core.event.ThinkingBlockStartEvent; @@ -174,7 +177,7 @@ public Mono callAsync(ToolCallParam param) { } @Test - void textOnlyReplyEmitsExpectedEventOrder() { + void unwrappedTextOnlyStreamPreservesLegacySequenceWithoutDisposition() { ChatModelBase model = new ScriptedModel(List.of(() -> Flux.just(textResponse("hello world")))); ReActAgent agent = @@ -188,12 +191,18 @@ void textOnlyReplyEmitsExpectedEventOrder() { List events = agent.streamEvents(List.of()).collectList().block(); assertNotNull(events); - assertTrue(events.get(0) instanceof AgentStartEvent); - assertTrue(events.get(events.size() - 1) instanceof AgentEndEvent); - long modelStarts = events.stream().filter(e -> e instanceof ModelCallStartEvent).count(); - long modelEnds = events.stream().filter(e -> e instanceof ModelCallEndEvent).count(); - assertEquals(1L, modelStarts); - assertEquals(1L, modelEnds); + assertEquals( + List.of( + AgentEventType.AGENT_START, + AgentEventType.MODEL_CALL_START, + AgentEventType.TEXT_BLOCK_START, + AgentEventType.TEXT_BLOCK_DELTA, + AgentEventType.TEXT_BLOCK_END, + AgentEventType.MODEL_CALL_END, + AgentEventType.AGENT_RESULT, + AgentEventType.AGENT_END), + events.stream().map(AgentEvent::getType).toList()); + assertFalse(events.stream().anyMatch(TextOutputDispositionEvent.class::isInstance)); } @Test diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java new file mode 100644 index 0000000000..830f623426 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -0,0 +1,700 @@ +/* + * 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.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.message.AssistantMessage; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import reactor.test.publisher.TestPublisher; + +class AgentEventStreamsTest { + + @Test + void realReActAgentClosesLastModelReplyAtInvocationEnd() { + ReActAgent agent = + ReActAgent.builder().name("test-agent").model(new MockModel("answer")).build(); + Msg input = Msg.builder().role(MsgRole.USER).textContent("hello").build(); + + List events = + AgentEventStreams.withTextOutputDisposition(agent.streamEvents(List.of(input))) + .collectList() + .block(); + + ModelCallStartEvent modelStart = + events.stream() + .filter(ModelCallStartEvent.class::isInstance) + .map(ModelCallStartEvent.class::cast) + .findFirst() + .orElseThrow(); + AgentEndEvent agentEnd = + events.stream() + .filter(AgentEndEvent.class::isInstance) + .map(AgentEndEvent.class::cast) + .findFirst() + .orElseThrow(); + TextOutputDispositionEvent terminal = + events.stream() + .filter(TextOutputDispositionEvent.class::isInstance) + .map(TextOutputDispositionEvent.class::cast) + .filter( + disposition -> + disposition.getDisposition() + == TextOutputDisposition.TERMINAL) + .findFirst() + .orElseThrow(); + + assertNotEquals(modelStart.getReplyId(), agentEnd.getReplyId()); + assertEquals(modelStart.getReplyId(), terminal.getReplyId()); + assertEquals(GenerateReason.MODEL_STOP, terminal.getGenerateReason()); + } + + @Test + void emitsResultTerminalThenEndOnNormalCompletion() { + AgentResultEvent result = result(GenerateReason.MODEL_STOP); + AgentEndEvent end = new AgentEndEvent("reply-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + new TextBlockEndEvent("reply-1", "block-1"), + new ModelCallEndEvent("reply-1", null), + result, + end)) + .collectList() + .block(); + + assertEquals(7, events.size()); + assertSame(result, events.get(4)); + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(5)); + assertEquals("reply-1", disposition.getReplyId()); + assertEquals(TextOutputDisposition.TERMINAL, disposition.getDisposition()); + assertEquals(GenerateReason.MODEL_STOP, disposition.getGenerateReason()); + assertSame(end, events.get(6)); + } + + @Test + void emitsIntermediateBeforeToolWhenVisibleTextPrecedesToolCall() { + ToolCallStartEvent tool = new ToolCallStartEvent("reply-1", "call-1", "search"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "checking"), + tool)) + .collectList() + .block(); + + assertEquals(4, events.size()); + assertDisposition(events.get(2), "reply-1", TextOutputDisposition.INTERMEDIATE); + assertSame(tool, events.get(3)); + } + + @Test + void emitsIntermediateAfterTextEndWhenToolCallPrecedesVisibleText() { + TextBlockEndEvent textEnd = new TextBlockEndEvent("reply-1", "block-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new ToolCallStartEvent("reply-1", "call-1", "search"), + new TextBlockDeltaEvent("reply-1", "block-1", "checking"), + textEnd)) + .collectList() + .block(); + + assertEquals(5, events.size()); + assertSame(textEnd, events.get(3)); + assertDisposition(events.get(4), "reply-1", TextOutputDisposition.INTERMEDIATE); + } + + @Test + void emitsIntermediateBeforeNextModelRoundWithoutToolCall() { + ModelCallStartEvent nextRound = new ModelCallStartEvent("reply-2"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "draft"), + new ModelCallEndEvent("reply-1", null), + nextRound)) + .collectList() + .block(); + + assertEquals(5, events.size()); + assertDisposition(events.get(3), "reply-1", TextOutputDisposition.INTERMEDIATE); + assertSame(nextRound, events.get(4)); + } + + @Test + void classifiesEachReplyAtMostOnceAcrossMultipleTextSegments() { + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "first"), + new ToolCallStartEvent("reply-1", "call-1", "search"), + new TextBlockEndEvent("reply-1", "block-1"), + new TextBlockDeltaEvent("reply-1", "block-2", "second"), + new TextBlockEndEvent("reply-1", "block-2"))) + .collectList() + .block(); + + assertEquals( + 1, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void isolatesSameSourceByTaskId() { + AgentEvent taskAStart = tagged(new ModelCallStartEvent("reply-a"), "worker", "task-a"); + AgentEvent taskAText = + tagged( + new TextBlockDeltaEvent("reply-a", "block-a", "draft-a"), + "worker", + "task-a"); + AgentEvent taskBStart = tagged(new ModelCallStartEvent("reply-b"), "worker", "task-b"); + AgentEvent taskBText = + tagged( + new TextBlockDeltaEvent("reply-b", "block-b", "draft-b"), + "worker", + "task-b"); + AgentEvent taskBTool = + tagged(new ToolCallStartEvent("reply-b", "call-b", "search"), "worker", "task-b"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just(taskAStart, taskAText, taskBStart, taskBText, taskBTool)) + .collectList() + .block(); + + assertEquals(6, events.size()); + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(4)); + assertEquals("reply-b", disposition.getReplyId()); + assertEquals("worker", disposition.getSource()); + assertEquals("task-b", disposition.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertSame(taskBTool, events.get(5)); + } + + @Test + void createsIndependentStateForEachSubscription() { + Flux annotated = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "draft"), + new ToolCallStartEvent("reply-1", "call-1", "search"))); + + List first = annotated.map(AgentEvent::getType).collectList().block(); + List second = annotated.map(AgentEvent::getType).collectList().block(); + + assertEquals(first, second); + assertEquals(AgentEventType.TEXT_OUTPUT_DISPOSITION, first.get(2)); + } + + @Test + void emitsTopLevelEndWithoutTerminalWhenNoAuthoritativeResultExists() { + AgentEndEvent end = new AgentEndEvent("reply-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + end)) + .collectList() + .block(); + + assertEquals(3, events.size()); + assertSame(end, events.get(2)); + } + + @Test + void emitsTopLevelEndWithoutTerminalWhenAuthoritativeResultIsNull() { + AgentResultEvent result = new AgentResultEvent(null); + AgentEndEvent end = new AgentEndEvent("reply-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result, + end)) + .collectList() + .block(); + + assertEquals(4, events.size()); + assertSame(result, events.get(2)); + assertSame(end, events.get(3)); + } + + @Test + void emitsTopLevelTerminalAndEndBeforeSourceErrorPropagates() { + RuntimeException failure = new RuntimeException("boom"); + AgentEndEvent end = new AgentEndEvent("reply-1"); + + Flux annotated = + AgentEventStreams.withTextOutputDisposition( + Flux.concat( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result(GenerateReason.MODEL_STOP), + end), + Flux.error(failure))); + + StepVerifier.create(annotated) + .expectNextCount(3) + .expectNextMatches(TextOutputDispositionEvent.class::isInstance) + .expectNext(end) + .expectErrorMatches(error -> error == failure) + .verify(); + } + + @Test + void cancellationAfterResultDoesNotSynthesizeTerminalDisposition() { + TestPublisher source = TestPublisher.create(); + AgentResultEvent result = result(GenerateReason.MODEL_STOP); + + StepVerifier.create(AgentEventStreams.withTextOutputDisposition(source.flux())) + .then( + () -> + source.next( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result)) + .expectNextCount(2) + .expectNext(result) + .thenCancel() + .verify(); + + source.assertCancelled(); + } + + @Test + void emitsTopLevelTerminalAndEndBeforeSourceCompletes() { + TestPublisher source = TestPublisher.create(); + AgentResultEvent result = result(GenerateReason.MODEL_STOP); + AgentEndEvent end = new AgentEndEvent("reply-1"); + + StepVerifier.create(AgentEventStreams.withTextOutputDisposition(source.flux()), 0) + .thenRequest(1) + .then(() -> source.next(new ModelCallStartEvent("reply-1"))) + .expectNextMatches(ModelCallStartEvent.class::isInstance) + .thenRequest(1) + .then(() -> source.next(new TextBlockDeltaEvent("reply-1", "block-1", "answer"))) + .expectNextMatches(TextBlockDeltaEvent.class::isInstance) + .thenRequest(1) + .then(() -> source.next(result)) + .expectNext(result) + .thenRequest(1) + .then(() -> source.next(end)) + .expectNextMatches( + event -> { + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, event); + assertEquals( + TextOutputDisposition.TERMINAL, disposition.getDisposition()); + assertEquals("reply-1", disposition.getReplyId()); + return true; + }) + .thenRequest(1) + .expectNext(end) + .thenCancel() + .verify(); + + source.assertCancelled(); + } + + @Test + void retainsTopLevelReplyWhenChildFanOutExceedsTrackedSourceCapacity() { + AgentEndEvent end = new AgentEndEvent("reply-top"); + List fanOut = + IntStream.rangeClosed(0, 4096) + .mapToObj( + i -> + (AgentEvent) + new ModelCallStartEvent("reply-" + i) + .withSource("source-" + i)) + .toList(); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.concat( + Flux.just(new ModelCallStartEvent("reply-top")), + Flux.fromIterable(fanOut), + Flux.just( + new TextBlockDeltaEvent( + "reply-top", "block-top", "answer"), + result(GenerateReason.MODEL_STOP), + end))) + .collectList() + .block(); + + TextOutputDispositionEvent terminal = + events.stream() + .filter(TextOutputDispositionEvent.class::isInstance) + .map(TextOutputDispositionEvent.class::cast) + .filter( + disposition -> + disposition.getDisposition() + == TextOutputDisposition.TERMINAL) + .findFirst() + .orElseThrow(); + assertEquals("reply-top", terminal.getReplyId()); + assertSame(end, events.get(events.size() - 1)); + } + + @Test + void forwardsLateEventsWithoutFailingOrClassifyingThem() { + AgentEndEvent end = new AgentEndEvent("reply-1"); + AgentResultEvent lateResult = result(GenerateReason.MODEL_STOP); + ToolCallStartEvent lateTool = new ToolCallStartEvent("reply-1", "call-1", "search"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + end, + lateResult, + lateTool)) + .collectList() + .block(); + + assertEquals(5, events.size()); + assertSame(end, events.get(2)); + assertSame(lateResult, events.get(3)); + assertSame(lateTool, events.get(4)); + assertEquals( + 0, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void reopensSourceWhenANewInvocationStartsAfterEnd() { + AgentEndEvent end = new AgentEndEvent("reply-1"); + ToolCallStartEvent nextTool = new ToolCallStartEvent("reply-2", "call-2", "search"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + end, + new ModelCallStartEvent("reply-2"), + new TextBlockDeltaEvent("reply-2", "block-2", "more"), + nextTool)) + .collectList() + .block(); + + assertEquals(7, events.size()); + assertSame(end, events.get(2)); + assertDisposition(events.get(5), "reply-2", TextOutputDisposition.INTERMEDIATE); + assertSame(nextTool, events.get(6)); + } + + @Test + void respectsOneAtATimeDownstreamDemandForDerivedEvents() { + AtomicBoolean completed = new AtomicBoolean(); + AgentEndEvent end = new AgentEndEvent("reply-1"); + Flux annotated = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result(GenerateReason.STRUCTURED_OUTPUT), + end)) + .doOnComplete(() -> completed.set(true)); + + StepVerifier.create(annotated, 0) + .thenRequest(1) + .expectNextMatches(ModelCallStartEvent.class::isInstance) + .thenRequest(1) + .expectNextMatches(TextBlockDeltaEvent.class::isInstance) + .thenRequest(1) + .expectNextMatches(AgentResultEvent.class::isInstance) + .thenRequest(1) + .assertNext( + event -> { + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, event); + assertEquals( + GenerateReason.STRUCTURED_OUTPUT, + disposition.getGenerateReason()); + }) + .then(() -> assertEquals(false, completed.get())) + .thenRequest(1) + .expectNext(end) + .verifyComplete(); + } + + @Test + void childEndImmediatelyClosesVisibleReplyWithTerminalDisposition() { + AgentEndEvent end = successfulChildEnd("reply-1", "worker", "task-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end)) + .collectList() + .block(); + + assertEquals(4, events.size()); + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(2)); + assertEquals(TextOutputDisposition.TERMINAL, disposition.getDisposition()); + assertNull(disposition.getGenerateReason()); + assertEquals("worker", disposition.getSource()); + assertEquals("task-1", disposition.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertSame(end, events.get(3)); + } + + @Test + void childDispositionCarriesCorrelationKeysOnly() { + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + successfulChildEnd("reply-1", "worker", "task-1"))) + .collectList() + .block(); + + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(2)); + assertEquals("task-1", disposition.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertFalse( + disposition.getMetadata().containsKey(AgentEndEvent.METADATA_INVOCATION_OUTCOME), + "the trigger's invocation outcome describes the invocation, not the disposition"); + } + + @Test + void reclaimsEndedChildSources() { + AgentEventStreams.DispositionAnnotator annotator = + new AgentEventStreams.DispositionAnnotator(); + + List events = + annotator + .apply( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + successfulChildEnd("reply-1", "worker", "task-1"), + tagged( + new TextBlockEndEvent("reply-1", "block-1"), + "worker", + "task-1"), + tagged( + new ModelCallStartEvent("reply-2"), + "worker", + "task-2"), + tagged( + new TextBlockDeltaEvent( + "reply-2", "block-2", "answer"), + "worker", + "task-2"), + successfulChildEnd("reply-2", "worker", "task-2"), + // A subagent that reports a result but never an end must + // not + // be retained either. + tagged( + result(GenerateReason.MODEL_STOP), + "worker", + "task-3"))) + .collectList() + .block(); + + assertEquals(10, events.size()); + assertEquals( + 0, + annotator.retainedSourceCount(), + "child sources must not be retained after their end event or result"); + } + + @Test + void topLevelBookkeepingDoesNotGrowAcrossInvocations() { + AgentEventStreams.DispositionAnnotator annotator = + new AgentEventStreams.DispositionAnnotator(); + + List events = + annotator + .apply( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result(GenerateReason.MODEL_STOP), + new AgentEndEvent("reply-1"), + new ModelCallStartEvent("reply-2"))) + .collectList() + .block(); + + assertEquals(6, events.size()); + assertEquals( + 1, + annotator.retainedSourceCount(), + "only the active invocation state should remain after reopening the source"); + } + + @Test + void childEndWithoutExplicitSuccessOutcomeProducesNoTerminal() { + AgentEndEvent end = + (AgentEndEvent) tagged(new AgentEndEvent("reply-1"), "worker", "task-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end)) + .collectList() + .block(); + + assertEquals(3, events.size()); + assertSame(end, events.get(2)); + assertEquals( + 0, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void abnormalChildEndProducesNoTerminal() { + AgentEndEvent end = + (AgentEndEvent) + tagged(new AgentEndEvent("reply-1"), "worker", "task-1") + .withMetadataEntry( + AgentEndEvent.METADATA_INVOCATION_OUTCOME, + AgentEndEvent.OUTCOME_CANCELLED); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end)) + .collectList() + .block(); + + assertEquals(3, events.size()); + assertEquals( + 0, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void cancellationAfterChildTerminalCanPreventFollowingEnd() { + AgentEndEvent end = successfulChildEnd("reply-1", "worker", "task-1"); + + StepVerifier.create( + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end))) + .expectNextCount(2) + .expectNextMatches(TextOutputDispositionEvent.class::isInstance) + .thenCancel() + .verify(); + } + + private static AgentResultEvent result(GenerateReason reason) { + return new AgentResultEvent( + AssistantMessage.builder().textContent("answer").generateReason(reason).build()); + } + + private static AgentEndEvent successfulChildEnd(String replyId, String source, String taskId) { + return (AgentEndEvent) + tagged(new AgentEndEvent(replyId), source, taskId) + .withMetadataEntry( + AgentEndEvent.METADATA_INVOCATION_OUTCOME, + AgentEndEvent.OUTCOME_SUCCESS); + } + + private static AgentEvent tagged(AgentEvent event, String source, String taskId) { + return event.withSource(source).withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId); + } + + private static void assertDisposition( + AgentEvent event, String replyId, TextOutputDisposition expected) { + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, event); + assertEquals(replyId, disposition.getReplyId()); + assertEquals(expected, disposition.getDisposition()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/TextOutputDispositionEventTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/TextOutputDispositionEventTest.java new file mode 100644 index 0000000000..2e703241f4 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/event/TextOutputDispositionEventTest.java @@ -0,0 +1,57 @@ +/* + * 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.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.util.JsonUtils; +import org.junit.jupiter.api.Test; + +class TextOutputDispositionEventTest { + + @Test + void jsonRoundTripPreservesDispositionAndInheritedContext() { + TextOutputDispositionEvent original = + new TextOutputDispositionEvent( + "reply-1", TextOutputDisposition.TERMINAL, GenerateReason.MODEL_STOP); + original.withSource("parent/researcher") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-1"); + + String json = JsonUtils.getJsonCodec().toJson(original); + AgentEvent decoded = JsonUtils.getJsonCodec().fromJson(json, AgentEvent.class); + + TextOutputDispositionEvent restored = + assertInstanceOf(TextOutputDispositionEvent.class, decoded); + assertEquals(AgentEventType.TEXT_OUTPUT_DISPOSITION, restored.getType()); + assertEquals("reply-1", restored.getReplyId()); + assertEquals(TextOutputDisposition.TERMINAL, restored.getDisposition()); + assertEquals(GenerateReason.MODEL_STOP, restored.getGenerateReason()); + assertEquals("parent/researcher", restored.getSource()); + assertEquals("task-1", restored.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + } + + @Test + void intermediateDispositionDoesNotClaimGenerateReason() { + TextOutputDispositionEvent event = + new TextOutputDispositionEvent("reply-2", TextOutputDisposition.INTERMEDIATE, null); + + assertEquals(TextOutputDisposition.INTERMEDIATE, event.getDisposition()); + assertNull(event.getGenerateReason()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java new file mode 100644 index 0000000000..9594649ec0 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java @@ -0,0 +1,153 @@ +/* + * 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.internal.stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallStartEvent; +import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.ToolCallStartEvent; +import org.junit.jupiter.api.Test; + +class ReplyLifecycleTrackerTest { + + @Test + void sourceKeyUsesTaskIdToIsolateConcurrentCallsFromSameSource() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + AgentEvent taskA = + new ModelCallStartEvent("reply-a") + .withSource("parent/worker") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-a"); + AgentEvent taskB = + new ModelCallStartEvent("reply-b") + .withSource("parent/worker") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-b"); + + assertNotEquals(tracker.sourceKey(taskA), tracker.sourceKey(taskB)); + } + + @Test + void taskScopedEventWithoutSourceIsNotTreatedAsTopLevel() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + AgentEvent event = + new ModelCallStartEvent("reply-1") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-1"); + + assertFalse(tracker.sourceKey(event).isTopLevel()); + } + + @Test + void nonEmptyTextDeltaMarksOnlyItsCurrentReplyAsVisible() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + tracker.observe(new ModelCallStartEvent("reply-1")); + + ReplyLifecycleTracker.Observation blank = + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "")); + ReplyLifecycleTracker.Observation visible = + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "answer")); + + assertFalse(blank.after().textSeen()); + assertTrue(visible.currentReplyEvent()); + assertTrue(visible.after().textSeen()); + } + + @Test + void toolCallForDifferentReplyDoesNotMarkCurrentReply() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + tracker.observe(new ModelCallStartEvent("reply-1")); + + ReplyLifecycleTracker.Observation unrelated = + tracker.observe(new ToolCallStartEvent("reply-2", "tool-1", "search")); + ReplyLifecycleTracker.Observation current = + tracker.observe(new ToolCallStartEvent("reply-1", "tool-2", "search")); + + assertFalse(unrelated.currentReplyEvent()); + assertFalse(unrelated.after().toolCallSeen()); + assertTrue(current.currentReplyEvent()); + assertTrue(current.after().toolCallSeen()); + } + + @Test + void modelStartExposesPreviousReplyBeforeResettingState() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "draft")); + tracker.markDispositionEmitted(ReplyLifecycleTracker.SourceKey.topLevel()); + + ReplyLifecycleTracker.Observation next = + tracker.observe(new ModelCallStartEvent("reply-2")); + + assertEquals("reply-1", next.before().replyId()); + assertTrue(next.before().textSeen()); + assertTrue(next.before().dispositionEmitted()); + assertEquals("reply-2", next.after().replyId()); + assertFalse(next.after().textSeen()); + assertFalse(next.after().dispositionEmitted()); + } + + @Test + void clearSourceRemovesTrackedState() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + ReplyLifecycleTracker.SourceKey topLevel = ReplyLifecycleTracker.SourceKey.topLevel(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "answer")); + + tracker.clearSource(topLevel); + + assertEquals(0, tracker.trackedSourceCount()); + } + + @Test + void markDispositionEmittedDoesNotRecreateClearedSource() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + ReplyLifecycleTracker.SourceKey topLevel = ReplyLifecycleTracker.SourceKey.topLevel(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.clearSource(topLevel); + + tracker.markDispositionEmitted(topLevel); + + assertEquals(0, tracker.trackedSourceCount()); + } + + @Test + void eventsThatCannotEstablishAConversationDoNotCreateTrackedState() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + ReplyLifecycleTracker.SourceKey topLevel = ReplyLifecycleTracker.SourceKey.topLevel(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "answer")); + tracker.clearSource(topLevel); + + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "late")); + tracker.observe(new ToolCallStartEvent("reply-1", "tool-1", "search")); + + assertEquals(0, tracker.trackedSourceCount()); + } + + @Test + void capsTrackedChildSources() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + + for (int i = 0; i < 4097; i++) { + tracker.observe(new ModelCallStartEvent("reply-" + i).withSource("source-" + i)); + } + + assertEquals(ReplyLifecycleTracker.MAX_TRACKED_CHILD_SOURCES, tracker.trackedSourceCount()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java index c8a8cdf02b..cab7b7fe59 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java @@ -138,6 +138,36 @@ void stateIsolatedAcrossSubscriptions() { .anyMatch(event -> "final answer".equals(event.getDelta()))); } + @Test + void textFromAnIntermediateRoundIsNotLeakedIntoTheFinalRound() { + List events = + apply( + Flux.just( + new ModelCallStartEvent(REPLY_ID), + new TextBlockDeltaEvent(REPLY_ID, "text", "checking"), + new ToolCallStartEvent(REPLY_ID, "tool-1", "search"), + new ModelCallEndEvent(REPLY_ID, (ChatUsage) null), + new ModelCallStartEvent("reply-2"), + new TextBlockStartEvent("reply-2", "text"), + new TextBlockDeltaEvent("reply-2", "text", "final answer"), + new TextBlockEndEvent("reply-2", "text"), + new ModelCallEndEvent("reply-2", (ChatUsage) null))); + + assertFalse( + textDeltas(events).stream().anyMatch(delta -> "checking".equals(delta.getDelta())), + "text from a round that produced a tool call must not reach the final answer"); + assertTrue( + textDeltas(events).stream() + .anyMatch(delta -> "final answer".equals(delta.getDelta()))); + } + + private static List textDeltas(List events) { + return events.stream() + .filter(TextBlockDeltaEvent.class::isInstance) + .map(TextBlockDeltaEvent.class::cast) + .toList(); + } + private List apply(Flux source) { return middleware .onReasoning( diff --git a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java index 0949463d9c..60acb5ebae 100644 --- a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java @@ -16,15 +16,9 @@ package io.agentscope.examples.documentation2.streaming; import io.agentscope.core.ReActAgent; -import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; -import io.agentscope.core.event.AgentStartEvent; -import io.agentscope.core.event.ModelCallEndEvent; -import io.agentscope.core.event.ModelCallStartEvent; -import io.agentscope.core.event.TextBlockDeltaEvent; -import io.agentscope.core.event.ToolCallEndEvent; -import io.agentscope.core.event.ToolCallStartEvent; -import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.event.AgentEventStreams; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.message.Msg; import io.agentscope.core.message.UserMessage; import io.agentscope.core.tool.Tool; @@ -32,13 +26,15 @@ import io.agentscope.core.tool.Toolkit; /** - * AgentEventStreamExample - Demonstrates {@link ReActAgent#streamEvents} and the - * {@link AgentEvent} hierarchy. + * AgentEventStreamExample - Demonstrates the opt-in text output disposition wrapper on top of + * {@link ReActAgent#streamEvents} and the {@link AgentEvent} hierarchy. * *

{@code streamEvents()} returns a {@link reactor.core.publisher.Flux}{@code } * that covers the full agent lifecycle: startup, each model call, every text token, tool - * invocations, tool results, and shutdown. This gives callers the granularity needed to build - * real-time UIs, audit logs, or cost trackers without custom middleware. + * invocations, tool results, and shutdown. {@link AgentEventStreams#withTextOutputDisposition} + * preserves those events and derives lifecycle signals that classify streamed text as intermediate + * or terminal. This example prints only those derived disposition events; callers can inspect the + * unchanged underlying events in the same callback when needed. * *

Event sequence for a single-turn response (no tools): *

@@ -48,10 +44,12 @@
  *         TEXT_BLOCK_DELTA  (repeated — one per streamed token chunk)
  *       TEXT_BLOCK_END
  *     MODEL_CALL_END        (carries token usage)
+ *   AGENT_RESULT            (authoritative invocation result)
+ *   TEXT_OUTPUT_DISPOSITION (TERMINAL, derived by the opt-in wrapper)
  *   AGENT_END
  * 
* - *

Additional events when a tool is called: + *

Additional underlying events when a tool is called: *

  *     TOOL_CALL_START       (tool name + call ID)
  *       TOOL_CALL_DELTA     (optional — streamed tool input)
@@ -80,7 +78,7 @@ public static void main(String[] args) {
         System.out.println("AgentEvent Stream Example");
         System.out.println("=".repeat(60));
         System.out.println(
-                "Shows every lifecycle event emitted by streamEvents(), including tool calls.");
+                "Prints derived text dispositions while preserving the underlying event stream.");
         System.out.println("=".repeat(60) + "\n");
 
         Toolkit toolkit = new Toolkit();
@@ -94,7 +92,7 @@ public static void main(String[] args) {
                         .toolkit(toolkit)
                         .build();
 
-        Msg userMsg = new UserMessage("user", "What is the weather like in Beijing and Shanghai?");
+        Msg input = new UserMessage("user", "What is the weather like in Beijing and Shanghai?");
 
         System.out.println("User: What is the weather like in Beijing and Shanghai?\n");
 
@@ -106,74 +104,18 @@ public static void main(String[] args) {
         //   event.getId()         — unique event ID
         //   event.getCreatedAt()  — ISO-8601 timestamp
         //
-        // Use instanceof to access type-specific fields.
-        agent.streamEvents(userMsg).doOnNext(AgentEventStreamExample::handleEvent).blockLast();
-    }
-
-    /**
-     * Dispatches an {@link AgentEvent} to a type-specific handler.
-     *
-     * 

The {@code instanceof} pattern is the recommended way to consume events because - * it gives compile-time access to typed fields without casting. An exhaustive - * {@code switch} on {@link io.agentscope.core.event.AgentEventType} is an alternative - * when only the type discriminator is needed. - * - * @param event the event to handle - */ - private static void handleEvent(AgentEvent event) { - if (event instanceof AgentStartEvent e) { - // Emitted once at the very beginning of an invocation. - // replyId correlates all subsequent events to this invocation. - System.out.printf( - "[AGENT_START] agent=%s replyId=%s%n", e.getName(), e.getReplyId()); - - } else if (event instanceof ModelCallStartEvent e) { - // Emitted before each model (LLM) API call. - // A multi-tool ReAct loop fires one MODEL_CALL_START per iteration. - System.out.printf("[MODEL_CALL_START] replyId=%s%n", e.getReplyId()); - - } else if (event instanceof TextBlockDeltaEvent e) { - // Emitted for every token chunk streamed from the model. - // Print without newline to render the response incrementally. - System.out.print(e.getDelta()); - - } else if (event instanceof ModelCallEndEvent e) { - // Emitted after the model call completes. - // ChatUsage carries input/output token counts when the model reports them. - System.out.println(); - if (e.getUsage() != null) { - System.out.printf( - "[MODEL_CALL_END] inputTokens=%d outputTokens=%d%n", - e.getUsage().getInputTokens(), e.getUsage().getOutputTokens()); - } else { - System.out.println("[MODEL_CALL_END]"); - } - - } else if (event instanceof ToolCallStartEvent e) { - // Emitted when the model requests a tool invocation. - // toolCallId correlates ToolCallStart/End and ToolResultStart/End pairs. - System.out.printf( - "[TOOL_CALL_START] tool=%s callId=%s%n", - e.getToolCallName(), e.getToolCallId()); - - } else if (event instanceof ToolCallEndEvent e) { - System.out.printf("[TOOL_CALL_END] callId=%s%n", e.getToolCallId()); - - } else if (event instanceof ToolResultEndEvent e) { - // Emitted after the tool result has been produced. - // ToolResultState is SUCCESS or ERROR. - System.out.printf( - "[TOOL_RESULT_END] callId=%s state=%s%n", e.getToolCallId(), e.getState()); - - } else if (event instanceof AgentEndEvent e) { - // Emitted once after the agent finishes all iterations. - System.out.printf("[AGENT_END] replyId=%s%n", e.getReplyId()); - - } else { - // All other events (TEXT_BLOCK_START/END, TOOL_RESULT_START, THINKING_BLOCK_*, etc.) - // are silently ignored in this example but are available for more advanced use cases. - System.out.printf("[%-24s] (skipped)%n", event.getType()); - } + // TERMINAL closes the streamed text lifecycle; AgentResultEvent remains the authoritative + // invocation result and may differ from the streamed preview. + AgentEventStreams.withTextOutputDisposition(agent.streamEvents(input)) + .doOnNext( + event -> { + if (event instanceof TextOutputDispositionEvent disposition) { + System.out.printf( + "%s -> %s%n", + disposition.getReplyId(), disposition.getDisposition()); + } + }) + .blockLast(); } /** Simulated weather tool used to trigger tool-call events. */ diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java index 7448db21a1..fe2e8d5801 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventStreams; import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.ConfirmResult; import io.agentscope.core.message.GenerateReason; @@ -244,7 +245,8 @@ private String runAgent( AtomicReference resultRef = new AtomicReference<>(); String detail = submitCtx.detail(); - Flux events = agent.streamEvents(msg, ctx); + Flux events = + AgentEventStreams.withTextOutputDisposition(agent.streamEvents(msg, ctx)); events.doOnNext( event -> { if (event instanceof AgentResultEvent resultEvent) { diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java index 0da7c14bcf..fdeb3ab7fc 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java @@ -28,10 +28,13 @@ import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.ModelCallStartEvent; import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ToolResultTextDeltaEvent; +import io.agentscope.core.message.GenerateReason; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.model.ChatUsage; @@ -45,6 +48,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -61,27 +65,39 @@ class AgentProtocolStreamDetailTest { private ProtocolTaskRepository taskRepository; private HarnessAgent agent; + private AtomicInteger agentStreamSubscriptions; @BeforeEach void setUp() { taskRepository = new WorkspaceProtocolTaskRepository(tempDir); agent = mock(HarnessAgent.class); + agentStreamSubscriptions = new AtomicInteger(); when(agent.streamEvents(any(Msg.class), any(RuntimeContext.class))) - .thenReturn(Flux.fromIterable(agentRun())); + .thenReturn( + Flux.defer( + () -> { + agentStreamSubscriptions.incrementAndGet(); + return Flux.fromIterable(agentRun()); + })); } /** A run touching a wire-typed event, a delta, and several passthrough-only events. */ private static List agentRun() { return List.of( - new AgentStartEvent("sess", "reply", "worker"), - new TextBlockStartEvent("reply", "b1"), - new TextBlockDeltaEvent("reply", "b1", "hello"), - new TextBlockEndEvent("reply", "b1"), - new ToolResultTextDeltaEvent("reply", "call-1", "read_file", "file contents"), - new ModelCallEndEvent("reply", new ChatUsage(10, 20, 0, 0.5)), + new AgentStartEvent("sess", "invocation-reply", "worker"), + new ModelCallStartEvent("model-reply"), + new TextBlockStartEvent("model-reply", "b1"), + new TextBlockDeltaEvent("model-reply", "b1", "hello"), + new TextBlockEndEvent("model-reply", "b1"), + new ToolResultTextDeltaEvent("model-reply", "call-1", "read_file", "file contents"), + new ModelCallEndEvent("model-reply", new ChatUsage(10, 20, 0, 0.5)), new AgentResultEvent( - Msg.builder().role(MsgRole.ASSISTANT).textContent("done").build()), - new AgentEndEvent("reply")); + Msg.builder() + .role(MsgRole.ASSISTANT) + .textContent("done") + .generateReason(GenerateReason.MODEL_STOP) + .build()), + new AgentEndEvent("invocation-reply")); } @Test @@ -95,13 +111,33 @@ void statusLevelCarriesLifecycleOnly() { } @Test - void fullLevelAddsDeltasButNotPassthrough() { - Set types = typesFor("full", "t-full"); + void fullLevelAddsDeltasAndAuthoritativePassthroughEvents() { + List events = collect("full", "t-full"); + Set types = + events.stream() + .map(RemoteAgentEvent::getType) + .collect(Collectors.toUnmodifiableSet()); + Set eventTypes = + events.stream() + .map(RemoteAgentEvent::getEventType) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); assertTrue(types.contains(RemoteEventType.TEXT_DELTA)); - assertFalse( - types.contains(RemoteEventType.AGENT_EVENT), - "full keeps the pre-existing volume for deployments that never asked for more"); + assertTrue(types.contains(RemoteEventType.AGENT_EVENT)); + assertTrue(eventTypes.contains("TEXT_OUTPUT_DISPOSITION"), eventTypes.toString()); + assertTrue(eventTypes.contains("AGENT_RESULT"), eventTypes.toString()); + assertFalse(eventTypes.contains("TEXT_BLOCK_START"), eventTypes.toString()); + assertFalse(eventTypes.contains("TOOL_RESULT_TEXT_DELTA"), eventTypes.toString()); + + TextOutputDispositionEvent disposition = + events.stream() + .filter(event -> "TEXT_OUTPUT_DISPOSITION".equals(event.getEventType())) + .findFirst() + .flatMap(RemoteEventCodec::toAgentEvent) + .map(TextOutputDispositionEvent.class::cast) + .orElseThrow(); + assertEquals("model-reply", disposition.getReplyId()); } @Test @@ -146,6 +182,9 @@ private List collect(String detail, String taskId) { Flux subscription = bus.subscribe(taskId, 0L); store.submit(taskId, "worker", "go", Map.of("detail", detail)); - return subscription.take(Duration.ofSeconds(5)).collectList().block(); + List events = + subscription.take(Duration.ofSeconds(5)).collectList().block(); + assertEquals(1, agentStreamSubscriptions.get(), "the agent stream must execute once"); + return events; } } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md new file mode 100644 index 0000000000..2081e0c420 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md @@ -0,0 +1,39 @@ +# AgentScope AG-UI extension + +This module converts AgentScope `AgentEvent` streams to AG-UI events. Text output disposition and +authoritative final-message calibration are opt-in so existing AG-UI event sequences and message +IDs remain unchanged by default. + +## Enable text output disposition + +```java +AguiAdapterConfig config = AguiAdapterConfig.builder() + .textOutputDispositionEnabled(true) + .build(); + +AguiAgentAdapter adapter = new AguiAgentAdapter(agent, config); +Flux events = adapter.run(input); +``` + +When enabled, the adapter derives `TextOutputDispositionEvent` values from the agent stream and +emits an AG-UI `CUSTOM` event named `agentscope.text_output.disposition`. Its value contains: + +- `replyId`: the AgentScope reply whose text lifecycle changed; +- `messageIds`: all AG-UI text segment IDs associated with that reply, using the stream's own + `replyId-blockId` message IDs (for example `reply-1-text-1`); +- `disposition`: `INTERMEDIATE` or `TERMINAL`; +- `generateReason`: the generation reason when one is available. + +`INTERMEDIATE` identifies text that should be presented as progress or commentary. `TERMINAL` +closes a streamed text lifecycle, but **does not mean that the text is the final answer**. The +authoritative invocation result remains `AgentResultEvent`. + +For completed top-level results, the opt-in adapter emits a standard AG-UI `MESSAGES_SNAPSHOT` +containing the authoritative result and available conversation state. Consumers should use that +snapshot to reconcile or replace streamed text. No snapshot is emitted for a pending interrupt or +for generation reasons that do not represent an ordinary completed answer. + +Leaving `textOutputDispositionEnabled` unset (or setting it to `false`) preserves the existing +`replyId-blockId` message ID and event sequence unchanged, and emits neither disposition `CUSTOM` +events nor final `MESSAGES_SNAPSHOT` calibration events. Enabling the flag only adds the +disposition `CUSTOM` events and snapshot calibration on top of those same message IDs. diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java index cb9211274f..1df38613bf 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java @@ -38,6 +38,7 @@ public class AguiAdapterConfig { private final boolean emitToolCallArgs; private final boolean emitTokenUsage; private final boolean enableReasoning; + private final boolean textOutputDispositionEnabled; private final boolean emitRunFinishedAfterError; private final Duration runTimeout; private final String defaultAgentId; @@ -52,6 +53,7 @@ private AguiAdapterConfig(Builder builder) { this.emitToolCallArgs = builder.emitToolCallArgs; this.emitTokenUsage = builder.emitTokenUsage; this.enableReasoning = builder.enableReasoning; + this.textOutputDispositionEnabled = builder.textOutputDispositionEnabled; this.emitRunFinishedAfterError = builder.emitRunFinishedAfterError; this.runTimeout = builder.runTimeout; this.defaultAgentId = builder.defaultAgentId; @@ -113,6 +115,18 @@ public boolean isEnableReasoning() { return enableReasoning; } + /** + * Check whether streamed text output disposition and final message snapshots are enabled. + * + *

Default is {@code false} so existing AG-UI event sequences and message IDs remain + * unchanged. + * + * @return true to derive text output disposition events + */ + public boolean isTextOutputDispositionEnabled() { + return textOutputDispositionEnabled; + } + /** * Check whether {@code RUN_FINISHED} should be emitted after {@code RUN_ERROR}. * @@ -173,9 +187,9 @@ public boolean isBaseEventPropertiesEnricherEnabled() { } /** - * When {@code false} (default), AgentEvents with a non-null {@code source} (subagent events) - * are emitted as AG-UI {@code CUSTOM} events under the {@code subagent.*} namespace instead of - * native {@code TEXT_MESSAGE_*} / run lifecycle events. + * When {@code false} (default), AgentEvents with a non-blank {@code source} or task id (subagent + * events) are emitted as AG-UI {@code CUSTOM} events under the {@code subagent.*} namespace + * instead of native {@code TEXT_MESSAGE_*} / run lifecycle events. * * @return true to keep the legacy native presentation for subagent events */ @@ -220,6 +234,7 @@ public static class Builder { private boolean emitToolCallArgs = true; private boolean emitTokenUsage = false; private boolean enableReasoning = false; + private boolean textOutputDispositionEnabled = false; private boolean emitRunFinishedAfterError = false; private Duration runTimeout = Duration.ofMinutes(10); private String defaultAgentId; @@ -290,6 +305,19 @@ public Builder enableReasoning(boolean enableReasoning) { return this; } + /** + * Set whether to derive text output disposition events and final message snapshots. + * + *

Default is {@code false} for backward compatibility. + * + * @param textOutputDispositionEnabled true to enable disposition conversion + * @return This builder + */ + public Builder textOutputDispositionEnabled(boolean textOutputDispositionEnabled) { + this.textOutputDispositionEnabled = textOutputDispositionEnabled; + return this; + } + /** * Set whether to emit {@code RUN_FINISHED} after {@code RUN_ERROR}. * @@ -396,9 +424,9 @@ public Builder baseEventPropertiesEnricherEnabled( /** * Set whether subagent-sourced events should use native AG-UI event types. * - *

Default is {@code false}: subagent events become {@code CUSTOM} events named {@code - * subagent.*}. Set {@code true} to restore the previous behavior where child text and - * lifecycle events map to the same AG-UI types as the parent. + *

Default is {@code false}: events with a non-blank source or task id become {@code + * CUSTOM} events named {@code subagent.*}. Set {@code true} to restore the previous behavior + * where child text and lifecycle events map to the same AG-UI types as the parent. * * @param emitSubagentEventsAsNative true for legacy native presentation * @return This builder diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java index bd2b0d97b0..88446d47e8 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java @@ -22,14 +22,17 @@ import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.agui.AguiUtil; +import io.agentscope.core.agui.adapter.strategy.AgentEventConverter; import io.agentscope.core.agui.adapter.strategy.AgentEventConverterRegistry; import io.agentscope.core.agui.adapter.strategy.AguiStreamContext; +import io.agentscope.core.agui.adapter.strategy.TextOutputDispositionConverter; import io.agentscope.core.agui.converter.AguiMessageConverter; import io.agentscope.core.agui.converter.AguiToolConverter; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.agui.model.ToolMergeMode; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventStreams; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; @@ -106,9 +109,14 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { this.config = Objects.requireNonNull(config, "config cannot be null"); this.messageConverter = new AguiMessageConverter(); this.toolConverter = new AguiToolConverter(); + List eventConverters = new ArrayList<>(); + if (config.isTextOutputDispositionEnabled()) { + eventConverters.add(new TextOutputDispositionConverter()); + } + eventConverters.addAll(config.getEventConverters()); this.agentEventConverterRegistry = new AgentEventConverterRegistry( - config.getEventConverters(), + eventConverters, config.getEventEnrichers(), config.isEmitSubagentEventsAsNative()); } @@ -203,20 +211,34 @@ private AgentStream streamWithRuntimeContext( if (agent instanceof ReActAgent reAct) { AguiStreamContext context = - new AguiStreamContext(threadId, runId, config, input, externalToolDetector()); + new AguiStreamContext( + threadId, + runId, + config, + input, + externalToolDetector(), + () -> authoritativeMessages(runtimeContext)); Flux events = Objects.requireNonNull( reAct.streamEvents(msgs, runtimeContext), "agent stream is null"); + events = applyTextOutputDisposition(events); return new AgentStream( convertAgentEvents(events, context), () -> finishPendingEvents(context)); } if (AguiUtil.isHarnessAgent(agent)) { AguiStreamContext context = - new AguiStreamContext(threadId, runId, config, input, externalToolDetector()); + new AguiStreamContext( + threadId, + runId, + config, + input, + externalToolDetector(), + () -> authoritativeMessages(runtimeContext)); Flux events = Objects.requireNonNull( invokeHarnessStreamEvents(agent, msgs, runtimeContext), "agent stream is null"); + events = applyTextOutputDisposition(events); return new AgentStream( convertAgentEvents(events, context), () -> finishPendingEvents(context)); } @@ -236,7 +258,7 @@ private AgentStream streamWithRuntimeContext( } private Flux convertAgentEvents(Flux events, AguiStreamContext context) { - return events + return events.doOnNext(context::observe) // Use concatMapIterable to preserve strict event ordering .concatMapIterable(event -> agentEventConverterRegistry.convert(event, context)) .onErrorResume( @@ -248,6 +270,17 @@ private Flux finishPendingEvents(AguiStreamContext context) { agentEventConverterRegistry.enrich(null, context.finishPendingEvents(), context)); } + private Flux applyTextOutputDisposition(Flux events) { + return config.isTextOutputDispositionEnabled() + ? AgentEventStreams.withTextOutputDisposition(events) + : events; + } + + private List authoritativeMessages(RuntimeContext runtimeContext) { + var state = RuntimeContext.resolveAgentState(runtimeContext, agent); + return state != null ? state.getContext() : null; + } + private record AgentStream(Flux events, Supplier> finish) {} @SuppressWarnings("unchecked") diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java index 5b2460de0a..4c2c5058a9 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java @@ -59,8 +59,8 @@ public AgentEventConverterRegistry( * @param customConverters converters registered after built-in converters * @param enrichers enrichers applied after each conversion * @param emitSubagentEventsAsNative when {@code true}, child events use the same converters as - * the parent; when {@code false} (default), {@code source != null} events become {@code - * subagent.*} CUSTOM / RAW events + * the parent; when {@code false} (default), events with a non-blank source or task id become + * {@code subagent.*} CUSTOM / RAW events */ public AgentEventConverterRegistry( List customConverters, @@ -97,9 +97,7 @@ public List convert(AgentEvent event, AguiStreamContext context) { Objects.requireNonNull(event, "event cannot be null"); Objects.requireNonNull(context, "context cannot be null"); context.beginEvent(); - if (!emitSubagentEventsAsNative - && event.getSource() != null - && !event.getSource().isBlank()) { + if (!emitSubagentEventsAsNative && !context.isTopLevelEvent(event)) { subagentConverter.convert(event, context); } else { converters.getOrDefault(event.getClass(), rawConverter).convert(event, context); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java index 5201ed6e67..341ff92293 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java @@ -81,6 +81,7 @@ public void convert(AgentEvent event, AguiStreamContext context) { for (AguiEvent pendingEvent : context.finishPendingEvents()) { context.emit(pendingEvent); } + context.emitFinalMessagesSnapshot((AgentEndEvent) event); List interrupts = context.getPendingInterrupts(); AguiEvent.RunFinishedOutcome outcome = interrupts.isEmpty() diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java index 9f76aa8d18..1456fc81e0 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java @@ -16,15 +16,26 @@ package io.agentscope.core.agui.adapter.strategy; import io.agentscope.core.agui.adapter.AguiAdapterConfig; +import io.agentscope.core.agui.converter.AguiMessageConverter; import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiTool; import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.event.AgentEndEvent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentResultEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; import io.agentscope.core.model.ChatUsage; import io.agentscope.core.util.JsonException; import io.agentscope.core.util.JsonUtils; import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -32,6 +43,9 @@ import java.util.Objects; import java.util.Set; import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +53,19 @@ public class AguiStreamContext { private static final Logger logger = LoggerFactory.getLogger(AguiStreamContext.class); + private static final Set FINAL_SNAPSHOT_REASONS = + EnumSet.of( + GenerateReason.MODEL_STOP, + GenerateReason.STRUCTURED_OUTPUT, + GenerateReason.MAX_ITERATIONS); + + /** + * Matches the reserved live text-segment message ids produced by the AG-UI text/reasoning + * converters ({@code -text[-N]}, {@code -thinking[-N]}). The reply id itself + * is recovered from the first capture group. + */ + private static final Pattern TEXT_SEGMENT_ID = + Pattern.compile("^(.+)-(?:text|thinking|reasoning)(?:-\\d+)?$"); private final String threadId; private final String runId; @@ -56,12 +83,18 @@ public class AguiStreamContext { private final Set adoptedToolCalls = new LinkedHashSet<>(); private String currentTextMessageId; private String currentReasoningMessageId; + private final Map> textMessageIdsByReply = new LinkedHashMap<>(); + private final Map textOutputDispositionsByReply = + new LinkedHashMap<>(); private final Map toolResultContent = new LinkedHashMap<>(); private final Map pendingInterrupts = new LinkedHashMap<>(); private final Set warnedMissingToolCallIdOperations = new LinkedHashSet<>(); private final TokenUsageAccumulator tokenUsageAccumulator = new TokenUsageAccumulator(); private final Predicate isExternalTool; private final Map startedToolCallNames = new LinkedHashMap<>(); + private final Supplier> authoritativeMessagesSupplier; + private final AguiMessageConverter messageConverter = new AguiMessageConverter(); + private Msg finalResult; public AguiStreamContext(String threadId, String runId, AguiAdapterConfig config) { this(threadId, runId, config, null, null); @@ -89,12 +122,26 @@ public AguiStreamContext( AguiAdapterConfig config, RunAgentInput runInput, Predicate isExternalTool) { + this(threadId, runId, config, runInput, isExternalTool, List::of); + } + + public AguiStreamContext( + String threadId, + String runId, + AguiAdapterConfig config, + RunAgentInput runInput, + Predicate isExternalTool, + Supplier> authoritativeMessagesSupplier) { this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); this.runId = Objects.requireNonNull(runId, "runId cannot be null"); this.config = Objects.requireNonNull(config, "config cannot be null"); this.runInput = runInput; this.isExternalTool = isExternalTool != null ? isExternalTool : defaultExternalToolDetector(runInput); + this.authoritativeMessagesSupplier = + Objects.requireNonNull( + authoritativeMessagesSupplier, + "authoritativeMessagesSupplier cannot be null"); } public String getThreadId() { @@ -127,21 +174,69 @@ public void emit(AguiEvent event) { pendingEvents.add(event); } + public void observe(AgentEvent event) { + if (event instanceof AgentResultEvent resultEvent && isTopLevelEvent(event)) { + finalResult = resultEvent.getResult(); + } + } + + /** + * Whether an event belongs to the parent invocation. A blank source with a task id still belongs + * to a child source, matching the correlation key used by the core event stream. + * + *

Producers must leave {@link AgentEvent#METADATA_TASK_ID} unset on top-level events. + * Child-forwarding producers, currently {@code AgentSpawnTool} and + * {@code RemoteEventCodec}, stamp it on events forwarded from a child source. + */ + boolean isTopLevelEvent(AgentEvent event) { + Objects.requireNonNull(event, "event"); + if (!isBlank(event.getSource())) { + return false; + } + Object taskId = + event.getMetadata() == null + ? null + : event.getMetadata().get(AgentEvent.METADATA_TASK_ID); + return taskId == null || taskId.toString().isBlank(); + } + TokenUsageAccumulator getTokenUsageAccumulator() { return tokenUsageAccumulator; } + /** + * Starts a live text message without naming the reply it belongs to. Callers that know the reply + * id should use {@link #startTextMessage(String, String)} instead: this overload has to recover + * it from the message id naming convention. + */ public void startTextMessage(String messageId) { + startTextMessage(messageId, replyIdOf(messageId)); + } + + /** + * Starts a live text message owned by {@code replyId}. The reply id is recorded explicitly so the + * correlation never depends on how the message id was named. + */ + public void startTextMessage(String messageId, String replyId) { + String owner = replyId != null ? replyId : replyIdOf(messageId); if (startedTextMessages.add(messageId)) { + textMessageIdsByReply + .computeIfAbsent(owner, ignored -> new ArrayList<>()) + .add(messageId); emit(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant")); + emitRememberedTextOutputDisposition(owner); } currentTextMessageId = messageId; } public void appendTextDelta(String messageId, String delta) { + appendTextDelta(messageId, replyIdOf(messageId), delta); + } + + public void appendTextDelta(String messageId, String replyId, String delta) { if (delta != null && !delta.isEmpty()) { - startTextMessage(messageId); - emit(new AguiEvent.TextMessageContent(threadId, runId, messageId, delta)); + startTextMessage(messageId, replyId); + emit(new AguiEvent.TextMessageContent(threadId, runId, currentTextMessageId, delta)); } } @@ -165,6 +260,59 @@ public void closeTextMessage(String messageId) { emit(new AguiEvent.TextMessageEnd(threadId, runId, messageId)); } + /** + * Returns the AG-UI text message ids that were started for {@code replyId}, in start order. Ids + * are recorded for every reply regardless of {@code textOutputDispositionEnabled}, so the answer + * does not depend on that flag; the list is empty when nothing was streamed for the reply. + */ + public List getTextMessageIds(String replyId) { + return List.copyOf(textMessageIdsByReply.getOrDefault(replyId, List.of())); + } + + public void emitTextOutputDisposition(TextOutputDispositionEvent dispositionEvent) { + TextOutputDispositionState disposition = + new TextOutputDispositionState( + dispositionEvent.getDisposition(), dispositionEvent.getGenerateReason()); + textOutputDispositionsByReply.put(dispositionEvent.getReplyId(), disposition); + emitTextOutputDisposition(dispositionEvent.getReplyId(), disposition); + } + + public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { + if (!config.isTextOutputDispositionEnabled() + || !isTopLevelEvent(endEvent) + || finalResult == null + || !FINAL_SNAPSHOT_REASONS.contains(finalResult.getGenerateReason()) + || !pendingInterrupts.isEmpty()) { + return; + } + Map messagesById = new LinkedHashMap<>(); + List authoritativeMessages = authoritativeMessagesSupplier.get(); + boolean hasAuthoritativeMessages = + authoritativeMessages != null && !authoritativeMessages.isEmpty(); + if (authoritativeMessages != null) { + for (Msg message : authoritativeMessages) { + if (message != null && !isGeneratedTextSegmentId(message.getId())) { + messagesById.put(message.getId(), messageConverter.toAguiMessage(message)); + } + } + } + if (runInput != null) { + // The submitted turn must survive the snapshot: agent state is not guaranteed to echo + // it, and consumers reconcile by replacing their streamed text with this snapshot. + for (AguiMessage message : runInput.getMessages()) { + if (message != null + && !isGeneratedTextSegmentId(message.getId()) + && (!hasAuthoritativeMessages || message.isUserMessage())) { + messagesById.putIfAbsent(message.getId(), message); + } + } + } + messagesById.put(finalResult.getId(), messageConverter.toAguiMessage(finalResult)); + emit( + new AguiEvent.MessagesSnapshot( + threadId, runId, new ArrayList<>(messagesById.values()))); + } + public void startReasoningMessage(String messageId) { if (startedReasoningMessages.add(messageId)) { emit(new AguiEvent.ReasoningMessageStart(threadId, runId, messageId, "reasoning")); @@ -343,6 +491,47 @@ private StringBuilder toolResultBuffer(String toolCallId) { return toolResultContent.computeIfAbsent(toolCallId, ignored -> new StringBuilder()); } + /** + * Compatibility fallback that recovers the owning reply id from a live text-segment message id + * ({@code -text[-N]}, {@code -thinking[-N]}). The converters know the reply id + * they are streaming and pass it explicitly; ids that are not segment ids are treated as their + * own reply id, which is only correct when the producer really used the reply id as a message id. + */ + private static String replyIdOf(String messageId) { + if (messageId == null) { + return null; + } + Matcher matcher = TEXT_SEGMENT_ID.matcher(messageId); + return matcher.matches() ? matcher.group(1) : messageId; + } + + private void emitRememberedTextOutputDisposition(String replyId) { + TextOutputDispositionState disposition = textOutputDispositionsByReply.get(replyId); + if (disposition != null) { + emitTextOutputDisposition(replyId, disposition); + } + } + + private void emitTextOutputDisposition(String replyId, TextOutputDispositionState disposition) { + Map value = new LinkedHashMap<>(); + value.put("replyId", replyId); + value.put("messageIds", getTextMessageIds(replyId)); + value.put("disposition", disposition.disposition().name()); + value.put( + "generateReason", + disposition.generateReason() != null ? disposition.generateReason().name() : null); + emit( + new AguiEvent.Custom( + threadId, + runId, + TextOutputDispositionConverter.EVENT_NAME, + Collections.unmodifiableMap(value))); + } + + private boolean isGeneratedTextSegmentId(String messageId) { + return messageId != null && startedTextMessages.contains(messageId); + } + private static String normalizeToolCallName(String toolCallName) { return toolCallName != null && !toolCallName.isBlank() ? toolCallName : "unknown"; } @@ -358,6 +547,9 @@ private static String serialize(ContentBlock data) { } } + private record TextOutputDispositionState( + TextOutputDisposition disposition, GenerateReason generateReason) {} + private static boolean isBlank(String value) { return value == null || value.isBlank(); } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java index 9888ea8794..e95ceebc1f 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java @@ -18,21 +18,31 @@ import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextBlockEndEvent; +import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; +import io.agentscope.core.event.ThinkingBlockEndEvent; +import io.agentscope.core.event.ThinkingBlockStartEvent; +import io.agentscope.core.event.ToolCallDeltaEvent; import io.agentscope.core.event.ToolCallEndEvent; import io.agentscope.core.event.ToolCallStartEvent; +import io.agentscope.core.event.ToolResultDataDeltaEvent; import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.event.ToolResultStartEvent; +import io.agentscope.core.event.ToolResultTextDeltaEvent; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; /** - * Converts subagent-sourced {@link AgentEvent}s ({@code source != null}) into AG-UI {@code CUSTOM} - * events under the {@code subagent.*} name namespace so they do not pollute the parent run lifecycle - * or text stream. + * Converts subagent-sourced {@link AgentEvent}s (a non-blank {@code source} or task id) into AG-UI + * {@code CUSTOM} events under the {@code subagent.*} name namespace so they do not pollute the + * parent run lifecycle or text stream. */ final class SubagentEventConverter implements AgentEventConverter { @@ -41,6 +51,8 @@ final class SubagentEventConverter implements AgentEventConverter { static final String NAME_THINKING = "subagent.thinking"; static final String NAME_TOOL_CALL = "subagent.tool_call"; static final String NAME_TOOL_RESULT = "subagent.tool_result"; + static final String NAME_TEXT_DISPOSITION = "subagent.text_disposition"; + static final String NAME_RESULT = "subagent.result"; static final String NAME_CONFIRM = "subagent.require_confirm"; static final String NAME_OTHER = "subagent.event"; @@ -54,83 +66,198 @@ public Set> eventTypes() { @Override public void convert(AgentEvent event, AguiStreamContext context) { - String source = event.getSource(); if (event instanceof AgentStartEvent start) { + Map extra = new LinkedHashMap<>(); + extra.put("sessionId", nullToEmpty(start.getSessionId())); + extra.put("name", nullToEmpty(start.getName())); + extra.put("role", nullToEmpty(start.getRole())); + putIfPresent(extra, "replyId", start.getReplyId()); + context.emit(custom(context, NAME_LIFECYCLE, value(event, "AGENT_START", extra))); + return; + } + if (event instanceof AgentEndEvent end) { + Map extra = new LinkedHashMap<>(); + putIfPresent(extra, "replyId", end.getReplyId()); + putMetadataIfPresent(extra, event, AgentEndEvent.METADATA_INVOCATION_OUTCOME); + context.emit(custom(context, NAME_LIFECYCLE, value(event, "AGENT_END", extra))); + return; + } + if (event instanceof AgentResultEvent result) { + Map extra = new LinkedHashMap<>(); + putIfPresent(extra, "result", result.getResult()); + context.emit(custom(context, NAME_RESULT, value(event, "AGENT_RESULT", extra))); + return; + } + if (event instanceof TextOutputDispositionEvent disposition) { + Map extra = new LinkedHashMap<>(); + putIfPresent(extra, "replyId", disposition.getReplyId()); + extra.put("disposition", disposition.getDisposition().name()); + if (disposition.getGenerateReason() != null) { + extra.put("generateReason", disposition.getGenerateReason().name()); + } context.emit( custom( context, - NAME_LIFECYCLE, - value( - source, - "AGENT_START", - Map.of( - "name", nullToEmpty(start.getName()), - "replyId", nullToEmpty(start.getReplyId()))))); + NAME_TEXT_DISPOSITION, + value(event, "TEXT_OUTPUT_DISPOSITION", extra))); return; } - if (event instanceof AgentEndEvent end) { + if (event instanceof TextBlockStartEvent textStart) { context.emit( custom( context, - NAME_LIFECYCLE, + NAME_TEXT, value( - source, - "AGENT_END", - Map.of("replyId", nullToEmpty(end.getReplyId()))))); + event, + "TEXT_BLOCK_START", + block(textStart.getReplyId(), textStart.getBlockId())))); return; } if (event instanceof TextBlockDeltaEvent text) { + Map extra = block(text.getReplyId(), text.getBlockId()); + extra.put("delta", nullToEmpty(text.getDelta())); + context.emit(custom(context, NAME_TEXT, value(event, "TEXT_BLOCK_DELTA", extra))); + return; + } + if (event instanceof TextBlockEndEvent textEnd) { context.emit( custom( context, NAME_TEXT, value( - source, - "TEXT_BLOCK_DELTA", - Map.of("delta", nullToEmpty(text.getDelta()))))); + event, + "TEXT_BLOCK_END", + block(textEnd.getReplyId(), textEnd.getBlockId())))); + return; + } + if (event instanceof ThinkingBlockStartEvent thinkingStart) { + context.emit( + custom( + context, + NAME_THINKING, + value( + event, + "THINKING_BLOCK_START", + block( + thinkingStart.getReplyId(), + thinkingStart.getBlockId())))); return; } if (event instanceof ThinkingBlockDeltaEvent thinking) { + Map extra = block(thinking.getReplyId(), thinking.getBlockId()); + extra.put("delta", nullToEmpty(thinking.getDelta())); + context.emit( + custom(context, NAME_THINKING, value(event, "THINKING_BLOCK_DELTA", extra))); + return; + } + if (event instanceof ThinkingBlockEndEvent thinkingEnd) { context.emit( custom( context, NAME_THINKING, value( - source, - "THINKING_BLOCK_DELTA", - Map.of("delta", nullToEmpty(thinking.getDelta()))))); + event, + "THINKING_BLOCK_END", + block(thinkingEnd.getReplyId(), thinkingEnd.getBlockId())))); return; } if (event instanceof ToolCallStartEvent toolStart) { - Map extra = new LinkedHashMap<>(); - extra.put("toolCallId", nullToEmpty(toolStart.getToolCallId())); - extra.put("toolName", nullToEmpty(toolStart.getToolCallName())); - context.emit(custom(context, NAME_TOOL_CALL, value(source, "TOOL_CALL_START", extra))); + context.emit( + custom( + context, + NAME_TOOL_CALL, + value( + event, + "TOOL_CALL_START", + tool( + toolStart.getReplyId(), + toolStart.getToolCallId(), + toolStart.getToolCallName())))); + return; + } + if (event instanceof ToolCallDeltaEvent toolDelta) { + Map extra = + tool( + toolDelta.getReplyId(), + toolDelta.getToolCallId(), + toolDelta.getToolCallName()); + extra.put("argumentsDelta", nullToEmpty(toolDelta.getDelta())); + context.emit(custom(context, NAME_TOOL_CALL, value(event, "TOOL_CALL_DELTA", extra))); return; } if (event instanceof ToolCallEndEvent toolEnd) { - Map extra = new LinkedHashMap<>(); - extra.put("toolCallId", nullToEmpty(toolEnd.getToolCallId())); - extra.put("toolName", nullToEmpty(toolEnd.getToolCallName())); - context.emit(custom(context, NAME_TOOL_CALL, value(source, "TOOL_CALL_END", extra))); + context.emit( + custom( + context, + NAME_TOOL_CALL, + value( + event, + "TOOL_CALL_END", + tool( + toolEnd.getReplyId(), + toolEnd.getToolCallId(), + toolEnd.getToolCallName())))); + return; + } + if (event instanceof ToolResultStartEvent toolResultStart) { + context.emit( + custom( + context, + NAME_TOOL_RESULT, + value( + event, + "TOOL_RESULT_START", + tool( + toolResultStart.getReplyId(), + toolResultStart.getToolCallId(), + toolResultStart.getToolCallName())))); + return; + } + if (event instanceof ToolResultTextDeltaEvent textDelta) { + Map extra = + tool( + textDelta.getReplyId(), + textDelta.getToolCallId(), + textDelta.getToolCallName()); + extra.put("delta", nullToEmpty(textDelta.getDelta())); + context.emit( + custom( + context, + NAME_TOOL_RESULT, + value(event, "TOOL_RESULT_TEXT_DELTA", extra))); + return; + } + if (event instanceof ToolResultDataDeltaEvent dataDelta) { + Map extra = + tool( + dataDelta.getReplyId(), + dataDelta.getToolCallId(), + dataDelta.getToolCallName()); + extra.put("data", dataDelta.getData()); + context.emit( + custom( + context, + NAME_TOOL_RESULT, + value(event, "TOOL_RESULT_DATA_DELTA", extra))); return; } if (event instanceof ToolResultEndEvent toolResult) { - Map extra = new LinkedHashMap<>(); - extra.put("toolCallId", nullToEmpty(toolResult.getToolCallId())); - extra.put("toolName", nullToEmpty(toolResult.getToolCallName())); + Map extra = + tool( + toolResult.getReplyId(), + toolResult.getToolCallId(), + toolResult.getToolCallName()); if (toolResult.getState() != null) { extra.put("state", toolResult.getState().name()); } - context.emit( - custom(context, NAME_TOOL_RESULT, value(source, "TOOL_RESULT_END", extra))); + context.emit(custom(context, NAME_TOOL_RESULT, value(event, "TOOL_RESULT_END", extra))); return; } if (event instanceof RequireUserConfirmEvent confirm) { Map extra = new LinkedHashMap<>(); extra.put("toolCallCount", confirm.getToolCalls().size()); context.emit( - custom(context, NAME_CONFIRM, value(source, "REQUIRE_USER_CONFIRM", extra))); + custom(context, NAME_CONFIRM, value(event, "REQUIRE_USER_CONFIRM", extra))); return; } // Unknown typed events: prefer Raw (already carries source) over opaque custom. @@ -143,9 +270,10 @@ private static AguiEvent.Custom custom( } private static Map value( - String source, String type, Map extra) { + AgentEvent event, String type, Map extra) { Map value = new LinkedHashMap<>(); - value.put("source", source); + value.put("source", event.getSource()); + putMetadataIfPresent(value, event, AgentEvent.METADATA_TASK_ID); value.put("type", type); if (extra != null) { value.putAll(extra); @@ -153,6 +281,34 @@ private static Map value( return value; } + private static Map block(String replyId, String blockId) { + Map value = new LinkedHashMap<>(); + putIfPresent(value, "replyId", replyId); + putIfPresent(value, "blockId", blockId); + return value; + } + + private static Map tool(String replyId, String toolCallId, String toolName) { + Map value = new LinkedHashMap<>(); + putIfPresent(value, "replyId", replyId); + value.put("toolCallId", nullToEmpty(toolCallId)); + value.put("toolName", nullToEmpty(toolName)); + return value; + } + + private static void putMetadataIfPresent( + Map target, AgentEvent event, String key) { + if (event.getMetadata() != null) { + putIfPresent(target, key, event.getMetadata().get(key)); + } + } + + private static void putIfPresent(Map target, String key, Object value) { + if (value != null) { + target.put(key, value); + } + } + private static String nullToEmpty(String s) { return s != null ? s : ""; } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java index 49e1d5c8d9..b66a193353 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java @@ -34,7 +34,9 @@ public void convert(AgentEvent event, AguiStreamContext context) { if (event instanceof TextBlockDeltaEvent delta) { // AguiEvent.TextMessageStart delays sending when content arrives context.appendTextDelta( - messageId(delta.getReplyId(), delta.getBlockId()), delta.getDelta()); + messageId(delta.getReplyId(), delta.getBlockId()), + delta.getReplyId(), + delta.getDelta()); } else if (event instanceof TextBlockEndEvent end) { context.closeTextMessage(messageId(end.getReplyId(), end.getBlockId())); } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java new file mode 100644 index 0000000000..b38c674f2e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agui.adapter.strategy; + +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; +import java.util.Set; + +/** Converts opt-in text output lifecycle signals to AG-UI custom events and final snapshots. */ +public final class TextOutputDispositionConverter implements AgentEventConverter { + + public static final String EVENT_NAME = "agentscope.text_output.disposition"; + + @Override + public Set> eventTypes() { + return Set.of(TextOutputDispositionEvent.class); + } + + @Override + public void convert(AgentEvent event, AguiStreamContext context) { + context.emitTextOutputDisposition((TextOutputDispositionEvent) event); + } +} diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java index a56ca108e7..9ff2e2437d 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java @@ -129,40 +129,60 @@ public Msg toMsg(AguiMessage aguiMessage) { */ public AguiMessage toAguiMessage(Msg msg) { String role = convertRole(msg.getRole()); - StringBuilder content = new StringBuilder(); + List contentParts = new ArrayList<>(); List toolCalls = new ArrayList<>(); String toolCallId = null; for (ContentBlock block : msg.getContent()) { - if (block instanceof TextBlock tb) { - if (content.length() > 0) { - content.append("\n"); - } - content.append(tb.getText()); - } else if (block instanceof ToolUseBlock tub) { + if (block instanceof ToolUseBlock tub) { toolCalls.add(toAguiToolCall(tub)); } else if (block instanceof ToolResultBlock trb) { toolCallId = trb.getId(); - // Extract text content from tool result for (ContentBlock output : trb.getOutput()) { - if (output instanceof TextBlock tb) { - if (content.length() > 0) { - content.append("\n"); - } - content.append(tb.getText()); - } + addAguiContentPart(contentParts, output); } + } else { + addAguiContentPart(contentParts, block); } } return new AguiMessage( msg.getId(), role, - content.length() > 0 ? new MessageContent.Text(content.toString()) : null, + toMessageContent(contentParts), toolCalls.isEmpty() ? null : toolCalls, toolCallId); } + private MessageContent toMessageContent(List contentParts) { + if (contentParts.isEmpty()) { + return null; + } + if (contentParts.stream().allMatch(TextInputContent.class::isInstance)) { + String text = + contentParts.stream() + .map(TextInputContent.class::cast) + .map(TextInputContent::text) + .collect(Collectors.joining("\n")); + return text.isEmpty() ? null : new MessageContent.Text(text); + } + return new MessageContent.Blocks(contentParts); + } + + private void addAguiContentPart(List contentParts, ContentBlock block) { + if (block instanceof TextBlock text) { + if (text.getText() != null && !text.getText().isEmpty()) { + contentParts.add(new TextInputContent(text.getText())); + } + } else if (block instanceof ImageBlock image) { + contentParts.add(new ImageInputContent(toInputContentSource(image.getSource()), null)); + } else if (block instanceof AudioBlock audio) { + contentParts.add(new AudioInputContent(toInputContentSource(audio.getSource()), null)); + } else if (block instanceof VideoBlock video) { + contentParts.add(new VideoInputContent(toInputContentSource(video.getSource()), null)); + } + } + /** * Convert a list of AG-UI messages to AgentScope messages. * @@ -359,6 +379,16 @@ private Source toSource(InputContentSource inputSource) { throw new IllegalStateException("Unhandled InputContentSource type: " + inputSource); } + private InputContentSource toInputContentSource(Source source) { + if (source instanceof URLSource url) { + return new InputContentUrlSource(url.getUrl(), url.getMimeType()); + } + if (source instanceof Base64Source data) { + return new InputContentDataSource(data.getData(), data.getMediaType()); + } + throw new IllegalStateException("Unhandled Source type: " + source); + } + /** * Convert an AG-UI tool call to an AgentScope ToolUseBlock. * diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java index e45e2ad92d..2f14f007b0 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java @@ -196,10 +196,14 @@ public ProcessResult process(AguiRuntimeContextRequest request) { event, runErrorSeen.get()); }) - .doFinally( - signalType -> - resumeCoordinator.finishRun( - threadId, runId)); + // doOnTerminate runs before completion/error reaches + // downstream. The conditional removal in finishRun also + // prevents an overlapping cancel hook from clearing a newer + // run. + .doOnTerminate( + () -> resumeCoordinator.finishRun(threadId, runId)) + .doOnCancel( + () -> resumeCoordinator.finishRun(threadId, runId)); } catch (Throwable error) { resumeCoordinator.finishRun(threadId, runId); return processorErrorEvents(input, error); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java index ee1403cf4a..b9716b0985 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java @@ -47,6 +47,7 @@ void testDefaultConfig() { assertTrue(config.isEmitToolCallArgs()); assertFalse(config.isEmitTokenUsage()); assertFalse(config.isEnableReasoning()); // Default should be false + assertFalse(config.isTextOutputDispositionEnabled()); assertFalse(config.isEmitRunFinishedAfterError()); // Default should be false assertEquals(Duration.ofMinutes(10), config.getRunTimeout()); assertNull(config.getDefaultAgentId()); @@ -64,6 +65,7 @@ void testBuilderWithDefaults() { assertTrue(config.isEmitStateEvents()); assertTrue(config.isEmitToolCallArgs()); assertFalse(config.isEmitTokenUsage()); + assertFalse(config.isTextOutputDispositionEnabled()); assertEquals(Duration.ofMinutes(10), config.getRunTimeout()); assertFalse(config.isBaseEventPropertiesEnricherEnabled()); assertTrue(config.getEventEnrichers().isEmpty()); @@ -122,6 +124,17 @@ void testBuilderEmitTokenUsage() { assertTrue(configEnabled.isEmitTokenUsage()); } + @Test + void testBuilderTextOutputDispositionEnabled() { + AguiAdapterConfig configDisabled = + AguiAdapterConfig.builder().textOutputDispositionEnabled(false).build(); + AguiAdapterConfig configEnabled = + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); + + assertFalse(configDisabled.isTextOutputDispositionEnabled()); + assertTrue(configEnabled.isTextOutputDispositionEnabled()); + } + @Test void testBuilderRunTimeout() { Duration customTimeout = Duration.ofMinutes(30); @@ -161,6 +174,7 @@ void testBuilderFullConfiguration() { .emitToolCallArgs(false) .emitTokenUsage(true) .enableReasoning(true) + .textOutputDispositionEnabled(true) .emitRunFinishedAfterError(true) .runTimeout(Duration.ofHours(1)) .defaultAgentId("my-agent") @@ -172,6 +186,7 @@ void testBuilderFullConfiguration() { assertFalse(config.isEmitToolCallArgs()); assertTrue(config.isEmitTokenUsage()); assertTrue(config.isEnableReasoning()); + assertTrue(config.isTextOutputDispositionEnabled()); assertTrue(config.isEmitRunFinishedAfterError()); assertEquals(Duration.ofHours(1), config.getRunTimeout()); assertEquals("my-agent", config.getDefaultAgentId()); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index 3616d1b321..536319444c 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -39,8 +39,15 @@ import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiResume; import io.agentscope.core.agui.model.AguiTool; +import io.agentscope.core.agui.model.AudioInputContent; +import io.agentscope.core.agui.model.ImageInputContent; +import io.agentscope.core.agui.model.InputContentDataSource; +import io.agentscope.core.agui.model.InputContentUrlSource; +import io.agentscope.core.agui.model.MessageContent; import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.agui.model.TextInputContent; import io.agentscope.core.agui.model.ToolMergeMode; +import io.agentscope.core.agui.model.VideoInputContent; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.AgentResultEvent; @@ -50,11 +57,13 @@ import io.agentscope.core.event.DataBlockStartEvent; import io.agentscope.core.event.ExternalExecutionResultEvent; import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.ModelCallStartEvent; import io.agentscope.core.event.RequireExternalExecutionEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ThinkingBlockEndEvent; import io.agentscope.core.event.ThinkingBlockStartEvent; @@ -67,15 +76,22 @@ import io.agentscope.core.event.ToolResultTextDeltaEvent; import io.agentscope.core.event.UserConfirmResultEvent; import io.agentscope.core.message.AssistantMessage; +import io.agentscope.core.message.AudioBlock; +import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultState; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import io.agentscope.core.message.VideoBlock; import io.agentscope.core.model.ChatUsage; import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.state.AgentState; import io.agentscope.core.tool.SchemaOnlyTool; import io.agentscope.core.tool.Toolkit; import io.agentscope.harness.agent.HarnessAgent; @@ -86,6 +102,8 @@ import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.ArgumentCaptor; import reactor.core.publisher.Flux; @@ -308,6 +326,596 @@ void testRunUsesHarnessStreamEventsViaReflection() { @Nested class TextAndReasoningConversionTests { + @Test + void testTextOutputDispositionRemainsDisabledWithoutChangingLegacySequenceOrMessageId() { + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-legacy", "react"), + new ModelCallStartEvent("reply-legacy"), + new TextBlockDeltaEvent("reply-legacy", "text-1", "answer"), + new TextBlockEndEvent("reply-legacy", "text-1"), + new AgentResultEvent( + AssistantMessage.builder() + .id("reply-legacy") + .content(TextBlock.builder().text("answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build()), + new AgentEndEvent("reply-legacy")); + + assertEquals( + List.of( + AguiEventType.RUN_STARTED, + AguiEventType.TEXT_MESSAGE_START, + AguiEventType.TEXT_MESSAGE_CONTENT, + AguiEventType.TEXT_MESSAGE_END, + AguiEventType.RUN_FINISHED), + types(events)); + assertEquals( + List.of("reply-legacy-text-1", "reply-legacy-text-1", "reply-legacy-text-1"), + events.stream() + .filter( + event -> + event instanceof AguiEvent.TextMessageStart + || event instanceof AguiEvent.TextMessageContent + || event instanceof AguiEvent.TextMessageEnd) + .map( + event -> { + if (event instanceof AguiEvent.TextMessageStart start) { + return start.messageId(); + } + if (event instanceof AguiEvent.TextMessageContent content) { + return content.messageId(); + } + return ((AguiEvent.TextMessageEnd) event).messageId(); + }) + .toList()); + assertFalse(events.stream().anyMatch(AguiEvent.Custom.class::isInstance)); + assertFalse(events.stream().anyMatch(AguiEvent.MessagesSnapshot.class::isInstance)); + } + + @Test + void testEnabledDispositionUsesSegmentIdsAndEmitsOneCustomEventWithoutReasoning() { + AguiAdapterConfig config = + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); + List events = + runReActEvents( + config, + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "text-1", "first"), + new TextBlockEndEvent("reply-1", "text-1"), + new TextBlockDeltaEvent("reply-1", "text-2", "second"), + new TextBlockEndEvent("reply-1", "text-2"), + new ModelCallStartEvent("reply-2")); + + assertEquals( + List.of( + "reply-1-text-1", + "reply-1-text-1", + "reply-1-text-1", + "reply-1-text-2", + "reply-1-text-2", + "reply-1-text-2"), + events.stream() + .filter( + event -> + event instanceof AguiEvent.TextMessageStart + || event instanceof AguiEvent.TextMessageContent + || event instanceof AguiEvent.TextMessageEnd) + .map( + event -> { + if (event instanceof AguiEvent.TextMessageStart start) { + return start.messageId(); + } + if (event instanceof AguiEvent.TextMessageContent content) { + return content.messageId(); + } + return ((AguiEvent.TextMessageEnd) event).messageId(); + }) + .toList()); + AguiEvent.Custom disposition = + events.stream() + .filter(AguiEvent.Custom.class::isInstance) + .map(AguiEvent.Custom.class::cast) + .findFirst() + .orElseThrow(); + assertEquals("agentscope.text_output.disposition", disposition.name()); + assertEquals( + Map.of( + "replyId", + "reply-1", + "messageIds", + List.of("reply-1-text-1", "reply-1-text-2"), + "disposition", + "INTERMEDIATE"), + customValue(disposition).entrySet().stream() + .filter(entry -> entry.getValue() != null) + .collect( + java.util.stream.Collectors.toMap( + Map.Entry::getKey, Map.Entry::getValue))); + assertTrue(customValue(disposition).containsKey("generateReason")); + assertNull(customValue(disposition).get("generateReason")); + assertFalse( + events.stream() + .anyMatch(event -> event.getType().name().startsWith("REASONING"))); + } + + @Test + void testToolFollowupSegmentInheritsSingleIntermediateDisposition() { + AguiAdapterConfig config = + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); + List events = + runReActEvents( + config, + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "text-1", "first"), + new TextBlockEndEvent("reply-1", "text-1"), + new ToolCallStartEvent("reply-1", "tool-1", "lookup"), + new ToolCallEndEvent("reply-1", "tool-1", "lookup"), + new TextBlockDeltaEvent("reply-1", "text-2", "second"), + new TextBlockEndEvent("reply-1", "text-2")); + + assertEquals( + List.of( + "reply-1-text-1", + "reply-1-text-1", + "reply-1-text-1", + "reply-1-text-2", + "reply-1-text-2", + "reply-1-text-2"), + events.stream() + .filter( + event -> + event instanceof AguiEvent.TextMessageStart + || event instanceof AguiEvent.TextMessageContent + || event instanceof AguiEvent.TextMessageEnd) + .map( + event -> { + if (event instanceof AguiEvent.TextMessageStart start) { + return start.messageId(); + } + if (event instanceof AguiEvent.TextMessageContent content) { + return content.messageId(); + } + return ((AguiEvent.TextMessageEnd) event).messageId(); + }) + .toList()); + List dispositions = + events.stream() + .filter(AguiEvent.Custom.class::isInstance) + .map(AguiEvent.Custom.class::cast) + .filter( + event -> + "agentscope.text_output.disposition" + .equals(event.name())) + .toList(); + assertEquals(2, dispositions.size()); + assertEquals( + List.of("reply-1-text-1"), customValue(dispositions.get(0)).get("messageIds")); + assertEquals( + List.of("reply-1-text-1", "reply-1-text-2"), + customValue(dispositions.get(1)).get("messageIds")); + assertEquals("INTERMEDIATE", customValue(dispositions.get(1)).get("disposition")); + assertFalse( + events.stream() + .anyMatch(event -> event.getType().name().startsWith("REASONING"))); + } + + @ParameterizedTest + @EnumSource( + value = GenerateReason.class, + names = {"MODEL_STOP", "STRUCTURED_OUTPUT", "MAX_ITERATIONS"}) + void testEnabledDispositionEmitsMessagesSnapshotForAllowedFinalReasons( + GenerateReason generateReason) { + List events = runTerminalDisposition(generateReason, null); + + assertEquals( + 1, + events.stream().filter(AguiEvent.MessagesSnapshot.class::isInstance).count()); + assertTrue( + events.indexOf( + events.stream() + .filter(AguiEvent.MessagesSnapshot.class::isInstance) + .findFirst() + .orElseThrow()) + < events.indexOf( + events.stream() + .filter(AguiEvent.RunFinished.class::isInstance) + .findFirst() + .orElseThrow())); + } + + @ParameterizedTest + @EnumSource( + value = GenerateReason.class, + names = {"MODEL_STOP", "STRUCTURED_OUTPUT", "MAX_ITERATIONS"}) + void testEnabledDispositionEmitsSnapshotForResultOnlyFinalReasons( + GenerateReason generateReason) { + Msg result = + AssistantMessage.builder() + .id("reply-result-only") + .content(TextBlock.builder().text("authoritative result").build()) + .generateReason(generateReason) + .build(); + + List events = + runReActEvents( + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(), + new AgentStartEvent("thread-v2", "reply-result-only", "react"), + new AgentResultEvent(result), + new AgentEndEvent("reply-result-only")); + + assertEquals( + List.of( + AguiEventType.RUN_STARTED, + AguiEventType.MESSAGES_SNAPSHOT, + AguiEventType.RUN_FINISHED), + types(events)); + } + + @Test + void testCustomDispositionConverterDoesNotDisableFinalSnapshot() { + AgentEventConverter customDispositionConverter = + new AgentEventConverter() { + @Override + public Set> eventTypes() { + return Set.of(TextOutputDispositionEvent.class); + } + + @Override + public void convert(AgentEvent event, AguiStreamContext context) { + context.emit( + new AguiEvent.Custom( + context.getThreadId(), + context.getRunId(), + "custom.disposition", + Map.of())); + } + }; + AguiAdapterConfig config = + AguiAdapterConfig.builder() + .textOutputDispositionEnabled(true) + .addEventConverter(customDispositionConverter) + .build(); + Msg result = + AssistantMessage.builder() + .id("reply-custom") + .content(TextBlock.builder().text("answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = + runReActEvents( + config, + new AgentStartEvent("thread-v2", "reply-custom", "react"), + new ModelCallStartEvent("reply-custom"), + new TextBlockDeltaEvent("reply-custom", "text-1", "answer"), + new TextBlockEndEvent("reply-custom", "text-1"), + new AgentResultEvent(result), + new AgentEndEvent("reply-custom")); + + assertTrue( + events.stream() + .filter(AguiEvent.Custom.class::isInstance) + .map(AguiEvent.Custom.class::cast) + .anyMatch(event -> "custom.disposition".equals(event.name()))); + assertEquals( + 1, + events.stream().filter(AguiEvent.MessagesSnapshot.class::isInstance).count()); + } + + @ParameterizedTest + @EnumSource( + value = GenerateReason.class, + mode = EnumSource.Mode.EXCLUDE, + names = {"MODEL_STOP", "STRUCTURED_OUTPUT", "MAX_ITERATIONS"}) + void testEnabledDispositionDoesNotEmitMessagesSnapshotForNonFinalReasons( + GenerateReason generateReason) { + List events = runTerminalDisposition(generateReason, null); + + assertFalse(events.stream().anyMatch(AguiEvent.MessagesSnapshot.class::isInstance)); + } + + @Test + void testFinalSnapshotUsesSessionMessagesAndResultDeduplicatedById() { + Msg sessionUser = + Msg.builder() + .id("session-user") + .role(MsgRole.USER) + .textContent("session question") + .build(); + Msg staleResult = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("stale result").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentState state = + AgentState.builder().context(List.of(sessionUser, staleResult)).build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + Msg finalResult = + AssistantMessage.builder() + .id("reply-final") + .content( + List.of( + TextBlock.builder().text("canonical result").build(), + ToolUseBlock.builder() + .id("tool-final") + .name("lookup") + .input(Map.of("q", "answer")) + .build())) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = runTerminalDisposition(callerContext, finalResult); + + int snapshotIndex = + events.indexOf( + events.stream() + .filter(AguiEvent.MessagesSnapshot.class::isInstance) + .findFirst() + .orElseThrow()); + int finishedIndex = + events.indexOf( + events.stream() + .filter(AguiEvent.RunFinished.class::isInstance) + .findFirst() + .orElseThrow()); + AguiEvent.MessagesSnapshot snapshot = + assertInstanceOf(AguiEvent.MessagesSnapshot.class, events.get(snapshotIndex)); + + assertEquals(finishedIndex - 1, snapshotIndex); + assertEquals( + List.of("session-user", "reply-final", "msg-1"), + snapshot.messages().stream().map(AguiMessage::getId).toList()); + AguiMessage resultMessage = + snapshot.messages().stream() + .filter(message -> "reply-final".equals(message.getId())) + .findFirst() + .orElseThrow(); + assertEquals("canonical result", resultMessage.getTextContent()); + assertEquals(1, resultMessage.getToolCalls().size()); + assertFalse( + snapshot.messages().stream() + .map(AguiMessage::getId) + .anyMatch("reply-final-text-live"::equals)); + } + + @Test + void testFinalSnapshotKeepsSubmittedTurnWithCaseInsensitiveUserRole() { + Msg assistantOnly = + AssistantMessage.builder() + .id("reply-previous") + .content(TextBlock.builder().text("previous answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentState state = AgentState.builder().context(List.of(assistantOnly)).build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + Msg finalResult = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("canonical result").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + RunAgentInput runInput = + inputBuilder() + .messages( + List.of( + AguiMessage.textMessage( + "msg-1", + "User", + "submitted question", + null, + null))) + .build(); + + List events = runTerminalDisposition(runInput, callerContext, finalResult); + + assertEquals( + List.of("reply-previous", "msg-1", "reply-final"), + messageIds(snapshot(events)), + "the submitted turn must survive a snapshot that does not echo it"); + } + + @Test + void testFinalSnapshotDoesNotLetRunInputOverrideAuthoritativeHistory() { + Msg authoritativeUser = + Msg.builder() + .id("shared-user") + .role(MsgRole.USER) + .textContent("state user") + .build(); + Msg authoritativeAssistant = + AssistantMessage.builder() + .id("shared-assistant") + .content(TextBlock.builder().text("state answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentState state = + AgentState.builder() + .context(List.of(authoritativeUser, authoritativeAssistant)) + .build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + RunAgentInput runInput = + inputBuilder() + .messages( + List.of( + AguiMessage.userMessage("shared-user", "client user"), + AguiMessage.assistantMessage( + "shared-assistant", "client answer"), + AguiMessage.assistantMessage( + "client-only", "injected assistant"))) + .build(); + Msg result = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("canonical result").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + AguiEvent.MessagesSnapshot snapshot = + snapshot(runTerminalDisposition(runInput, callerContext, result)); + + assertEquals( + List.of("shared-user", "shared-assistant", "reply-final"), + messageIds(snapshot)); + assertEquals("state user", snapshot.messages().get(0).getTextContent()); + assertEquals("state answer", snapshot.messages().get(1).getTextContent()); + } + + @Test + void testTaskIdOnlyChildEventsStayOutsideParentLifecycleAndSnapshot() { + Msg childResult = + AssistantMessage.builder() + .id("child-reply") + .content(TextBlock.builder().text("child answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentResultEvent childResultEvent = new AgentResultEvent(childResult); + childResultEvent.withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + AgentEndEvent childEnd = new AgentEndEvent("child-reply"); + childEnd.withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + + List events = + runReActFlux( + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(), + Flux.just(childResultEvent, childEnd, new AgentEndEvent("parent"))); + + assertFalse(events.stream().anyMatch(AguiEvent.MessagesSnapshot.class::isInstance)); + assertEquals( + 2, + events.stream().filter(AguiEvent.Custom.class::isInstance).count(), + "both task-id-only child events must use the subagent converter"); + assertEquals( + 1, + events.stream().filter(AguiEvent.RunFinished.class::isInstance).count(), + "only the parent end should finish the AG-UI run"); + } + + @Test + void testFinalSnapshotFallsBackToOriginalInputWithoutAgentState() { + AguiMessage inputMessage = + AguiMessage.userMessage( + "input-media", + List.of( + new TextInputContent("describe this"), + new ImageInputContent( + new InputContentUrlSource( + "https://example.test/input.png", "image/png"), + Map.of("detail", "high")))); + RunAgentInput runInput = inputBuilder().messages(List.of(inputMessage)).build(); + Msg result = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("description").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = runTerminalDisposition(runInput, null, result); + + AguiEvent.MessagesSnapshot snapshot = snapshot(events); + assertEquals(List.of("input-media", "reply-final"), messageIds(snapshot)); + assertEquals(inputMessage, snapshot.messages().get(0)); + } + + @Test + void testFinalSnapshotExcludesOnlySegmentsCreatedByCurrentRun() { + Msg user = Msg.builder().id("session-user").role(MsgRole.USER).textContent("q").build(); + Msg generatedSegment = + AssistantMessage.builder() + .id("reply-final-text-live") + .content(TextBlock.builder().text("preview").build()) + .build(); + Msg legitimatePatternId = + AssistantMessage.builder() + .id("order-text-1") + .content(TextBlock.builder().text("kept").build()) + .build(); + AgentState state = + AgentState.builder() + .context(List.of(user, generatedSegment, legitimatePatternId)) + .build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + + List events = + runTerminalDisposition(GenerateReason.MODEL_STOP, callerContext); + + assertEquals( + List.of("session-user", "order-text-1", "msg-1", "reply-final"), + messageIds(snapshot(events))); + } + + @Test + void testFinalSnapshotPreservesMultimodalStateAndResultContent() { + Msg history = + Msg.builder() + .id("history-media") + .role(MsgRole.USER) + .content( + List.of( + TextBlock.builder().text("history text").build(), + ImageBlock.builder() + .source( + new URLSource( + "https://example.test/history.png", + "image/png")) + .build(), + AudioBlock.builder() + .source( + new Base64Source( + "audio/wav", "aGlzdG9yeQ==")) + .build())) + .build(); + AgentState state = AgentState.builder().context(List.of(history)).build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + Msg result = + AssistantMessage.builder() + .id("reply-media") + .content( + List.of( + TextBlock.builder().text("result text").build(), + VideoBlock.builder() + .source( + new URLSource( + "https://example.test/result.mp4", + "video/mp4")) + .build())) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = runTerminalDisposition(callerContext, result); + + AguiEvent.MessagesSnapshot snapshot = snapshot(events); + MessageContent.Blocks historyContent = + assertInstanceOf( + MessageContent.Blocks.class, snapshot.messages().get(0).getContent()); + assertEquals( + List.of( + TextInputContent.class, + ImageInputContent.class, + AudioInputContent.class), + historyContent.parts().stream().map(Object::getClass).toList()); + ImageInputContent image = + assertInstanceOf(ImageInputContent.class, historyContent.parts().get(1)); + assertEquals( + new InputContentUrlSource("https://example.test/history.png", "image/png"), + image.source()); + AudioInputContent audio = + assertInstanceOf(AudioInputContent.class, historyContent.parts().get(2)); + assertEquals(new InputContentDataSource("aGlzdG9yeQ==", "audio/wav"), audio.source()); + + MessageContent.Blocks resultContent = + assertInstanceOf( + MessageContent.Blocks.class, snapshot.messages().get(2).getContent()); + assertEquals( + List.of(TextInputContent.class, VideoInputContent.class), + resultContent.parts().stream().map(Object::getClass).toList()); + VideoInputContent video = + assertInstanceOf(VideoInputContent.class, resultContent.parts().get(1)); + assertEquals( + new InputContentUrlSource("https://example.test/result.mp4", "video/mp4"), + video.source()); + } + @Test void testTextBlockEventsConvertToAguiTextMessageEvents() { List events = @@ -611,6 +1219,55 @@ void testStateIsIsolatedAcrossMultipleSubscriptions() { 1, secondEvents.stream().filter(AguiEvent.ToolCallEnd.class::isInstance).count()); } + + private List runTerminalDisposition( + GenerateReason generateReason, RuntimeContext callerContext) { + Msg result = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("canonical result").build()) + .generateReason(generateReason) + .build(); + return runTerminalDisposition(input(), callerContext, result); + } + + private List runTerminalDisposition(RuntimeContext callerContext, Msg result) { + return runTerminalDisposition(input(), callerContext, result); + } + + private List runTerminalDisposition( + RunAgentInput runInput, RuntimeContext callerContext, Msg result) { + ReActAgent agent = mock(ReActAgent.class); + when(agent.streamEvents(anyList(), any(RuntimeContext.class))) + .thenReturn( + Flux.just( + new AgentStartEvent("thread-v2", "reply-final", "react"), + new ModelCallStartEvent("reply-final"), + new TextBlockDeltaEvent( + "reply-final", "text-live", "streamed preview"), + new TextBlockEndEvent("reply-final", "text-live"), + new AgentResultEvent(result), + new AgentEndEvent("reply-final"))); + AguiAdapterConfig config = + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); + + return new AguiAgentAdapter(agent, config) + .run(runInput, callerContext) + .collectList() + .block(); + } + + private AguiEvent.MessagesSnapshot snapshot(List events) { + return events.stream() + .filter(AguiEvent.MessagesSnapshot.class::isInstance) + .map(AguiEvent.MessagesSnapshot.class::cast) + .findFirst() + .orElseThrow(); + } + + private List messageIds(AguiEvent.MessagesSnapshot snapshot) { + return snapshot.messages().stream().map(AguiMessage::getId).toList(); + } } @Nested diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java index 4d1df67a57..e96d1e78b5 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java @@ -16,14 +16,37 @@ package io.agentscope.core.agui.adapter.strategy; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.agui.adapter.AguiAdapterConfig; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.event.AgentEndEvent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextBlockEndEvent; +import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; +import io.agentscope.core.event.ThinkingBlockDeltaEvent; +import io.agentscope.core.event.ThinkingBlockEndEvent; +import io.agentscope.core.event.ThinkingBlockStartEvent; +import io.agentscope.core.event.ToolCallDeltaEvent; +import io.agentscope.core.event.ToolCallEndEvent; +import io.agentscope.core.event.ToolCallStartEvent; +import io.agentscope.core.event.ToolResultDataDeltaEvent; +import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.event.ToolResultStartEvent; +import io.agentscope.core.event.ToolResultTextDeltaEvent; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultState; +import java.util.ArrayList; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -75,6 +98,160 @@ void subagentEventsDowngradeToCustomByDefault() { assertEquals(SubagentEventConverter.NAME_TEXT, textCustom.name()); } + @Test + void taskIdOnlyEventsDowngradeToCustomByDefault() { + AgentEventConverterRegistry registry = new AgentEventConverterRegistry(); + AguiStreamContext context = + new AguiStreamContext("t1", "r1", AguiAdapterConfig.defaultConfig()); + AgentEndEvent childEnd = new AgentEndEvent("child-reply"); + childEnd.withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + + List events = registry.convert(childEnd, context); + + AguiEvent.Custom custom = assertInstanceOf(AguiEvent.Custom.class, events.get(0)); + assertEquals(SubagentEventConverter.NAME_LIFECYCLE, custom.name()); + assertEquals("AGENT_END", value(custom).get("type")); + assertEquals("task-42", value(custom).get("taskId")); + } + + @Test + void completeSubagentSequencePreservesOrderIdentityAndStructuredPayloads() { + AgentEventConverterRegistry registry = new AgentEventConverterRegistry(); + AguiStreamContext context = + new AguiStreamContext("thread-1", "run-1", AguiAdapterConfig.defaultConfig()); + Msg result = + Msg.builder() + .id("child-result") + .role(MsgRole.ASSISTANT) + .textContent("final answer") + .generateReason(GenerateReason.MODEL_STOP) + .build(); + List sourceEvents = + List.of( + new AgentStartEvent("child-session", "reply-1", "researcher"), + new ThinkingBlockStartEvent("reply-1", "thinking-1"), + new ThinkingBlockDeltaEvent("reply-1", "thinking-1", "analyzing"), + new ThinkingBlockEndEvent("reply-1", "thinking-1"), + new TextBlockStartEvent("reply-1", "text-1"), + new TextBlockDeltaEvent("reply-1", "text-1", "checking sources"), + new TextBlockEndEvent("reply-1", "text-1"), + new TextOutputDispositionEvent( + "reply-1", TextOutputDisposition.INTERMEDIATE, null), + new ToolCallStartEvent("reply-1", "tool-1", "search"), + new ToolCallStartEvent("reply-1", "tool-2", "lookup"), + new ToolCallDeltaEvent("reply-1", "tool-1", "search", "{\"q\":"), + new ToolCallDeltaEvent("reply-1", "tool-2", "lookup", "{\"id\":"), + new ToolCallEndEvent("reply-1", "tool-2", "lookup"), + new ToolCallEndEvent("reply-1", "tool-1", "search"), + new ToolResultStartEvent("reply-1", "tool-2", "lookup"), + new ToolResultStartEvent("reply-1", "tool-1", "search"), + new ToolResultTextDeltaEvent( + "reply-1", "tool-2", "lookup", "lookup result"), + new ToolResultEndEvent( + "reply-1", "tool-2", "lookup", ToolResultState.SUCCESS), + new ToolResultDataDeltaEvent( + "reply-1", + "tool-1", + "search", + TextBlock.builder().text("search data").build()), + new ToolResultEndEvent( + "reply-1", "tool-1", "search", ToolResultState.SUCCESS), + new TextBlockStartEvent("reply-2", "text-2"), + new TextBlockDeltaEvent("reply-2", "text-2", "final answer"), + new TextBlockEndEvent("reply-2", "text-2"), + new AgentResultEvent(result), + new TextOutputDispositionEvent( + "reply-2", + TextOutputDisposition.TERMINAL, + GenerateReason.MODEL_STOP), + new AgentEndEvent("reply-2")); + + List converted = new ArrayList<>(); + for (AgentEvent event : sourceEvents) { + event.withSource("parent/researcher") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + converted.addAll(registry.convert(event, context)); + } + + assertEquals(sourceEvents.size(), converted.size()); + List customEvents = + converted.stream() + .map(event -> assertInstanceOf(AguiEvent.Custom.class, event)) + .toList(); + assertEquals( + List.of( + "subagent.lifecycle:AGENT_START", + "subagent.thinking:THINKING_BLOCK_START", + "subagent.thinking:THINKING_BLOCK_DELTA", + "subagent.thinking:THINKING_BLOCK_END", + "subagent.text:TEXT_BLOCK_START", + "subagent.text:TEXT_BLOCK_DELTA", + "subagent.text:TEXT_BLOCK_END", + "subagent.text_disposition:TEXT_OUTPUT_DISPOSITION", + "subagent.tool_call:TOOL_CALL_START", + "subagent.tool_call:TOOL_CALL_START", + "subagent.tool_call:TOOL_CALL_DELTA", + "subagent.tool_call:TOOL_CALL_DELTA", + "subagent.tool_call:TOOL_CALL_END", + "subagent.tool_call:TOOL_CALL_END", + "subagent.tool_result:TOOL_RESULT_START", + "subagent.tool_result:TOOL_RESULT_START", + "subagent.tool_result:TOOL_RESULT_TEXT_DELTA", + "subagent.tool_result:TOOL_RESULT_END", + "subagent.tool_result:TOOL_RESULT_DATA_DELTA", + "subagent.tool_result:TOOL_RESULT_END", + "subagent.text:TEXT_BLOCK_START", + "subagent.text:TEXT_BLOCK_DELTA", + "subagent.text:TEXT_BLOCK_END", + "subagent.result:AGENT_RESULT", + "subagent.text_disposition:TEXT_OUTPUT_DISPOSITION", + "subagent.lifecycle:AGENT_END"), + customEvents.stream() + .map(event -> event.name() + ":" + value(event).get("type")) + .toList()); + assertTrue( + customEvents.stream() + .allMatch( + event -> + "parent/researcher".equals(value(event).get("source")) + && "task-42".equals(value(event).get("taskId")))); + + Map toolArgs = value(customEvents.get(10)); + assertEquals("reply-1", toolArgs.get("replyId")); + assertEquals("tool-1", toolArgs.get("toolCallId")); + assertEquals("{\"q\":", toolArgs.get("argumentsDelta")); + + Map textResult = value(customEvents.get(16)); + assertEquals("lookup result", textResult.get("delta")); + Map dataResult = value(customEvents.get(18)); + assertInstanceOf(TextBlock.class, dataResult.get("data")); + + Map agentResult = value(customEvents.get(23)); + assertEquals(result, agentResult.get("result")); + Map terminal = value(customEvents.get(24)); + assertEquals("TERMINAL", terminal.get("disposition")); + assertEquals("MODEL_STOP", terminal.get("generateReason")); + } + + @Test + void nullSubagentResultDoesNotInterruptEventConversion() { + AgentEventConverterRegistry registry = new AgentEventConverterRegistry(); + AguiStreamContext context = + new AguiStreamContext("thread-1", "run-1", AguiAdapterConfig.defaultConfig()); + AgentResultEvent event = new AgentResultEvent(null); + event.withSource("parent/researcher") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + + List converted = registry.convert(event, context); + + AguiEvent.Custom custom = assertInstanceOf(AguiEvent.Custom.class, converted.get(0)); + assertEquals(SubagentEventConverter.NAME_RESULT, custom.name()); + assertEquals("AGENT_RESULT", value(custom).get("type")); + assertEquals("parent/researcher", value(custom).get("source")); + assertEquals("task-42", value(custom).get("taskId")); + assertFalse(value(custom).containsKey("result")); + } + @Test void nativeModeKeepsLegacyBehavior() { AgentEventConverterRegistry registry = @@ -100,4 +277,9 @@ void configFlagDefaultsFalse() { .build() .isEmitSubagentEventsAsNative()); } + + @SuppressWarnings("unchecked") + private static Map value(AguiEvent.Custom event) { + return (Map) event.value(); + } } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java index c4f127b260..971e448b45 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; @@ -326,6 +327,45 @@ void processRejectsConcurrentRunOnSameThreadUntilActiveRunFinishes() { } } + @Test + void processReleasesActiveRunBeforeCompletionReachesDownstream() { + AgentResolver resolver = mock(AgentResolver.class); + ReActAgent agent = mock(ReActAgent.class); + when(resolver.resolveAgent(eq("default"), eq("thread-1"), nullable(String.class))) + .thenReturn(agent); + when(agent.streamEvents(anyList(), any(RuntimeContext.class))).thenReturn(Flux.empty()); + AtomicInteger adapterCount = new AtomicInteger(); + AguiRequestProcessor processor = + AguiRequestProcessor.builder() + .agentResolver(resolver) + .adapterFactory( + (resolvedAgent, config) -> { + adapterCount.incrementAndGet(); + return new AguiAgentAdapter(resolvedAgent, config); + }) + .build(); + + AtomicReference> nextRunEvents = new AtomicReference<>(); + processor + .process(request(input("run-1"))) + .events() + .doOnComplete( + () -> + nextRunEvents.set( + processor + .process(request(input("run-2"))) + .events() + .collectList() + .block())) + .blockLast(); + + List events = nextRunEvents.get(); + assertNotNull(events); + assertTrue( + events.stream().noneMatch(AguiEvent.RunError.class::isInstance), events.toString()); + assertEquals(2, adapterCount.get()); + } + @Test void processDoesNotBeginRunUntilEventsAreSubscribed() { AgentResolver resolver = mock(AgentResolver.class); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java index 10264f86c3..da0f05566f 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java @@ -354,10 +354,11 @@ public boolean isRemoteStreaming() { /** * How much of the remote event stream to forward. Defaults to {@link RemoteStreamDetail#FULL} - * (lifecycle, tool calls, text and thinking deltas). Use {@link RemoteStreamDetail#VERBOSE} to - * mirror a local subagent's stream in full — block boundaries, tool output deltas, model calls - * with token usage and every other event — at the cost of more traffic. Only relevant when - * {@link #isRemoteStreaming()} is true. + * (lifecycle, tool calls, text and thinking deltas, text output disposition, and authoritative + * agent result events). Use {@link RemoteStreamDetail#VERBOSE} to mirror a local subagent's + * stream in full — block boundaries, tool output deltas, model calls with token usage and every + * other event — at the cost of more traffic. Only relevant when {@link #isRemoteStreaming()} is + * true. */ public RemoteStreamDetail getRemoteStreamDetail() { return remoteStreamDetail != null ? remoteStreamDetail : RemoteStreamDetail.FULL; @@ -629,9 +630,10 @@ public Builder remoteStreaming(Boolean remoteStreaming) { /** * How much of the remote event stream to forward. {@code null} (default) is treated as - * {@link RemoteStreamDetail#FULL}; {@link RemoteStreamDetail#VERBOSE} forwards every event - * the remote agent emits, matching a local subagent. Only relevant when - * {@link #remoteStreaming(Boolean)} is enabled. + * {@link RemoteStreamDetail#FULL}, including text output disposition and authoritative + * agent result events; {@link RemoteStreamDetail#VERBOSE} forwards every event the remote + * agent emits, matching a local subagent. Only relevant when {@link + * #remoteStreaming(Boolean)} is enabled. */ public Builder remoteStreamDetail(RemoteStreamDetail remoteStreamDetail) { this.remoteStreamDetail = remoteStreamDetail; diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java index b2d5731e9f..0bffd8fdb1 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java @@ -20,9 +20,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventType; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ToolCallEndEvent; import io.agentscope.core.event.ToolCallStartEvent; @@ -62,8 +64,10 @@ private RemoteEventCodec() {} * *

Every event also carries its full serialization in {@link RemoteAgentEvent#getPayload()}, * so a client can restore the original instance instead of the lossy flat fields. Event types - * without a dedicated wire type are forwarded as {@link RemoteEventType#AGENT_EVENT} and are - * only visible at {@code detail=verbose}. + * without a dedicated wire type are forwarded as {@link RemoteEventType#AGENT_EVENT}. At + * {@code detail=full}, text disposition and authoritative result subtypes are included; other + * passthrough subtypes remain visible only at {@code detail=verbose}. The payload stays a JSON + * string, and older clients may ignore the unknown passthrough subtype. */ public static Optional fromAgentEvent(AgentEvent event) { if (event == null) { @@ -74,7 +78,12 @@ public static Optional fromAgentEvent(AgentEvent event) { if (event.getType() != null) { dto.setEventType(event.getType().name()); } - dto.setPayload(serializeEvent(event)); + String payload = serializeEvent(event); + if (event instanceof TextOutputDispositionEvent && (payload == null || payload.isBlank())) { + log.warn("Dropping text output disposition event because its payload is unavailable"); + return Optional.empty(); + } + dto.setPayload(payload); return Optional.of(withTypedFields(dto, event)); } @@ -232,9 +241,26 @@ public static boolean matchesDetail(RemoteEventType type, String detail) { }; } - /** Detail check for a whole DTO; equivalent to {@link #matchesDetail(RemoteEventType, String)}. */ + /** + * Detail check for a whole DTO. At {@code full}, disposition and result passthrough events are + * included while other {@link RemoteEventType#AGENT_EVENT} values remain verbose-only. + */ public static boolean matchesDetail(RemoteAgentEvent event, String detail) { - return event != null && matchesDetail(event.getType(), detail); + if (event == null) { + return false; + } + if (event.getType() != RemoteEventType.AGENT_EVENT) { + return matchesDetail(event.getType(), detail); + } + RemoteStreamDetail level = RemoteStreamDetail.parse(detail); + return level.includes(RemoteStreamDetail.VERBOSE) + || (level.includes(RemoteStreamDetail.FULL) + && isAuthoritativeFullEvent(event.getEventType())); + } + + private static boolean isAuthoritativeFullEvent(String eventType) { + return AgentEventType.TEXT_OUTPUT_DISPOSITION.name().equals(eventType) + || AgentEventType.AGENT_RESULT.name().equals(eventType); } private static RequireUserConfirmEvent toRequireConfirm(RemoteAgentEvent remote) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java index e7ed536074..89d3974141 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java @@ -35,8 +35,9 @@ public enum RemoteEventType { /** * Carries an {@link io.agentscope.core.event.AgentEvent} that has no dedicated wire type, fully - * serialized in {@link RemoteAgentEvent#getPayload()}. Emitted only at {@code detail=verbose}; - * clients that predate this type skip it. + * serialized as a JSON string in {@link RemoteAgentEvent#getPayload()}. At {@code detail=full}, + * {@code TEXT_OUTPUT_DISPOSITION} and {@code AGENT_RESULT} are included; other subtypes require + * {@code detail=verbose}. Clients that predate this type may ignore it. */ AGENT_EVENT } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java index fdee554a66..3e6c4bd036 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java @@ -27,7 +27,12 @@ public enum RemoteStreamDetail { /** Lifecycle, tool call boundaries, tool results and confirmation requests. */ STATUS, - /** {@link #STATUS} plus text and thinking deltas. The default for a streaming parent. */ + /** + * {@link #STATUS} plus text and thinking deltas, text output disposition events, and + * authoritative agent result events. Disposition and result use {@link + * RemoteEventType#AGENT_EVENT} with a JSON string payload. This is the default for a streaming + * parent. + */ FULL, /** diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java index e1fd5c6bcc..447a596cab 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java @@ -25,6 +25,7 @@ import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.AgentEventEmitter; +import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.SubagentExposedEvent; import io.agentscope.core.message.Msg; @@ -69,6 +70,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.Disposable; @@ -771,7 +773,8 @@ private Mono execLocalSync( String userId, String prompt, SpawnedAgent spawned, - RuntimeContext parentCtx) { + RuntimeContext parentCtx, + String taskId) { return Mono.deferContextual( ctxView -> { DefaultAgentManager manager = managerFor(parentCtx); @@ -782,18 +785,30 @@ private Mono execLocalSync( String sourcePath = buildSourcePath(spawned, parentCtx); String replyId = UUID.randomUUID().toString().replace("-", ""); AgentEventEmitter taggedEmitter = - event -> parentEmitter.emit(event.withSource(sourcePath)); + event -> + parentEmitter.emit( + tagForwardedEvent(event, sourcePath, taskId)); parentEmitter.emit( - new AgentStartEvent(spawned.sessionId(), replyId, spawned.agentId()) - .withSource(sourcePath)); + tagForwardedEvent( + new AgentStartEvent( + spawned.sessionId(), replyId, spawned.agentId()), + sourcePath, + taskId)); AtomicBoolean endEmitted = new AtomicBoolean(); - Runnable emitEnd = - () -> { + Consumer emitEnd = + outcome -> { if (endEmitted.compareAndSet(false, true)) { parentEmitter.emit( - new AgentEndEvent(replyId).withSource(sourcePath)); + tagForwardedEvent( + new AgentEndEvent(replyId) + .withMetadataEntry( + AgentEndEvent + .METADATA_INVOCATION_OUTCOME, + outcome), + sourcePath, + taskId)); } }; @@ -805,14 +820,24 @@ private Mono execLocalSync( taggedEmitter)) // Emit before success or error reaches the parent, which may // otherwise complete its event sink before doFinally runs. - .doOnSuccess(ignored -> emitEnd.run()) - .doOnError(ignored -> emitEnd.run()) + .doOnSuccess( + result -> { + if (result != null) { + parentEmitter.emit( + tagForwardedEvent( + new AgentResultEvent(result), + sourcePath, + taskId)); + } + emitEnd.accept(AgentEndEvent.OUTCOME_SUCCESS); + }) + .doOnError(ignored -> emitEnd.accept(AgentEndEvent.OUTCOME_ERROR)) // Preserve best-effort cancellation signaling without emitting a // duplicate if cancellation races with normal termination. .doFinally( signal -> { if (signal == SignalType.CANCEL) { - emitEnd.run(); + emitEnd.accept(AgentEndEvent.OUTCOME_CANCELLED); } }); } @@ -895,6 +920,7 @@ private Mono execWithTimeoutPromotion( Mono.create( sink -> { CompletableFuture bridge = new CompletableFuture<>(); + String taskId = "task_" + UUID.randomUUID(); Mono inner = execLocalSync( @@ -903,7 +929,8 @@ private Mono execWithTimeoutPromotion( userId, task, spawned, - runtimeContext) + runtimeContext, + taskId) .contextWrite( c -> reactor.util.context.Context.of( @@ -954,6 +981,7 @@ private Mono execWithTimeoutPromotion( header, timeoutMs, agentId, + taskId, sink, forceSync, innerSub); @@ -1005,6 +1033,7 @@ private void handleExecError( String header, long timeoutMs, String agentId, + String taskId, reactor.core.publisher.MonoSink sink, boolean forceSync, Disposable innerSub) { @@ -1024,7 +1053,6 @@ private void handleExecError( sink.success(header + "\n" + formatForceSyncTimeout(timeoutMs)); return; } - String taskId = "task_" + UUID.randomUUID(); String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; CompletableFuture textFuture = bridge.thenApply(AgentSpawnTool::textOf); taskRepository.putTask( @@ -1195,6 +1223,15 @@ static String buildRemoteSourcePath(String parentSessionId, String agentId) { */ static AgentEvent tagRemoteForwardedEvent( AgentEvent event, String sourcePath, String taskId, String parentSessionId) { + event = tagForwardedEvent(event, sourcePath, taskId); + if (event == null) return null; + if (parentSessionId != null && !parentSessionId.isBlank()) { + event.withMetadataEntry(AgentEvent.METADATA_PARENT_SESSION_ID, parentSessionId.trim()); + } + return event; + } + + static AgentEvent tagForwardedEvent(AgentEvent event, String sourcePath, String taskId) { if (event == null) { return null; } @@ -1202,9 +1239,6 @@ static AgentEvent tagRemoteForwardedEvent( if (taskId != null && !taskId.isBlank()) { event.withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId); } - if (parentSessionId != null && !parentSessionId.isBlank()) { - event.withMetadataEntry(AgentEvent.METADATA_PARENT_SESSION_ID, parentSessionId.trim()); - } return event; } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java index 115dae5351..83119ec025 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java @@ -39,8 +39,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -97,15 +100,18 @@ private static ChatResponse stopChunk(String id, String text) { } private static ChatResponse toolCallChunk(String id, String toolName, Map in) { + return new ChatResponse( + id, List.of(toolCallBlock(id, toolName, in)), null, Map.of(), "tool_use"); + } + + private static ToolUseBlock toolCallBlock(String id, String toolName, Map in) { String contentJson = io.agentscope.core.util.JsonUtils.getJsonCodec().toJson(in); - ToolUseBlock tc = - ToolUseBlock.builder() - .id("tc-" + id) - .name(toolName) - .input(in) - .content(contentJson) - .build(); - return new ChatResponse(id, List.of(tc), null, Map.of(), "tool_use"); + return ToolUseBlock.builder() + .id("tc-" + id) + .name(toolName) + .input(in) + .content(contentJson) + .build(); } private void writeSubagentSpec(String childId, String description, String body) @@ -197,6 +203,119 @@ void streamEvents_childEventsForwardedWithSource() throws Exception { "expected at least one parent event with source == null"); } + @Test + void streamEvents_sameSourceConcurrentChildCallsHaveDistinctTaskIds() throws Exception { + String childId = "worker"; + writeSubagentSpec(childId, "Worker", "Complete the assigned task."); + + Model model = mock(Model.class); + AtomicInteger streamCalls = new AtomicInteger(); + when(model.getModelName()).thenReturn("stub"); + when(model.stream(anyList(), any(), any())) + .thenAnswer( + ignored -> { + int call = streamCalls.getAndIncrement(); + if (call == 0) { + return Flux.just( + new ChatResponse( + "p1", + List.of( + toolCallBlock( + "first", + "agent_spawn", + Map.of( + "agent_id", + childId, + "task", + "first task", + "timeout_seconds", + 60)), + toolCallBlock( + "second", + "agent_spawn", + Map.of( + "agent_id", + childId, + "task", + "second task", + "timeout_seconds", + 60))), + null, + Map.of(), + "tool_use")); + } + if (call <= 2) { + return Flux.just(stopChunk("child-" + call, "completed")); + } + return Flux.just(stopChunk("parent-final", "done")); + }); + + parent = + HarnessAgent.builder() + .name("parent") + .model(model) + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .build(); + + List events = + parent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("start") + .build()), + RuntimeContext.builder().sessionId("sess-shared-source").build()) + .collectList() + .block(); + + assertNotNull(events); + List childEvents = + events.stream() + .filter( + event -> + ("sess-shared-source/" + childId).equals(event.getSource())) + .collect(Collectors.toList()); + assertFalse( + childEvents.isEmpty(), + "expected events forwarded from both child calls; got: " + + events.stream() + .map(event -> event.getType() + "(src=" + event.getSource() + ")") + .collect(Collectors.joining(", "))); + assertTrue( + childEvents.stream() + .allMatch( + event -> + event.getMetadata() != null + && event.getMetadata() + .containsKey(AgentEvent.METADATA_TASK_ID)), + "every forwarded child event must carry its taskId"); + + Set taskIds = + childEvents.stream() + .map(event -> event.getMetadata().get(AgentEvent.METADATA_TASK_ID)) + .collect(Collectors.toCollection(HashSet::new)); + assertEquals(2, taskIds.size(), "same-source calls must retain distinct taskIds"); + for (Object taskId : taskIds) { + List taskEvents = + childEvents.stream() + .filter( + event -> + taskId.equals( + event.getMetadata() + .get(AgentEvent.METADATA_TASK_ID))) + .collect(Collectors.toList()); + assertTrue( + taskEvents.stream() + .anyMatch(event -> event.getType() == AgentEventType.AGENT_START), + "each task must have a tagged child start event"); + assertTrue( + taskEvents.stream() + .anyMatch(event -> event.getType() == AgentEventType.AGENT_END), + "each task must have a tagged child end event"); + } + } + // ----------------------------------------------------------------- // 1b. streamEvents() under AsyncToolMiddleware — child events still // forwarded with source tag (regression for the bare-subscribe @@ -369,6 +488,18 @@ void streamEvents_childAgentStartAndEndEmittedWithSource() throws Exception { .collect(Collectors.toList()); assertFalse(childEnds.isEmpty(), "expected child AGENT_END with source"); assertTrue(childEnds.get(0).getSource().contains(childId)); + + List childResults = + events.stream() + .filter( + e -> + e.getType() == AgentEventType.AGENT_RESULT + && e.getSource() != null) + .collect(Collectors.toList()); + assertEquals(1, childResults.size(), "expected one authoritative child AGENT_RESULT"); + assertTrue( + events.indexOf(childResults.get(0)) < events.indexOf(childEnds.get(0)), + "child AGENT_RESULT must be emitted before child AGENT_END"); } // ----------------------------------------------------------------- diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java index e7710de45e..8df7dd835e 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java @@ -85,6 +85,7 @@ void localFilesystem_filesPersistAcrossCalls() throws Exception { HarnessAgent.builder() .name("my-local-assistant") .model(stubModel("done")) + .disableMemoryHooks() .workspace(workspace.toAbsolutePath().normalize().toString()) .abstractFilesystem(new LocalFilesystemWithShell(workspace)) .build()) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java index 40980429e3..7f3f3a12eb 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java @@ -44,6 +44,8 @@ import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ThinkingBlockEndEvent; import io.agentscope.core.event.ThinkingBlockStartEvent; @@ -107,6 +109,9 @@ private static Map sampleEvents() { events.put(AgentEventType.TEXT_BLOCK_START, new TextBlockStartEvent("reply", "b1")); events.put(AgentEventType.TEXT_BLOCK_DELTA, new TextBlockDeltaEvent("reply", "b1", "hi")); events.put(AgentEventType.TEXT_BLOCK_END, new TextBlockEndEvent("reply", "b1")); + events.put( + AgentEventType.TEXT_OUTPUT_DISPOSITION, + new TextOutputDispositionEvent("reply", TextOutputDisposition.TERMINAL, null)); events.put(AgentEventType.THINKING_BLOCK_START, new ThinkingBlockStartEvent("reply", "b2")); events.put( AgentEventType.THINKING_BLOCK_DELTA, diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java index cc0178dc57..6fb2e4a9a5 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java @@ -25,6 +25,8 @@ import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ToolCallStartEvent; import io.agentscope.core.message.ToolCallState; import io.agentscope.core.message.ToolUseBlock; @@ -116,6 +118,22 @@ void detailFilterHidesTextUnlessFull() { assertTrue(RemoteEventCodec.matchesDetail(RemoteEventType.REQUIRE_CONFIRM, null)); } + @Test + void roundTripTextOutputDispositionAsAgentEventPayload() { + RemoteAgentEvent remote = + RemoteEventCodec.fromAgentEvent( + new TextOutputDispositionEvent( + "reply-1", TextOutputDisposition.INTERMEDIATE, null)) + .orElseThrow(); + + assertEquals(RemoteEventType.AGENT_EVENT, remote.getType()); + assertEquals("TEXT_OUTPUT_DISPOSITION", remote.getEventType()); + assertInstanceOf(String.class, remote.getPayload()); + assertInstanceOf( + TextOutputDispositionEvent.class, + RemoteEventCodec.toAgentEvent(remote).orElseThrow()); + } + @Test void unknownInternalEventsDropped() { // Custom/other events without a codec mapping diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/AgentProtocolTaskClientTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/AgentProtocolTaskClientTest.java new file mode 100644 index 0000000000..b2b86dc21f --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/AgentProtocolTaskClientTest.java @@ -0,0 +1,85 @@ +/* + * 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.harness.agent.subagent.task; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import io.agentscope.harness.agent.subagent.protocol.RemoteAgentEvent; +import java.io.Closeable; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AgentProtocolTaskClientTest { + + @Test + void unknownWireEnumAndFieldDoNotDropStringPayload() throws Exception { + byte[] body = + ("data: {\"seq\":7,\"type\":\"FUTURE_EVENT\",\"taskId\":\"task-1\"," + + "\"payload\":\"{\\\"event\\\":\\\"kept\\\"}\"," + + "\"futureField\":\"ignored\"}\n\n") + .getBytes(UTF_8); + HttpServer server = + HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext( + "/tasks/task-1/events", + exchange -> { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, body.length); + try (var response = exchange.getResponseBody()) { + response.write(body); + } + }); + server.start(); + + CountDownLatch received = new CountDownLatch(1); + AtomicReference captured = new AtomicReference<>(); + try { + AgentProtocolTaskClient client = new AgentProtocolTaskClient(); + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + try (Closeable stream = + client.openEventStream( + baseUrl, + Map.of(), + "task-1", + 0, + event -> { + captured.set(event); + received.countDown(); + })) { + assertTrue( + received.await(5, TimeUnit.SECONDS), + "future wire enum event should remain parseable"); + } + } finally { + server.stop(0); + } + + RemoteAgentEvent event = captured.get(); + assertNull(event.getType()); + assertEquals(7, event.getSeq()); + assertEquals("task-1", event.getTaskId()); + assertEquals("{\"event\":\"kept\"}", event.getPayload()); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java index e4a35e7a07..08b417211f 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java @@ -16,6 +16,7 @@ package io.agentscope.harness.agent.tool; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -25,7 +26,12 @@ import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.AgentEventEmitter; +import io.agentscope.core.event.AgentEventStreams; import io.agentscope.core.event.AgentStartEvent; +import io.agentscope.core.event.ModelCallStartEvent; +import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.message.Msg; import io.agentscope.harness.agent.HarnessAgent; import io.agentscope.harness.agent.middleware.SubagentEntry; @@ -44,6 +50,7 @@ import org.junit.jupiter.api.Timeout; import org.mockito.Mockito; import reactor.core.Disposable; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** @@ -83,6 +90,10 @@ T first(Class type) { .findFirst() .orElseThrow(); } + + List snapshot() { + return List.copyOf(events); + } } @Test @@ -96,7 +107,10 @@ void parentCancel_emitsAgentEndEvent() throws Exception { Mockito.when(harness.getDelegate()).thenReturn(delegate); // Child never finishes on its own, so the only way this Mono terminates is cancel. Mockito.when(harness.call(any(Msg.class), any(RuntimeContext.class))) - .thenReturn(Mono.never().doOnSubscribe(ignored -> childStarted.countDown())); + .thenReturn( + emitVisibleTextThen( + Mono.never() + .doOnSubscribe(ignored -> childStarted.countDown()))); DefaultAgentManager manager = new DefaultAgentManager( @@ -135,6 +149,15 @@ void parentCancel_emitsAgentEndEvent() throws Exception { + " without a matching AgentEndEvent leaves consumers rendering the" + " subagent as running forever (doOnTerminate does not fire on cancel)"); assertReplyIdPair(emitter); + assertEquals( + AgentEndEvent.OUTCOME_CANCELLED, + emitter.first(AgentEndEvent.class) + .getMetadata() + .get(AgentEndEvent.METADATA_INVOCATION_OUTCOME)); + assertFalse( + annotatedEvents(emitter).stream() + .anyMatch(TextOutputDispositionEvent.class::isInstance), + "cancelled child text must not be classified as a successful terminal reply"); } @Test @@ -145,7 +168,10 @@ void normalCompletion_emitsPairedEvents() throws Exception { HarnessAgent harness = Mockito.mock(HarnessAgent.class); Mockito.when(harness.getDelegate()).thenReturn(delegate); Mockito.when(harness.call(any(Msg.class), any(RuntimeContext.class))) - .thenReturn(Mono.just(Msg.builder().name("child").textContent("done").build())); + .thenReturn( + emitVisibleTextThen( + Mono.just( + Msg.builder().name("child").textContent("done").build()))); DefaultAgentManager manager = new DefaultAgentManager( @@ -164,6 +190,55 @@ void normalCompletion_emitsPairedEvents() throws Exception { assertEquals(1, emitter.count(AgentStartEvent.class), "expected one start event"); assertEquals(1, emitter.count(AgentEndEvent.class), "expected one end event"); assertReplyIdPair(emitter); + assertEquals( + AgentEndEvent.OUTCOME_SUCCESS, + emitter.first(AgentEndEvent.class) + .getMetadata() + .get(AgentEndEvent.METADATA_INVOCATION_OUTCOME)); + TextOutputDispositionEvent terminal = + annotatedEvents(emitter).stream() + .filter(TextOutputDispositionEvent.class::isInstance) + .map(TextOutputDispositionEvent.class::cast) + .findFirst() + .orElseThrow(); + assertEquals("child-model-reply", terminal.getReplyId()); + assertEquals(TextOutputDisposition.TERMINAL, terminal.getDisposition()); + } + + @Test + @DisplayName("child error emits an abnormal end without terminal disposition") + void childError_doesNotEmitTerminalDisposition() { + ReActAgent delegate = Mockito.mock(ReActAgent.class); + HarnessAgent harness = Mockito.mock(HarnessAgent.class); + Mockito.when(harness.getDelegate()).thenReturn(delegate); + Mockito.when(harness.call(any(Msg.class), any(RuntimeContext.class))) + .thenReturn( + emitVisibleTextThen(Mono.error(new IllegalStateException("child failed")))); + + DefaultAgentManager manager = + new DefaultAgentManager( + List.of(new SubagentEntry("harness_agent", "Harness child", rc -> harness)), + null); + AgentSpawnTool tool = new AgentSpawnTool(manager, new NoopTaskRepository(), 0); + RuntimeContext parentCtx = + RuntimeContext.builder().sessionId("parent-session").userId("parent-user").build(); + RecordingEmitter emitter = new RecordingEmitter(); + + String result = + tool.agentSpawn(parentCtx, null, "harness_agent", "work", null, 30, null) + .contextWrite(ctx -> ctx.put(AgentEventEmitter.CONTEXT_KEY, emitter)) + .block(); + + assertTrue(result.contains("status: error")); + assertEquals( + AgentEndEvent.OUTCOME_ERROR, + emitter.first(AgentEndEvent.class) + .getMetadata() + .get(AgentEndEvent.METADATA_INVOCATION_OUTCOME)); + assertFalse( + annotatedEvents(emitter).stream() + .anyMatch(TextOutputDispositionEvent.class::isInstance), + "failed child text must not be classified as a successful terminal reply"); } private static void assertReplyIdPair(RecordingEmitter emitter) { @@ -173,6 +248,24 @@ private static void assertReplyIdPair(RecordingEmitter emitter) { assertEquals(start.getReplyId(), end.getReplyId()); } + private static Mono emitVisibleTextThen(Mono terminal) { + return Mono.deferContextual( + context -> { + AgentEventEmitter emitter = + AgentEventEmitter.fromForwardingContext(context).orElseThrow(); + emitter.emit(new ModelCallStartEvent("child-model-reply")); + emitter.emit( + new TextBlockDeltaEvent("child-model-reply", "child-text", "working")); + return terminal; + }); + } + + private static List annotatedEvents(RecordingEmitter emitter) { + return AgentEventStreams.withTextOutputDisposition(Flux.fromIterable(emitter.snapshot())) + .collectList() + .block(); + } + private static final class NoopTaskRepository implements TaskRepository { @Override public BackgroundTask getTask(RuntimeContext rc, String sessionId, String taskId) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java index ad98621307..a5f534c3dd 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java @@ -81,6 +81,18 @@ void tagRemoteForwardedEvent_skipsBlankParentSessionId() { .containsKey(AgentEvent.METADATA_PARENT_SESSION_ID)); } + @Test + void tagForwardedEvent_setsSourceAndTaskIdWithoutDiscardingMetadata() { + TextBlockDeltaEvent event = new TextBlockDeltaEvent(null, "b1", "hello"); + event.withMetadataEntry("keep", "me"); + + AgentEvent tagged = AgentSpawnTool.tagForwardedEvent(event, "parent/worker", "task_abc"); + + assertEquals("parent/worker", tagged.getSource()); + assertEquals("task_abc", tagged.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertEquals("me", tagged.getMetadata().get("keep")); + } + @Test void collectParentDenyRules_returnsEmptyWhenInheritDisabled() { AgentState parent =