Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0db3ed7
feat(core): 新增文本输出处置事件
dargoner Sep 3, 2026
72e56f5
refactor(core): 统一回复生命周期跟踪规则
dargoner Sep 3, 2026
d09f6b5
fix(core): 修正任务事件的顶层来源判定
dargoner Sep 3, 2026
3ba05d8
feat(core): 增加流式文本处置标注器
dargoner Sep 3, 2026
c788fe9
test(core): 补充流标注器边界覆盖
dargoner Sep 3, 2026
0fb5206
test(core): 强化顶层结束取消屏障
dargoner Sep 3, 2026
6e3b5a1
fix(harness): 为子代理流事件附加任务标识
dargoner Sep 3, 2026
61f58ae
feat(protocol): 透传文本处置和权威结果事件
dargoner Sep 3, 2026
1c8149e
feat(agui): 支持实时文本处置与结果快照
dargoner Sep 3, 2026
3ab4abe
fix(agui): 校准最终消息快照边界与多模态内容
dargoner Sep 3, 2026
75d07d6
feat(web): 增加文本处置预览与结果校准
dargoner Sep 3, 2026
e1083be
fix(web): 隔离预览生命周期并处理空权威结果
dargoner Sep 3, 2026
b5e3e86
feat(frontend): 区分过程文本与权威最终结果
dargoner Sep 3, 2026
6da2b33
fix(frontend): 保留流式事件顺序并续接恢复会话
dargoner Sep 3, 2026
50ed765
docs(streaming): 说明文本处置与结果校准用法
dargoner Sep 3, 2026
22a30bf
docs(streaming): 修正事件序列与兼容性证据
dargoner Sep 3, 2026
80c3595
fix(streaming): 修正文本处置的调用结束关联
dargoner Sep 4, 2026
6af8e3b
fix(streaming): 补齐子智能体结构化事件序列
dargoner Sep 6, 2026
1c6f4a5
chore(pr): 移除内部实施报告
dargoner Sep 6, 2026
76413d6
fix(streaming): 修复权威结果与预览并发边界
dargoner Sep 6, 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 @@ -2744,7 +2744,8 @@ private Mono<Msg> 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);
Expand Down Expand Up @@ -2884,8 +2885,7 @@ Flux<AgentEvent> actingStream(
new RequestStopEvent(
"permission asking",
GenerateReason.PERMISSION_ASKING));
})
.doOnNext(this::publishEvent);
});
}

/**
Expand Down Expand Up @@ -3841,7 +3841,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,13 @@
*/
public class AgentEndEvent extends AgentEvent {

/** Metadata key describing whether a synthesized invocation end succeeded, failed, or cancelled. */
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 @@ -37,6 +37,7 @@
@JsonSubTypes.Type(value = AgentStartEvent.class, name = "AGENT_START"),
@JsonSubTypes.Type(value = AgentEndEvent.class, name = "AGENT_END"),
@JsonSubTypes.Type(value = AgentResultEvent.class, name = "AGENT_RESULT"),
@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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* 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 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}.
*
* @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 class DispositionAnnotator {

private final ReplyLifecycleTracker tracker = new ReplyLifecycleTracker();
private final Map<SourceKey, AgentEndEvent> pendingTopLevelEnds = new LinkedHashMap<>();
private final Set<SourceKey> endedSources = new HashSet<>();

private Flux<AgentEvent> apply(Flux<AgentEvent> source) {
Flux<AgentEvent> processed =
source.concatMap(event -> Flux.fromIterable(process(event)), 1);
return processed.concatWith(Flux.defer(() -> Flux.fromIterable(complete())));
}

private List<AgentEvent> process(AgentEvent event) {
SourceKey sourceKey = tracker.sourceKey(event);
if (endedSources.contains(sourceKey)) {
throw new IllegalStateException(
"Received event after AgentEndEvent for source " + sourceKey);
}

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_END -> onAgentEnd((AgentEndEvent) event, observation);
default -> 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();
endedSources.add(sourceKey);
if (sourceKey.isTopLevel()) {
pendingTopLevelEnds.put(sourceKey, event);
return List.of();
}

ReplySnapshot current = observation.after();
List<AgentEvent> output = new ArrayList<>(2);
if (isNormallyCompleted(event) && hasUnclassifiedText(current)) {
output.add(
disposition(
current.replyId(), TextOutputDisposition.TERMINAL, null, event));
tracker.markDispositionEmitted(sourceKey);
}
output.add(event);
return output;
}

private List<AgentEvent> complete() {
List<AgentEvent> output = new ArrayList<>(pendingTopLevelEnds.size() * 2);
for (Map.Entry<SourceKey, AgentEndEvent> entry : pendingTopLevelEnds.entrySet()) {
SourceKey sourceKey = entry.getKey();
AgentEndEvent end = entry.getValue();
ReplySnapshot current = tracker.snapshot(sourceKey);
AgentResultEvent result = current.lastResult();
if (isNormallyCompleted(end)
&& hasUnclassifiedText(current)
&& result != null
&& result.getResult() != null) {
GenerateReason reason = result.getResult().getGenerateReason();
output.add(
disposition(
current.replyId(),
TextOutputDisposition.TERMINAL,
reason,
end));
tracker.markDispositionEmitted(sourceKey);
}
output.add(end);
tracker.clearSource(sourceKey);
}
pendingTopLevelEnds.clear();
return output;
}

private static boolean isNormallyCompleted(AgentEndEvent end) {
Object outcome =
end.getMetadata() == null
? null
: end.getMetadata().get(AgentEndEvent.METADATA_INVOCATION_OUTCOME);
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()).withMetadata(trigger.getMetadata());
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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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.
*
* <p>A terminal disposition is a lifecycle signal, not an authoritative final answer. Consumers
* must use {@link AgentResultEvent} for the invocation result.
*/
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;
}
}
Loading
Loading