Skip to content

feat(streaming): add semantic text output disposition events - #3013

Open
dargoner wants to merge 20 commits into
agentscope-ai:mainfrom
dargoner:codex/pr-stream-events-text-disposition
Open

feat(streaming): add semantic text output disposition events#3013
dargoner wants to merge 20 commits into
agentscope-ai:mainfrom
dargoner:codex/pr-stream-events-text-disposition

Conversation

@dargoner

@dargoner dargoner commented Sep 6, 2026

Copy link
Copy Markdown

AgentScope-Java Version

2.0.3-SNAPSHOT

Description

Background

TextDeltaEvent preserves provider-level token streaming, but it does not tell a consumer whether a text segment belongs to an intermediate model turn that will invoke tools or to the last user-visible reply. Today, adapters have to infer that intent from neighboring tool and lifecycle events. That inference is easy to implement differently across AG-UI, Agent Protocol, Web UI, and custom consumers, especially when subagents or concurrent tasks are involved.

Waiting for AgentResultEvent and then emitting the whole answer is simpler, but it removes the real-time preview that streaming consumers expect.

This PR introduces an opt-in semantic layer that keeps the existing token stream intact while classifying each completed text lifecycle:

AgentEventStreams.withTextOutputDisposition(agent.streamEvents(...))

The wrapper emits TextOutputDispositionEvent with one of two dispositions:

  • INTERMEDIATE: the referenced text belongs to a non-terminal model turn and can be presented as progress or commentary.
  • TERMINAL: the last user-visible reply candidate has completed its streaming lifecycle.

The disposition event classifies text that has already been streamed; it does not replace or delay the original TextDeltaEvent. TERMINAL is also not the authoritative answer. AgentResultEvent remains the authoritative invocation result and can reconcile the preview with the final message, structured output, or an empty result.

For a normally completed invocation, the closing order is:

AgentResultEvent
-> TextOutputDispositionEvent(TERMINAL)
-> AgentEndEvent

This ordering lets consumers keep token-level preview latency while committing only the authoritative result.

Correlation and isolation

  • replyId associates a disposition with the text lifecycle it classifies.
  • source and metadata.taskId isolate top-level, subagent, and concurrent invocations, including concurrent calls from the same source.
  • Each subscription owns independent tracking state.
  • Cancellation and error paths do not synthesize a terminal classification for an invocation that did not complete.

Compatibility

  • ReActAgent#streamEvents() keeps its existing default event sequence.
  • The semantic wrapper and adapter integrations are disabled by default.
  • Existing consumers continue receiving the original delta, result, lifecycle, and raw provider events.
  • Consumers that do not recognize TextOutputDispositionEvent can ignore it without changing their current behavior.

Adapter behavior

AG-UI can opt in with:

AguiAdapterConfig.builder()
        .textOutputDispositionEnabled(true)
        .build();

When enabled, intermediate text is exposed as a semantic custom event rather than being presented as model reasoning. On completion, the authoritative AgentResultEvent produces a standard MESSAGES_SNAPSHOT to reconcile provisional text. When disabled, the legacy AG-UI sequence and message IDs are preserved.

The managed Web flow treats text deltas as non-persistent preview updates. INTERMEDIATE can downgrade a preview to commentary, TERMINAL closes the preview lifecycle, and AgentEndEvent commits the message derived from the buffered authoritative result. Empty authoritative results can therefore remove stale preview text, and late subscribers do not receive obsolete preview frames.

Local and remote subagents preserve the same semantics. Remote transport continues using AGENT_EVENT with its JSON string payload, while local subagent forwarding now includes the authoritative AgentResultEvent before AgentEndEvent.

Why this shape

The framework is the only layer that reliably knows the model-turn and invocation lifecycle. Publishing a small semantic envelope there avoids duplicating heuristics in every protocol adapter, while keeping provider events available for observability and leaving presentation policy to consumers. It also allows consumers to choose either a full-trace projection or a final-answer projection without forcing buffering into the core token stream.

Verification

  • Targeted reactor tests covering core disposition semantics, subagent forwarding, AG-UI conversion, and managed Web preview reconciliation: 233 tests, 0 failures.
  • Full reactor compile/package/Javadoc/frontend verification with tests skipped: 89/89 modules successful.
  • mvn spotless:check and git diff --check are clean.

