Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5a57181
feat(core): 新增文本输出处置事件
dargoner Sep 3, 2026
8a45285
refactor(core): 统一回复生命周期跟踪规则
dargoner Sep 3, 2026
da5a6f0
fix(core): 修正任务事件的顶层来源判定
dargoner Sep 3, 2026
74e204f
feat(core): 增加流式文本处置标注器
dargoner Sep 3, 2026
73bf320
test(core): 补充流标注器边界覆盖
dargoner Sep 3, 2026
a979755
test(core): 强化顶层结束取消屏障
dargoner Sep 3, 2026
28aecf9
fix(harness): 为子代理流事件附加任务标识
dargoner Sep 3, 2026
d707d81
feat(protocol): 透传文本处置和权威结果事件
dargoner Sep 3, 2026
27a3877
feat(agui): 支持实时文本处置与结果快照
dargoner Sep 3, 2026
f703918
fix(agui): 校准最终消息快照边界与多模态内容
dargoner Sep 3, 2026
95f2c72
docs(streaming): 说明文本处置与结果校准用法
dargoner Sep 3, 2026
b8fd236
docs(streaming): 修正事件序列与兼容性证据
dargoner Sep 3, 2026
230cd2a
fix(streaming): 修正文本处置的调用结束关联
dargoner Sep 4, 2026
5d89e23
fix(streaming): 补齐子智能体结构化事件序列
dargoner Sep 6, 2026
785fc94
fix(streaming): 修复权威结果与预览并发边界
dargoner Sep 6, 2026
19b32e5
fix(agui): 复用官方文本段消息 ID 并对齐处置事件
dargoner Sep 11, 2026
39ecc5f
chore(pr): 收敛变更范围,移除控制面改动与内部实施报告
dargoner Sep 12, 2026
ffa6971
fix(core): 修复文本处置流的结束事件与迟到事件处理
dargoner Sep 12, 2026
c9503ab
fix(streaming): 处理文本处置与 AG-UI 适配器的第二轮复审意见
dargoner Sep 12, 2026
0492d45
merge: 同步上游 main(a81bc49e)
dargoner Sep 12, 2026
471c1f5
merge: 同步上游 main(7399ed61)
dargoner Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2810,7 +2810,8 @@ private Mono<Msg> acting(int iter) {
MiddlewareBase::onActing,
actingCore)
.apply(new ActingInput(toolCalls));
return stream.doOnNext(
return stream.doOnNext(this::publishEvent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Moving .doOnNext(this::publishEvent) from the tail of actingStream(...) out to the middleware-wrapped stream changes who controls publication: tool/text events are now published only if a middleware forwards the stream. A middleware that short-circuits, retries or filters onActing used to still publish the core events and now will not (and conversely, events a middleware injects are published where they weren't before). That may well be the intent, but it touches the HITL paths this file keeps churning on (#3099/#3104), so could you confirm with a test that a stop/deny path still emits the permission-denied tool result events end-to-end?

.doOnNext(
ev -> {
if (ev instanceof RequestStopEvent rs) {
actingStopRequested.compareAndSet(null, rs);
Expand Down Expand Up @@ -2950,8 +2951,7 @@ Flux<AgentEvent> actingStream(
new RequestStopEvent(
"permission asking",
GenerateReason.PERMISSION_ASKING));
})
.doOnNext(this::publishEvent);
});
}

/**
Expand Down Expand Up @@ -3911,7 +3911,8 @@ private Mono<Msg> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@
*/
public class AgentEndEvent extends AgentEvent {

/**
* Metadata key describing whether a synthesized invocation end succeeded, failed, or cancelled.
*
* <p>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
*
* <p>Each event carries a unique ID, creation timestamp, and type discriminator.
* Events are emitted during agent execution and can be consumed via reactive streams.
*
* <p>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)
Expand All @@ -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"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Adding a subtype to this closed @JsonSubTypes list is a wire-format change, not just an in-process API addition. AgentEvent uses @JsonTypeInfo(Id.NAME) with no defaultImpl, so any consumer pinned to an older agentscope-core (or an event stream persisted by 2.0.3 and replayed on 2.0.2) fails deserialization with an unknown-type-id error rather than ignoring the new event. That contradicts the 'consumers that do not recognize TextOutputDispositionEvent can ignore it' compatibility claim, which only holds for code that switches on the event class. If events are persisted/replayed anywhere (session recovery, distribution, tracing exporters), please note the minimum consumer version in the docs/migration notes, and consider whether the disposition belongs in the persisted event log at all, or should stay a transient-only event.

@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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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}.
*
* <p>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.
*
* <p>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<AgentEvent> withTextOutputDisposition(Flux<AgentEvent> 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<SourceKey, AgentResultEvent> authoritativeResults = new LinkedHashMap<>();
private final Set<SourceKey> endedSources = new HashSet<>();

Flux<AgentEvent> apply(Flux<AgentEvent> 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();
}

private List<AgentEvent> process(AgentEvent event) {
SourceKey sourceKey = tracker.sourceKey(event);
if (endedSources.contains(sourceKey)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Failing the whole stream with IllegalStateException when an event arrives after AgentEndEvent is a risky default for an opt-in, purely additive signal. process() runs inside concatMap, so the throw is translated into onError for the subscriber: a consumer that previously saw the remaining events now sees the invocation abort, and the buffered top-level AgentEndEvent in pendingTopLevelEnds is never flushed. Since event ordering across subagent / concurrent-task producers is not guaranteed today (e.g. a forwarded child end can precede a trailing parent event), I'd make this lenient — log at debug/warn and pass the event through — or gate strictness behind an explicit failOnLateEvent flag that defaults to off. The test rejectsEventsAfterTopLevelEndWithoutLeakingHeldEvents currently locks in the fail-fast contract; worth confirming that contract is intentional for production consumers, not just for the unit test.

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<AgentEvent> 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<AgentEvent> 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<AgentEvent> 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<AgentEvent> 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<AgentEvent> onAgentEnd(AgentEndEvent event, Observation observation) {
SourceKey sourceKey = observation.sourceKey();

ReplySnapshot current = observation.after();
AgentResultEvent result = authoritativeResults.remove(sourceKey);
List<AgentEvent> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading