fix(subagent): complete output selection contract
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: d90047c07a300a1afbc42c7db1a4fefa25d56764
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: 0a5ce02dccef422dc75bc980d104f41f116427f2
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179
|
||||
|
||||
@@ -6,24 +6,26 @@ English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The agent loop appends an EMPTY-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks (`BlockAssembler.blocks()` drops truncated tool calls): the message exists solely to host usage. Three consumers each selected "the child's answer" with their own rule and all treated that usage host as the answer. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture took the LAST `assistant/message` unfiltered, and the SDK backend's observer let any `assistant/message` beat its streamed-text fallback. In a multi-step turn cut off at max-tokens, the final empty message therefore erased the real partial answer: `SubagentResult.output` came back `[]`, and the tool result, telemetry, and `subagent/end.lastAssistantMessage` all saw nothing. The in-process driver additionally had no streamed-text fallback at all, so a cancelled child whose only text lived in `assistant/chunk` events also reported `[]`.
|
||||
The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message records usage only. Three consumers selected the child's output independently and treated that usage record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in `assistant/chunk` events reported `[]`.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. The rule has one implementation, the incremental `AssistantOutputFold` (`push(event)` for session-event transports, `pushText(text)` for chunk-only transports, `collect()` to select), and `finalAssistantOutput(events)` applies it to a complete event suffix (the in-process `readResult` and the Activation capture). The SDK backend folds notification events; the ACP backend, which surfaces no complete assistant messages, folds raw chunk text into the same streamed fallback. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` selects by the same rule, and "no output" has one encoding on that edge — the field is absent, never an empty array, on both the one-shot and continuable lifecycle shapes. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed.
|
||||
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
|
||||
|
||||
The foreground delegation tool observes the same selection: a non-`completed` result stays an `isError` tool result, but its message appends the child's preserved partial text after the stop-reason headline, so the parent model sees the truncated answer instead of a bare failure.
|
||||
The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output.
|
||||
|
||||
The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message, and the authored `subagent-max-tokens-partial` ACP snapshot scenario pins the assembled transcript: a scripted child streams text plus a tool call, is cut off by a tool-only max-tokens step (the empty usage-only message appears in its committed log), and the parent's tool result carries the partial answer.
|
||||
## Verification
|
||||
|
||||
The keyless SDK backend test uses `FAKE_EMPTY_MESSAGE` to emit a usage-only terminal message. The `subagent-max-tokens-partial` ACP snapshot records a child that streams text and a tool call, ends at a tool-only max-tokens step with an empty usage message in its durable log, and returns the partial text through the parent's errored tool result. Unit coverage checks empty terminal messages, cancellation, message ordering, textless non-empty messages, and exclusion of tool-result content.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Fix each consumer in place without a shared helper.** Rejected: the defect existed precisely because three hand-rolled selections drifted; observers of one run must agree on its answer, so the rule needs one implementation (the drafts that first proved the defect, PR #1140 and PR #1141, patched two of the three call sites separately and left the Activation capture inconsistent).
|
||||
**Fix each consumer in place without a shared helper.** Rejected: three independent selections had diverged, while observers of one run must agree on its output.
|
||||
|
||||
**Stop the loop from appending the empty message.** Rejected: the message is the usage host and the step's durable record ("model-visible ⟺ logged"); reshaping session events for a consumer-side selection bug would touch every replay and projection consumer.
|
||||
**Stop the loop from appending the empty message.** Rejected: the message records usage and preserves the step in the durable log ("model-visible ⟺ logged"); changing session events to address output selection would affect every replay and projection consumer.
|
||||
|
||||
**Treat empty-content messages as an error.** Rejected: the streamed text is the child's real partial answer, and the stop reason already tells the consumer the turn was cut short.
|
||||
|
||||
## Consequences
|
||||
|
||||
Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. A non-empty message also wins over text streamed AFTER it: a child cancelled while streaming a later step reports its earlier complete message, matching the SDK backend's documented contract, with the stop reason signalling the truncation. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections.
|
||||
Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children retain text streamed before the abort; one-shot and continuable `subagent/end` events agree with `SubagentResult.output`. A message whose content is non-empty but textless, such as reasoning-only content, is selected instead of streamed text because the rule tests content length rather than text presence. A non-empty message is also selected instead of text streamed after it: a child cancelled while streaming a later step reports its earlier complete message, while the stop reason records the truncation.
|
||||
|
||||
@@ -6,24 +6,26 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
当 `max-tokens` 步骤只组装了工具调用块时(`BlockAssembler.blocks()` 会丢弃被截断的工具调用),agent loop 会追加一条内容为**空**的 `assistant/message`——这条消息仅用于承载 usage。三个消费方各自用自己的规则选取"子代理的回答",并且都把这个 usage 宿主当成了回答:进程内驱动的 `readResult` 和 continuable Activation 的 `subagent/end` capture 不加过滤地取**最后一条** `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 覆盖其流式文本兜底。于是在被 max-tokens 截断的多步回合中,最后那条空消息抹掉了真实的部分回答:`SubagentResult.output` 返回 `[]`,工具结果、遥测和 `subagent/end.lastAssistantMessage` 全都看不到任何内容。此外进程内驱动完全没有流式文本兜底,因此被取消的子代理若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。
|
||||
当 `max-tokens` 步骤只组装了工具调用块时,agent loop(智能体循环)会追加一条空内容的 `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的工具调用;这条消息仅记录 usage。三个消费方独立选取子 agent 的输出,并把这条 usage 记录当成输出。进程内驱动的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 优先于累积的文本。在被 max-tokens 截断的多步轮次中,最后那条空消息导致 `SubagentResult.output`、工具结果、遥测与 `subagent/end.lastAssistantMessage` 都漏掉真实的部分回答。进程内驱动也没有流式文本兜底,因此被取消的子 agent 若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。规则只有一个实现,即增量的 `AssistantOutputFold`(会话事件传输用 `push(event)`,仅分块传输用 `pushText(text)`,`collect()` 完成选取);`finalAssistantOutput(events)` 把它应用于完整的事件后缀(进程内 `readResult` 与 Activation capture)。SDK 后端折叠通知事件;ACP 后端不产生完整 assistant 消息,因此把原始分块文本折叠进同一个流式兜底。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 按同一规则选取,且"无输出"在该边沿只有一种编码——字段缺省,绝不是空数组,一次性与 continuable 两种生命周期形态一致。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。
|
||||
`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。
|
||||
|
||||
前台委派工具观察同一选取结果:非 `completed` 的结果仍是 `isError` 工具结果,但其消息在终止原因标题之后附带子代理保留下来的部分文本,父模型看到的是被截断的回答而不是一句干巴巴的失败。
|
||||
前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。
|
||||
|
||||
fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息;authored 的 `subagent-max-tokens-partial` ACP snapshot 场景钉住了组装后的 transcript:脚本化的子代理先流式输出文本和一次工具调用,再被仅含工具调用的 max-tokens 步骤截断(空的 usage-only 消息出现在其提交的日志中),父侧工具结果携带部分回答。
|
||||
## 验证
|
||||
|
||||
无密钥 SDK 后端测试使用 `FAKE_EMPTY_MESSAGE` 发出一条仅记录 usage 的终止消息。`subagent-max-tokens-partial` ACP 快照记录一个子 agent:它流式输出文本与一次工具调用,结束于仅含工具调用的 max-tokens 步骤,持久化日志中含一条空的 usage 消息,并通过父侧的错误工具结果返回部分文本。单元覆盖检查空终止消息、取消、消息顺序、不含文本的非空消息,以及排除工具结果内容。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**各消费方就地修复、不抽共享辅助函数。** 之所以否决:缺陷恰恰源于三处手写选取的漂移;同一次运行的观察方必须对其回答达成一致,因此规则需要唯一实现(最早证明该缺陷的草稿 PR #1140 与 PR #1141 分别修补了三处调用点中的两处,留下 Activation capture 不一致)。
|
||||
**各消费方就地修复、不抽共享辅助函数。** 之所以否决:三处独立选取已发生分歧,而同一次运行的观察方必须对其输出达成一致。
|
||||
|
||||
**让 loop 不再追加空消息。** 之所以否决:这条消息是 usage 宿主,也是该步骤的持久化记录("model-visible ⟺ logged");为一个消费方侧的选取缺陷重塑会话事件,会波及所有 replay 与 projection 消费方。
|
||||
**让 loop 不再追加空消息。** 之所以否决:这条消息记录 usage,并在持久化日志中保留该步骤("model-visible ⟺ logged");为处理输出选取而改动会话事件,会影响所有 replay 与 projection 消费方。
|
||||
|
||||
**把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。
|
||||
|
||||
## 后果
|
||||
|
||||
被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。非空消息同样优先于**其后**才流式出的文本:子代理在流式后续步骤时被取消,报告的是更早那条完整消息,与 SDK 后端文档化的契约一致,截断由终止原因示意。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。
|
||||
被 max-tokens 截断的多步子 agent 会报告其更早的文本;被取消的进程内子 agent 保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 事件同 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning 的内容)仍然优先于流式文本,因为规则检查内容长度,而不是文本是否存在。非空消息同样优先于其后才流式出的文本:子 agent 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断。
|
||||
|
||||
Reference in New Issue
Block a user