A complete Windows test run is not marked as passing because the repository contains symlink tests that require Windows symlink privileges and an existing order-sensitive baseline test. These are unrelated to this change; the affected streaming suites pass independently.

Related to #2872.
Related to #2975.

Checklist

  • Code has been formatted with mvn spotless:check
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

@CLAassistant

CLAassistant commented Sep 6, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@oss-maintainer

Copy link
Copy Markdown
Collaborator

CLA Not Signed

The Contributor License Agreement (CLA) check is currently pending on this PR (license/cla: Contributor License Agreement is not signed yet.). This PR cannot be merged until the CLA is signed.

@dargoner please sign the CLA via the CLA assistant badge in the comment above, or visit https://cla-assistant.io/agentscope-ai/agentscope-java. Once signed, the license/cla status will turn green.


Automated check by github-manager-bot

@oss-maintainer oss-maintainer left a comment

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.

Summary

The TextOutputDispositionEvent layer is a well-motivated addition: keeping TextDeltaEvent intact and adding an opt-in INTERMEDIATE/TERMINAL classification (with per-source + taskId isolation and per-subscription state) is the right shape, and the Jackson subtype registration plus the dedicated tests are appreciated. The blocking concerns are the lifecycle of the buffered top-level AgentEndEvent, the fail-open success inference, and the unbounded tracker state.

Verdict: comment-only for this round — two critical correctness items (inline) plus a scope request. Not approving.

Findings

  • [Critical] AgentEventStreams.java:66IllegalStateException on a late event aborts the wrapped stream via concatMap, turning an ordering anomaly into a user-visible failure of an opt-in, purely additive signal.
  • [Critical] AgentEventStreams.java:127 — buffering the top-level AgentEndEvent until complete() (line 144) means the end event is dropped when the source errors and never arrives if the stream does not terminate; emitting [terminal, end] inline (as the child branch already does) removes both hazards.
  • [Warning] AgentEventStreams.java:176outcome == null counts as success, so any end without METADATA_INVOCATION_OUTCOME (custom/forwarded subagents, replayed state) can yield a TERMINAL.
  • [Warning] ReplyLifecycleTracker.java:140computeIfAbsent plus no eviction on child end grows states without bound, and each entry pins lastResult.
  • [Warning] AgentEvent.java:40 — a new @JsonSubTypes entry is a wire change; consumers on an older core fail on unknown type id, which conflicts with the "ignore it safely" compatibility claim.
  • [Info] FinalAnswerFilterMiddleware.java:73 — shared tracker now records unused lastResult state and widens reply-id recognition; needs its own guard-rail test.

Scope — please split this PR

