review: one fold implementation, uniform end-edge absence, partial text in tool errors, snapshot scenario
Address ds-review-bot on #2127: - assistant-output: the rule has ONE implementation, the incremental AssistantOutputFold (push/pushText/collect); finalAssistantOutput folds a complete suffix, the SDK backend folds notification events, and the ACP backend folds raw chunk text into the same streamed fallback. - subagent/end.lastAssistantMessage: 'no output' is encoded once — absent, never [], on both lifecycle shapes (observeRun now omits empty output). - tool-subagent: a non-completed foreground result stays isError but appends the child's preserved partial text after the stop-reason headline. - Authored keyless snapshot scenario subagent-max-tokens-partial pins the assembled transcript: the child's committed log carries the usage-only empty message and the parent's tool result carries the partial answer. - Rule-boundary sentence (message wins over later streamed text) and the consumer half recorded in the Agent Note; comments trimmed to pointers.
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: ece2a55930aabfaeaf88ef1757918296b32bfea5
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: 00b8f5bd7bd8e71935f68af2c35521f2b2377186
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: d90047c07a300a1afbc42c7db1a4fefa25d56764
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: 0a5ce02dccef422dc75bc980d104f41f116427f2
|
||||
|
||||
@@ -10,9 +10,11 @@ The agent loop appends an EMPTY-content `assistant/message` when a `max-tokens`
|
||||
|
||||
## 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. `finalAssistantOutput(events)` applies the rule to an event suffix (the in-process `readResult` and the Activation capture), and `assistantMessageOutput(event)` is the same per-event predicate for the SDK backend's incremental fold. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` declares it selects by the same rule. 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`: 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.
|
||||
|
||||
The ACP backend accumulates chunks only and was never affected. The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message.
|
||||
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 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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -24,4 +26,4 @@ The ACP backend accumulates chunks only and was never affected. The fake SDK run
|
||||
|
||||
## 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. 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 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.
|
||||
|
||||
@@ -10,9 +10,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。`finalAssistantOutput(events)` 把该规则应用于事件后缀(进程内 `readResult` 与 Activation capture),`assistantMessageOutput(event)` 是同一规则的逐事件谓词,供 SDK 后端的增量折叠使用。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 声明按同一规则选取。`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` 按同一规则选取,且"无输出"在该边沿只有一种编码——字段缺省,绝不是空数组,一次性与 continuable 两种生命周期形态一致。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。
|
||||
|
||||
ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息。
|
||||
前台委派工具观察同一选取结果:非 `completed` 的结果仍是 `isError` 工具结果,但其消息在终止原因标题之后附带子代理保留下来的部分文本,父模型看到的是被截断的回答而不是一句干巴巴的失败。
|
||||
|
||||
fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息;authored 的 `subagent-max-tokens-partial` ACP snapshot 场景钉住了组装后的 transcript:脚本化的子代理先流式输出文本和一次工具调用,再被仅含工具调用的 max-tokens 步骤截断(空的 usage-only 消息出现在其提交的日志中),父侧工具结果携带部分回答。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -24,4 +26,4 @@ ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPT
|
||||
|
||||
## 后果
|
||||
|
||||
被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。
|
||||
被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。非空消息同样优先于**其后**才流式出的文本:子代理在流式后续步骤时被取消,报告的是更早那条完整消息,与 SDK 后端文档化的契约一致,截断由终止原因示意。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。
|
||||
|
||||
Reference in New Issue
Block a user