Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
43 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
7dbb1d7
fix(streaming): 收敛回复状态保留并保护 AG-UI 权威快照
dargoner Sep 13, 2026
0717456
fix(streaming): 保护顶层回复状态与用户角色快照
dargoner Sep 13, 2026
03dbdf9
fix(streaming): 加固子来源状态与 AG-UI 隔离
dargoner Sep 13, 2026
4114c6b
merge: 同步上游 main(39cd304a)
dargoner Sep 13, 2026
7a1c343
fix(agui): 修复活动运行释放竞态
dargoner Sep 13, 2026
a4bef39
test(harness): 隔离本地文件持久化示例的记忆钩子
dargoner Sep 13, 2026
492488d
docs(agui): 明确事件归属与运行释放契约
dargoner Sep 13, 2026
7041ec4
merge: 同步上游 main(7d321e2b)
dargoner Sep 14, 2026
d83765e
merge: 同步上游 main(70686e4a)
dargoner Sep 17, 2026
816027a
merge(sync): 同步官方 main 分支最新代码
dargoner Sep 23, 2026
85b6520
merge(sync): 同步官方 main 分支最新代码
dargoner Sep 25, 2026
f453a60
合并:回退今日污染提交后同步远程 main
dargoner Sep 27, 2026
07ae18d
fix(agui): 接入文本 disposition 并支持媒体消息往返
dargoner Sep 27, 2026
47e20b0
fix(agui): 修复工具结果状态和调用标识校验
dargoner Sep 28, 2026
300a09e
fix(stream): 修复机器人复审指出的兼容性问题
dargoner Sep 30, 2026
dd975e0
merge(sync): 同步远程 main 分支最新代码
dargoner Sep 30, 2026
3fbd635
fix(agui): 修复输入转换错误路径并兼容空工具标识
dargoner Sep 30, 2026
e079167
Merge 3fbd6355544957075032422c0e94e4c722db6526 into fbbc2b631691743ec…
dargoner Oct 8, 2026
7355aa9
merge(sync): 同步官方 main 到 PR3013 分支
dargoner Oct 11, 2026
e7ecf03
fix(agui): 恢复结构化文本输入校验
dargoner Oct 12, 2026
509f958
style(harness): 格式化 AgentSpawnTool
dargoner Oct 12, 2026
ce4e688
test(harness): 适配子代理工具结果断言
dargoner Oct 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 @@ -2748,7 +2748,8 @@ private Mono<Msg> acting(int iter) {
MiddlewareBase::onActing,
actingCore)
.apply(new ActingInput(toolCalls));
return stream.doOnNext(
return stream.doOnNext(this::publishEvent)
Comment thread
dargoner marked this conversation as resolved.
Comment thread
dargoner marked this conversation as resolved.

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.

[Info] #3145 / #3149 (both already on main) touch this same file, but in disjoint regions — assembleModelConfig, the fallback wrapper at ~4068, and the builder. I verified the merged state: your doOnNext(this::publishEvent) reorder survives intact and nothing upstream is silently reverted.

The one gap is coverage, not correctness: CI runs on the head branch, so this file's combined content is never compiled or tested green. Since these are core event-ordering changes, please sync main and let CI run once more on the merge before this lands.

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.

Merge-sync verification (not a new finding): this is the region where #3145/#3149 and the CAS work in #3236 landed upstream after the previous review head. Re-checked on 816027a0: the upstream state is intact — assembleModelConfig still threads b.flatFailoverListener into ModelConfig, the pending-tool-id helpers still resolve through MessageUtils.pendingToolUseIds(state.contextMutable()), persistAgentStateCas still uses the saveIfVersion(...) unconditional-write form, and the new usage accumulators (totalCacheCreation / totalReasoning / totalToolUsePrompt) are present. This PR's own doOnNext(this::publishEvent) reorder survived the sync unchanged, so nothing upstream is silently reverted here. Reopening the previous round's [Info] thread condition (sync main and let CI run on the merge) is satisfied: build (ubuntu-latest), build (windows-latest), Check License, Check Module Sync and codecov/patch are all green on this head.

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

/**
Expand Down Expand Up @@ -3809,7 +3809,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"),
Comment thread
dargoner marked this conversation as resolved.
@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() {
Comment thread
dargoner marked this conversation as resolved.
return endedSources.size() + authoritativeResults.size() + tracker.trackedSourceCount();
}

private List<AgentEvent> process(AgentEvent event) {
SourceKey sourceKey = tracker.sourceKey(event);
if (endedSources.contains(sourceKey)) {
Comment thread
dargoner marked this conversation as resolved.
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)) {

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.

This branch requires a subagent end to carry METADATA_INVOCATION_OUTCOME equal to success before the reply closes, which is the right conservatism for locally synthesized ends — but RemoteEventCodec line 174 decodes the wire's RUN_FINISHED into new AgentEndEvent(null) with no metadata, and withTypedFields gives it nothing either. So a remote child whose server predates this PR (or any path where the end arrives as a typed RUN_FINISHED rather than a JSON payload) produces a child reply that is never classified: no terminal disposition, silently, with no log line to explain the gap.

Over the wire RUN_FINISHED is the success signal, so the two halves of one protocol now disagree about what that end means. If the intent is "remote children classify only once both sides are new", say so here and in the AgentEndEvent#METADATA_INVOCATION_OUTCOME javadoc, which currently tells producers only about the harness spawn tool. Otherwise tag that arm OUTCOME_SUCCESS — and its error/cancel counterparts with their own outcomes — so local and remote children behave the same way from the wrapper's point of view.

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.

Unchanged by the delta: a child terminal still requires METADATA_INVOCATION_OUTCOME == success (isSuccessfulChildEnd, :219-222), while RemoteEventCodec.java:183 decodes the wire's RUN_FINISHED into new AgentEndEvent(null) with no metadata — so a remote child whose server predates this PR (or any path where the end arrives as a typed RUN_FINISHED rather than a JSON payload) produces a child reply that is never terminally classified, silently, with no log line. Over the wire RUN_FINISHED is the success signal, so the two halves of one protocol still disagree. Either tag that arm OUTCOME_SUCCESS (and its error/cancel counterparts with their own outcomes) so local and remote children behave the same, or document the "remote children classify only once both sides are new" intent here and in the AgentEndEvent#METADATA_INVOCATION_OUTCOME javadoc.

// 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