This branch carries 187 commits, 249 files and ~34.4k additions, and includes substantial work unrelated to text-output disposition: skill/evolution/* (new SkillArtifactMaterializer, CanonicalSkillHasher), OtelTracingMiddleware changes, AguiResumeCoordinator rework, ReActAgent per-session-state and structured-output tests, sandbox/McpJsonDefaults fixes, and more. A reviewer cannot meaningfully approve a feature hidden inside that volume, and any regression cannot be attributed or reverted cleanly. Could you rebase this onto current main and keep only the disposition events, wrapper, tracker, the AG-UI/agent-protocol wiring, and their tests? The remaining changes belong in their own PRs.

mergeable_state is currently blocked, and the license/cla status is pending — the CLA reminder is posted separately. Once the CLA is signed and the scope is reduced, I am happy to re-review the trimmed diff.


Automated review by github-manager-bot


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.

private List<AgentEvent> onAgentEnd(AgentEndEvent event, Observation observation) {
SourceKey sourceKey = observation.sourceKey();
endedSources.add(sourceKey);
if (sourceKey.isTopLevel()) {

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.

Why buffer the top-level AgentEndEvent here instead of emitting [terminal, end] inline like the child path below? The documented close order (AgentResultEvent -> TextOutputDispositionEvent(TERMINAL) -> AgentEndEvent) is already satisfied at this point, because AgentResultEvent is observed before AgentEndEvent and current.lastResult() is available in the snapshot. Deferring to complete() (line 144) adds two avoidable failure modes:

  1. If the source errors before terminating, concatWith never runs, so the invocation's AgentEndEvent is dropped entirely (doNotLeakPendingTopLevelEndOrTerminalOnError treats this as intended). Parent-side lifecycle logic that waits on AgentEndEvent — e.g. the harness AgentSpawnTool / AG-UI SubagentEventConverter paths that also set METADATA_INVOCATION_OUTCOME — would hang or leak.
  2. For a non-terminating stream (long-lived AG-UI session where a child-forwarded end matches the empty SourceKey.topLevel() key), the deferred end may never be emitted at all.

Emitting inline removes pendingTopLevelEnds/endedSources state and the ordering hazard, and makes the top-level and child branches symmetric.

end.getMetadata() == null
? null
: end.getMetadata().get(AgentEndEvent.METADATA_INVOCATION_OUTCOME);
return outcome == null || AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString());

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.

outcome == null || OUTCOME_SUCCESS.equals(outcome) is fail-open: any AgentEndEvent without outcome metadata is classified as a successful completion and can therefore produce a TERMINAL disposition. Today only AgentSpawnTool (and SubagentEventConverter) sets the key, so a child end from a custom/forwarded agent — the EventStreamingAgent-style adapters in #3098 — or a replayed/persisted stream that lost metadata will look like a normal completion. The PR description promises that cancellation and error paths never synthesize a terminal classification; the null branch is where that breaks. Consider requiring positive evidence instead: OUTCOME_SUCCESS.equals(outcome) for the child path, or additionally current.lastResult() != null (as the top-level path at line 153 already does), so the two branches share one guarantee.

}

public void markDispositionEmitted(SourceKey sourceKey) {
states.computeIfAbsent(sourceKey, ignored -> new ReplyState()).dispositionEmitted = true;

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.

markDispositionEmitted uses computeIfAbsent, so it can re-create a ReplyState that clearSource already removed, and neither the annotator nor this class ever evicts entries for child sources — AgentEventStreams only calls clearSource for buffered top-level ends. states is a LinkedHashMap that therefore grows with the number of distinct source + taskId pairs seen by one subscription. For a long-running session that spawns many subagents/tasks on the same wrapped stream that is an unbounded retention path (each entry also pins lastResult, i.e. the full result message). Suggest: clear the source when its AgentEndEvent is handled, use states.get(...) (no auto-create) in markDispositionEmitted, and add a regression test that asserts the map is empty once all sources have ended.

@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"),

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.

if (event instanceof ModelCallStartEvent start) {
replyId = start.getReplyId();
toolCallSeen = false;
Observation observation = tracker.observe(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.

The refactor to share ReplyLifecycleTracker looks behaviour-preserving for replyId / toolCallSeen, but two notes: (1) the shared tracker now also records lastResult on every AGENT_RESULT, which FinalAnswerFilterMiddleware never reads — dead state pinned per source key, and this middleware's RoundState has no clearSource call at all, so it inherits the retention issue above; (2) the old isCurrentReply() only understood four event classes, while ReplyLifecycleTracker.replyId() also unwraps TextBlockStartEvent and AgentEndEvent. That widens which events are considered 'current reply' for the buffering decision. Both are fine if the extra classification is intended, but please add a middleware-level test asserting the buffer still drops intermediate text when a reply ends without a tool call, so the shared-state refactor has its own guard rail.


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

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.

withTextOutputDisposition is a new public API in agentscope-core, and changes to core interfaces cascade to harness, distribution and every extension. Two small asks before this settles: (1) ReplyLifecycleTracker is public but documented as 'not a stable public API' while living in io.agentscope.core.internal.stream — if it is meant to stay internal, keep the constructor package-private or expose only the annotator, otherwise the disclaimered-public rule is easy to break by accident; (2) please add a short Javadoc note on AgentEndEvent.METADATA_INVOCATION_OUTCOME about who owns writing it, since isNormallyCompleted depends on that contract and today only the harness spawn path fills it in.

按来源和任务关闭最后模型回复,并区分子代理成功、失败与取消结束。

补齐协议、AG-UI、前端和数据面的回归验证与最终修复报告。

(cherry picked from commit 80c3595)
透传子智能体生命周期、思考、文本处置、工具参数结果及任务标识。
修正 acting 中间件事件发布位置,避免事件遗漏或重复。

(cherry picked from commit 6af8e3b)
补齐本地子智能体的 AgentResultEvent 转发,避免只收到结束事件而缺少权威结果。
AG-UI 仅按当前运行实际生成的分段消息清理临时文本,避免误删合法历史消息。
Web 预览总线不再重放订阅前事件,并串行化并发发送以避免丢帧。

(cherry picked from commit 76413d6)
上游 agentscope-ai#3010 之后文本消息 ID 为 replyId-blockId(如 reply-1-text-1),本分支的 AguiStreamContext 仍把传入值当 replyId 再拼 :text:N,导致处置事件 messageIds 与流式消息 ID 不一致。

改为直接复用官方 messageId,并从段 ID 反推所属 replyId 维持处置分组;保留按本轮实际生成段判定快照排除的语义,同步更新测试期望与 README 说明。

(cherry picked from commit 42ad838)
@dargoner
dargoner force-pushed the codex/pr-stream-events-text-disposition branch from 42ad838 to 39ecc5f Compare September 12, 2026 05:01
维护者评审提出的两个 critical:

1. 迟到事件原先直接抛 IllegalStateException,经 concatMap 会转成 onError,
   把一个可选的附加信号升级成用户可见的流失败。现改为记录告警后原样透传;
   同一来源再次出现 MODEL_CALL_START 时视为新一次调用并恢复标注。
2. 顶层 AgentEndEvent 原先缓存到流结束后的 complete() 才发出,源出错时 end
   事件丢失,流不终止时永不发出。现改为内联发出 [terminal, end],并删除
   pendingTopLevelEnds 与 complete()。

同时处理评审中的其余意见:

- 子代理结束必须显式声明 OUTCOME_SUCCESS 才产生 TERMINAL,未知或异常 outcome
  不再 fail-open。
- 每个来源在 AgentEndEvent 时清理 tracker 状态,并把结果关联移出 tracker,
  避免 states 无界增长并 pin 住 AgentResultEvent。
- markDispositionEmitted 不再重建已清理的来源状态。
- 补充 AgentEndEvent 的 outcome 契约与新增事件的线格式兼容说明。

新增回归测试:结束先于源错误/源完成发出、迟到事件透传、子代理异常结束不产生
TERMINAL、tracker 状态清理、中间轮文本不泄漏进最终答案。
@dargoner

Copy link
Copy Markdown
Author

Thanks for the detailed review — all findings are addressed, and the scope split is done.

Scope: the branch is rebased onto current main and now carries 18 commits / 40 files / +3225 −241, containing only text-output disposition work: the event, the wrapper, the tracker, the AG-UI and agent-protocol wiring, and their tests. skill/evolution/*, OtelTracingMiddleware, AguiResumeCoordinator, the sandbox fixes and the unrelated ReActAgent test changes are gone from this PR. license/cla is green and every commit is authored by one signed identity.

Critical 1 — late events (AgentEventStreams.java:66/78): no longer throws. A late event is logged at WARN and forwarded unchanged, so an ordering anomaly can never fail the wrapped stream. If a MODEL_CALL_START arrives for an already-ended source it is treated as a new invocation and classification resumes. rejectsEventsAfterTopLevelEndWithoutLeakingHeldEvents was replaced by tests that lock in the pass-through contract (forwardsLateEventsWithoutFailingOrClassifyingThem, reopensSourceWhenANewInvocationStartsAfterEnd).

Critical 2 — buffered top-level end (AgentEventStreams.java:127/144): the top-level branch now emits [terminal, end] inline, exactly like the child branch. pendingTopLevelEnds and complete() are deleted. The documented order (AgentResultEvent -> TERMINAL -> AgentEndEvent) still holds because AgentResultEvent is observed before the end event. Both hazards are gone: the end is no longer dropped when the source errors first, and it no longer depends on the stream terminating. Regression tests: emitsTopLevelTerminalAndEndBeforeSourceCompletes, emitsTopLevelTerminalAndEndBeforeSourceErrorPropagates.

Warning — fail-open success (AgentEventStreams.java:176): the child path now requires positive evidence, OUTCOME_SUCCESS.equals(outcome); null and abnormal outcomes no longer classify as completion. The top-level path uses the same rule — a terminal is only derived for a non-abnormal end with a non-null authoritative result — so both branches share one guarantee. Tests: childEndWithoutExplicitSuccessOutcomeProducesNoTerminal, abnormalChildEndProducesNoTerminal.

Warning — unbounded tracker state (ReplyLifecycleTracker.java:140): states is now cleared per source on its AgentEndEvent; markDispositionEmitted uses states.get(...) and no longer re-creates a cleared entry; and lastResult was removed from the tracker entirely — the annotator correlates the authoritative result and drops it on end, so no AgentResultEvent is pinned. trackedSourceCount() is package-private for the regression tests clearSourceRemovesTrackedState and markDispositionEmittedDoesNotRecreateClearedSource.

Warning — wire format (AgentEvent.java:40): agreed, the compatibility claim was too broad. It is now documented on both AgentEvent and TextOutputDispositionEvent: the type id is additive, an older revision cannot resolve it and fails to deserialize, so these derived events must not be persisted or replayed into a log that older readers consume. They are produced only by the opt-in AgentEventStreams.withTextOutputDisposition wrapper, i.e. transient by construction.

Info — middleware guard rail (FinalAnswerFilterMiddleware.java:73): added textFromAnIntermediateRoundIsNotLeakedIntoTheFinalRound, asserting that text from a round which produced a tool call never reaches the final answer. On the widened reply-id recognition: TextBlockStartEvent was already resolved before the refactor, and ToolCallStartEvent / AgentEndEvent are forwarded verbatim by this middleware regardless of the buffering decision, so the widening is behaviour-neutral here — sharing one set of correlation rules is the point of the refactor.

Info — tracker visibility and outcome contract: ReplyLifecycleTracker is public only because io.agentscope.core.middleware needs it across packages; its Javadoc now states explicitly that it is not part of the supported API surface and may change without notice, and that it tracks reply lifecycle only (result correlation is the caller's job). AgentEndEvent.METADATA_INVOCATION_OUTCOME now documents who owns writing it (the producer that synthesizes a forwarded invocation end, e.g. the harness spawn path) and why a subagent end without it is not treated as a completion.

Verification: agentscope-core full suite 2396 tests / 0 failures (the 2 errors are the pre-existing Windows symlink-privilege ones), plus targeted suites in harness, agui and agent-protocol. CI is green on both platforms (build (ubuntu-latest), build (windows-latest), Check License, Check Module Sync, codecov/patch, license/cla) and mergeable_state is clean.

@oss-maintainer oss-maintainer left a comment

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.

Summary

Re-review of the trimmed branch (9b76667c..ffa69712). The scope reduction I asked for landed — 249 files / ~34.4k additions is now 40 files / +3225, the unrelated control-plane and skill-evolution work is gone, and both blocking items from the last round are genuinely fixed: late events after AgentEndEvent are now forwarded with a warning instead of failing the wrapped stream, and the top-level end event is emitted inline instead of being buffered until complete(). Opt-in AguiAdapterConfig.textOutputDispositionEnabled plus the README that spells out "terminal does not mean final answer" is the right shape. CI is green (build on ubuntu/windows, Check License, Check Module Sync) and license/cla is now passing.

Verdict: comment-only again — no blocking item left, but three correctness risks I would like resolved before this merges (inline).

Findings

  • [Warning] AgentEventStreams.java:155endedSources is still append-only for child sources, so the unbounded-state problem is only half fixed.
  • [Warning] AguiStreamContext.java:468 — replyId is recovered by regex over the AG-UI message id; mis-parsing silently attaches dispositions to the wrong reply.
  • [Warning] AguiStreamContext.java:274MESSAGES_SNAPSHOT can drop the submitted user turn when AgentState.getContext() does not echo it, and consumers are told to replace streamed text with that snapshot.
  • [Warning] ReActAgent.java:2787 — publication moved from actingStream(...) to the middleware-wrapped stream; a middleware that short-circuits onActing no longer publishes core tool events.
  • [Info] AguiStreamContext.java:236getTextMessageIds returns different key spaces depending on the config flag.
  • [Info] AgentEventStreams.java:219 — the derived disposition inherits the trigger metadata map, including METADATA_INVOCATION_OUTCOME.

What I verified this round

  • forwardsLateEventsWithoutFailingOrClassifyingThem exists in AgentEventStreamsTest, so the fail-open behaviour has a regression test.
  • tracker.clearSource(sourceKey) on child end and the explicit OUTCOME_SUCCESS requirement for child terminals both close earlier warnings.
  • SubagentEventConverter now stamps taskId on forwarded events and carries replyId/blockId through, which keeps multi-task subagent streams separable downstream.

Nice work on the rework — the diff is now reviewable, and the remaining items are narrow.


Automated review by github-manager-bot


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

tracker.clearSource(sourceKey) closes the unbounded states map I flagged last round, but endedSources still only grows inside a subscription: a child key (source + taskId) is removed only when another ModelCallStartEvent with the same key arrives, and AgentSpawnTool mints a fresh task_<uuid> per spawn. For a long-lived stream that fans out many subagent invocations the set, and the authoritativeResults entries of children that never end, keep accumulating until the subscription terminates. Dropping the child key once its end event is forwarded (the new-model-call branch already handles re-opening) would make the reclaim consistent with clearSource.

AgentEvent trigger) {
TextOutputDispositionEvent event =
new TextOutputDispositionEvent(replyId, disposition, generateReason);
event.withSource(trigger.getSource()).withMetadata(trigger.getMetadata());

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.

Minor: withMetadata(trigger.getMetadata()) copies the trigger's whole metadata map onto the derived event, so a disposition emitted from an AgentEndEvent also carries METADATA_INVOCATION_OUTCOME. Harmless today, but a consumer that reads outcome off the disposition would then disagree with the authoritative result. Consider copying only source/taskId (the correlation keys) rather than the full map.

* Recovers the owning reply id from a live text-segment message id. Ids that are not segment ids
* (for example a single-block reply id) are their own reply id.
*/
private static String replyIdOf(String messageId) {

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.

Recovering the reply id by regex over the AG-UI message id couples correctness to a naming convention owned elsewhere: any id that legitimately ends in -text, -thinking, -reasoning (or -reasoning-2) is silently mapped to a different replyId, and ids produced outside the text/thinking converters fall through to "messageId == replyId", creating a bogus reply bucket in textMessageIdsByReply. Dispositions would then be attached to the wrong reply. Since the callers that build these ids already know the replyId, could it be passed explicitly (e.g. startTextMessage(messageId, replyId)) and keep the regex only as a compatibility fallback?

for (AguiMessage message : runInput.getMessages()) {
if (message != null
&& !isGeneratedTextSegmentId(message.getId())
&& (!hasAuthoritativeMessages

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.

When the agent state yields any messages, run-input messages are kept only if their id is already present in messagesById. That makes the snapshot depend on AgentState.getContext() echoing the just-submitted user turn — if it does not (filtered memory, trimmed context, or a state store that persists only assistant turns), the user's message silently disappears from MESSAGES_SNAPSHOT, and since the README tells consumers to reconcile by replacing streamed text with the snapshot, the turn is lost in the UI. Safer default: keep run-input messages unless the authoritative list supplies a replacement for the same id. Worth a test for the "input message absent from agent state" case either way.

emit(new AguiEvent.TextMessageEnd(threadId, runId, messageId));
}

public List<String> getTextMessageIds(String replyId) {

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.

The disabled branch treats replyId as a message id (startedTextMessages.contains(replyId) ? List.of(replyId) : ...), so callers of getTextMessageIds get either the reply id back or nothing depending on a config flag. That asymmetry is easy to misuse in the converters; returning the segment ids that were started for that reply in both modes (or documenting the contract in the javadoc) would be clearer.

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?

维护者第二轮复审提出的四项风险:

1. 子来源墓碑无界:endedSources 里的子来源键按次生成(task_<uuid>),只增不减
   等价于让一个订阅保留它见过的所有子调用。现在子来源在结束事件发出后立即回收,
   顶层键保留到下一次模型调用重新打开,与 clearSource 的生命周期保持一致。
2. 派生处置事件继承了触发的整体 metadata:从 AgentEndEvent 派生的 TERMINAL 会带上
   METADATA_INVOCATION_OUTCOME,消费者据此判断会与权威结果矛盾。现在只复制 taskId
   这一关联键。
3. AG-UI 的 replyId 靠消息 id 正则反推:任何以 -text/-thinking/-reasoning 结尾的合法
   id 都会被归到别的 replyId,非文本转换器产生的 id 还会落到 messageId==replyId 的
   伪桶。现在文本转换器显式传入 replyId,正则只作兼容回退。
4. MESSAGES_SNAPSHOT 会丢掉本轮用户消息:仅当 agent 状态回显该消息时才保留 run
   输入,而消费者按快照替换流式文本,状态未回显时该轮次在 UI 上消失。现在 run 输入
   始终保留。

同时 getTextMessageIds 不再随 textOutputDispositionEnabled 返回不同键空间,
并删除只写不读的 activeTextMessageIdsByReply 与 currentTextReplyId。

新增回归测试:子来源回收与顶层墓碑释放、处置事件只带关联键、中间轮文本不泄漏、
拒绝工具的 DENIED 结果在 acting 中间件下仍发布、询问工具的停止路径仍发布、
agent 状态不回显用户轮次时快照仍保留该轮次。
@dargoner
dargoner force-pushed the codex/pr-stream-events-text-disposition branch from e367ee6 to c9503ab Compare September 12, 2026 08:32
@dargoner

Copy link
Copy Markdown
Author

All six items are addressed and pushed as c9503ab1; CI is green on both platforms.

Warning — endedSources still append-only for child sources (AgentEventStreams.java:155): child tombstones are now dropped as soon as their end event is forwarded, so the set is bounded to the top-level key that the next MODEL_CALL_START reopens. Fixing that surfaced the sibling retention you mentioned in the same sentence: authoritativeResults recorded a result for every source, but only the top-level terminal reads it (the subagent branch derives its terminal from the end event), so a subagent that reports a result and never ends pinned a full AgentResultEvent per invocation. Only the top-level result is recorded now. retainedSourceCount() is package-private and two tests assert both paths (reclaimsEndedChildSources, releasesTopLevelBookkeepingWhenTheNextInvocationStarts).

Warning — replyId recovered by regex (AguiStreamContext.java:468): done as suggested. The converters now pass it explicitly — startTextMessage(messageId, replyId) and appendTextDelta(messageId, replyId, delta), used by TextBlockEventConverter. The regex survives only as the documented compatibility fallback for the single-argument overloads.

Warning — MESSAGES_SNAPSHOT can drop the submitted user turn (AguiStreamContext.java:274): run-input messages are now always kept; the snapshot no longer depends on AgentState.getContext() echoing the turn. Added testFinalSnapshotKeepsSubmittedTurnWhenAgentStateDoesNotEchoIt (state holds only an assistant turn, the user turn must still appear), and updated the two existing assertions that encoded the old behaviour. Precedence is unchanged on a collision: a run-input message still replaces the state's rendering of the same id, so the client-provided shape wins.

Warning — publication moved off actingStream(...) (ReActAgent.java:2787): confirmed with tests that the stop and deny paths still publish end-to-end through streamEvents with a middleware registered. deniedToolResultEventsStayPublishedThroughActingMiddleware drives an auto-DENY rule and asserts exactly one ToolResultStartEvent / ToolResultEndEvent pair with DENIED in the published stream, and that the middleware observed the same events; askingToolStopPathStaysPublishedThroughActingMiddleware asserts RequireUserConfirmEvent + RequestStopEvent.

On intent: the move is what makes middleware-injected events reach consumers — HintBlockEvent appended by onActing was silently dropped before, and the existing actingMiddlewareEventsAreForwardedExactlyOnce locks that in. It does have the consequence you flagged: an onActing middleware that filters or swallows core events now also suppresses them from the published stream. I kept it that way because "publish exactly what the chain emits, once" is the only rule that can hold in both directions; if you would rather preserve the old core-publishes-regardless behaviour for filtering middlewares, say so and I will rework it.

Info — getTextMessageIds key spaces (AguiStreamContext.java:236): ids are now recorded for every reply regardless of textOutputDispositionEnabled, so the getter returns the started segment ids in both modes and the flag no longer changes the answer. The javadoc states the contract.

Info — disposition inherits the trigger metadata (AgentEventStreams.java:219): fixed as suggested — only taskId, the correlation key, is copied onto the derived event. Test: childDispositionCarriesCorrelationKeysOnly.

While refactoring I also removed two pieces of write-only state from this branch: activeTextMessageIdsByReply and currentTextReplyId were never read.

Local verification: agentscope-core full suite 2371 tests / 0 failures (the 2 errors are the pre-existing Windows symlink-privilege ones), plus targeted suites in agui (140), agent-protocol and harness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants