fix(subagent): complete output selection contract

This commit is contained in:
Hypatia May
2026-08-10 22:19:14 +08:00
parent 084638af47
commit 4f23fa84cc
32 changed files with 113 additions and 93 deletions
@@ -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 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断
+2 -2
View File
@@ -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 docs/subsystems/subagent.md
subagent.md: d04e63246f2cd3f35792905574425b3013910b8a
subagent.zh.md: 61f7330988820ddf86e7d2f8b1acc92b434768e6
subagent.md: 273745057a750a0cdbd9c829e3922ed43398b861
subagent.zh.md: 3d1bed59bee17900bc04156fb8bb854cab6ca1e0
+4 -4
View File
@@ -294,10 +294,10 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is
*/
interface SubagentResult {
/**
* The child's final assistant output: the content of the last NON-EMPTY
* assistant message (an empty-content message hosts only usage and is
* skipped), else the text streamed before the turn was cut short, or `[]`
* when the child produced none.
* The child's final assistant output is the content of its last non-empty
* assistant message. Empty-content messages, including usage-only messages,
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
/**
+4 -4
View File
@@ -294,10 +294,10 @@ type SubagentDescendantListEntry = SubagentListEntry & {
*/
interface SubagentResult {
/**
* The child's final assistant output: the content of the last NON-EMPTY
* assistant message (an empty-content message hosts only usage and is
* skipped), else the text streamed before the turn was cut short, or `[]`
* when the child produced none.
* The child's final assistant output is the content of its last non-empty
* assistant message. Empty-content messages, including usage-only messages,
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
/**
+3 -6
View File
@@ -315,12 +315,9 @@ const SCENARIOS: Scenario[] = [
// Windows bash process-tree kill is deferred with the Bash execution domain.
{ name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true },
// Keyless, authored (like error-finish): a live child cannot be coaxed into
// a max-tokens step that assembled ONLY tool-call blocks — the truncation
// shape whose usage-only empty assistant/message must not erase the child's
// earlier text. The child fixture scripts text + todo_write, then a
// tool-only max-tokens cutoff; the parent's subagent tool result must carry
// the child's real partial answer with the max-tokens stop reason.
// Keyless authored scenario: the child ends at max-tokens with an empty
// usage-only assistant/message after earlier text and a tool call. The
// parent's tool result must retain that assistant output and stop reason.
{ name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false },
{ name: 'subagent-multi', hasModelTurn: true, recorded: true },
{ name: 'subagent-fork', hasModelTurn: true, recorded: true },
@@ -26,9 +26,8 @@
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
* string (wire-validation probes).
* - `FAKE_EMPTY_MESSAGE`: the turn's assistant/message has EMPTY content (a
* usage-only max-tokens step) after streaming the text chunk — a consumer
* must keep the streamed text instead of the empty message.
* - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
* assistant/message for a usage-only max-tokens step.
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
@@ -120,9 +119,8 @@ function runTurn(sessionId: string): void {
message: {
id: `fake-assistant-${seq}`,
role: 'assistant',
// FAKE_EMPTY_MESSAGE: a usage-only terminal message (the harness loop
// appends one when a max-tokens step assembled no text blocks) whose
// empty content must not erase the text streamed above.
// Model the usage-only message recorded after a max-tokens step that
// assembled no output blocks.
content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
+2 -2
View File
@@ -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 packages/scaffold/protocol/README.md
README.md: 88a48957d0d44cec9f776d31eab7d25bd353de5f
README.zh.md: 6618d8838a00f945c79d7ec24b1e7491df08a3f1
README.md: 082a890454f900aec51df123669f28814d39d601
README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430
+1 -1
View File
@@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
## Model Experience
+1 -1
View File
@@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
`HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status``InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`
`HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status``SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`
## 模型体验
+1 -1
View File
@@ -85,7 +85,7 @@ export interface SubagentFinishedNotification {
status: SdkRunStatus
/** The provider-reported stop reason. */
stopReason: SubagentStopReason
/** The child's final assistant message, when it produced one. */
/** The child's selected assistant output; absent when the child produced none. */
lastAssistantMessage?: ContentBlock[]
}
@@ -106,8 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', (
})
expect(stderr).not.toContain('listener threw')
// A childless result carries NO lastAssistantMessage on the wire: the end
// edge encodes "no output" as an absent field, never `[]`.
// A result without output omits lastAssistantMessage from the wire; it
// never sends `[]`.
expect(JSON.parse(stdout) as unknown).toEqual([{
method: 'subagent.finished',
params: {
@@ -736,8 +736,8 @@ describe('HarnessSdkServer', () => {
stopReason: 'error',
})
// A childless result carries NO lastAssistantMessage on the wire: the
// end edge encodes "no output" as an absent field, never `[]`.
// A result without output omits lastAssistantMessage from the wire; it
// never sends `[]`.
expect(transport.notifications).toContainEqual({
method: 'subagent.finished',
params: {
+2 -3
View File
@@ -233,9 +233,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
// The child's streamed assistant text, accumulated under the seam's
// canonical selection rule (`AssistantOutputFold`); ACP surfaces no complete
// assistant messages, so only the streamed-fallback half applies.
// ACP exposes no complete assistant messages, so the shared fold selects its
// accumulated assistant text.
const fold = new AssistantOutputFold()
// Shared mutable state keeps cancellation visible across async closures.
const flags = { cancelled: false }
@@ -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 packages/subagent/subagent-dsh-sdk/README.md
README.md: 80f5c40a2c949b7c2a638ec19c02950e8cd69f0b
README.zh.md: b34421dbbf2d06b7c9236776aabf19ba8005204e
README.md: 493bb187d45c7654958cfb3dbbe1dee6bb21b368
README.zh.md: 2e1d9b1e602f2180d20d43fe8c358163ec4ec024
+1 -1
View File
@@ -10,7 +10,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete NON-EMPTY `assistant/message` (an empty-content message hosts only usage and is skipped), or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error.
`dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.
@@ -10,7 +10,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且**非空**`assistant/message`空内容消息仅承载 usage,会被跳过),或该活动中断前已经累积的 `text-delta`;部分答案在取消和错误路径上都得以保留
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空`assistant/message`记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta`。取消或发生错误后,部分输出仍然可用
`dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。
@@ -176,7 +176,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it('keeps streamed text when the terminal message is an EMPTY usage-only step', async () => {
it('keeps streamed text when the terminal message is an empty usage-only step', async () => {
// The child streams its answer, then emits an empty-content
// assistant/message (the harness loop appends one to host usage on a
// max-tokens step that assembled no text blocks). The empty message is
@@ -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 packages/subagent/subagent-inprocess/README.md
README.md: 2e2a3873843467b0811ccbc0ed1d9bb6a83eb31f
README.zh.md: 91013164e762bb01e7ad5a51597c6fa559c8d7a3
README.md: fd5129b044b3d9008c3ba73645f6de36ddbf35dc
README.zh.md: 8f7ff137f04183b50acbe96bcd20a4023dfb86f4
@@ -14,7 +14,7 @@ The driver follows this sequence:
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own output — its last NON-EMPTY assistant message (an empty-content message hosts only usage and is skipped), else the text it streamed before cancel or truncation cut the turn short — and the final durable turn reason from the complete owned child run, excluding any fork seed.
5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
@@ -14,7 +14,7 @@
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条**非空** assistant 消息(空内容消息仅承载 usage,会被跳过),否则取轮次被取消或截断前已流式的文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。
5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
@@ -157,10 +157,8 @@ describe('startInProcessRun', () => {
})
it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
// Step 1 streams "partial one" plus a tool call; step 2 hits max-tokens
// having assembled only a tool-call block, so the loop appends an EMPTY
// assistant/message to host usage. The empty message is not assistant
// output and must not erase step 1's text from the run's output.
// A tool-only max-tokens step records an empty assistant/message for
// usage. The result retains the preceding assistant output.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
+2 -2
View File
@@ -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 packages/subagent/subagent/README.md
README.md: 3bc5bc1c07077f21a4245c3ce08470bc976c1458
README.zh.md: 90fde6b4403891edf910c45327c9b0f629980303
README.md: b9757bb04609d3cdd1459d5845e5c59388b269f6
README.zh.md: 9d28d057e08ddb12530ba2894eda8046a5c0ff5a
+1 -1
View File
@@ -60,7 +60,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented once by the exported `AssistantOutputFold`/`finalAssistantOutput` helpers: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract).
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract).
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
+1 -1
View File
@@ -60,7 +60,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output``subagent/end` 边沿`lastAssistantMessage` 共用同一条选取规则,由导出的 `AssistantOutputFold``finalAssistantOutput` 辅助函数唯一实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output``subagent/end` 事件`lastAssistantMessage` 使用导出的 `AssistantOutputFold``finalAssistantOutput` 辅助函数取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output``[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
@@ -1,12 +1,11 @@
/**
* Canonical selection of a child's final assistant output. Every surface that
* reports "the child's answer" — backend run results and
* `subagent/end.lastAssistantMessage` — applies this one rule so observers
* agree: the last NON-EMPTY assistant message wins; an empty-content message
* hosts only usage (the loop appends one when a max-tokens step assembled no
* executable blocks) and never erases real output; without any non-empty
* message, the text streamed so far is the answer (a partial surviving
* cancel, error, and truncation paths).
* Canonical selection of a child's final assistant output. Backend run results
* and `subagent/end.lastAssistantMessage` apply the same rule: select the last
* non-empty assistant message. An empty-content message records usage only
* when the loop appends it after a max-tokens step with no executable blocks,
* so it does not replace earlier output. If no non-empty message exists,
* select the accumulated assistant text. Selection is independent of the
* run's stop reason.
*
* @module @deepseek-ai/dsh-subagent/assistant-output
*/
@@ -35,7 +34,7 @@ export class AssistantOutputFold {
const content = event.data.message.content
if (content.length > 0) this.message = content
} else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
this.partial.push(event.data.chunk.text)
this.pushText(event.data.chunk.text)
}
}
@@ -44,7 +43,7 @@ export class AssistantOutputFold {
* @param text - the next streamed text piece (an empty piece is a no-op).
*/
pushText(text: string): void {
this.partial.push(text)
if (text.length > 0) this.partial.push(text)
}
/**
+1 -2
View File
@@ -129,8 +129,7 @@ export function observeRun(
emit('subagent/end', {
...identity,
stopReason: result.stopReason,
// One encoding for "no output" across both lifecycle shapes: the
// field is absent, matching the continuable epoch edge.
// Omit the field when no output exists, matching continuable epochs.
...result.output.length === 0 ? {} : { lastAssistantMessage: result.output },
}, parent)
},
+4 -4
View File
@@ -218,10 +218,10 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
*/
export interface SubagentResult {
/**
* The child's final assistant output: the content of the last NON-EMPTY
* assistant message (an empty-content message hosts only usage and is
* skipped), else the text streamed before the turn was cut short, or `[]`
* when the child produced none.
* The child's final assistant output is the content of its last non-empty
* assistant message. Empty-content messages, including usage-only messages,
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
/**
@@ -15,6 +15,22 @@ function reasoningDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent
}
function toolResult(text: string): SessionEvent {
return {
type: 'tool/result',
data: {
message: {
content: [{
type: 'tool-result',
toolCallId: 'call-1',
content: [{ type: 'text', text }],
isError: false,
}],
},
},
} as SessionEvent
}
describe('finalAssistantOutput', () => {
it('selects the last non-empty message past a later empty usage-only message', () => {
const events = [
@@ -25,19 +41,30 @@ describe('finalAssistantOutput', () => {
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }])
})
it('prefers a non-empty message over the streamed text', () => {
it('prefers a non-empty message over text streamed before and after it', () => {
const events = [
textDelta('streamed '),
textDelta('text'),
textDelta('earlier partial'),
message([{ type: 'text', text: 'complete answer' }]),
textDelta('later partial'),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
})
it('falls back to accumulated text deltas when no non-empty message exists', () => {
it('treats textless assistant content as a non-empty message', () => {
const content: ContentBlock[] = [{ type: 'reasoning', text: 'complete reasoning' }]
expect(finalAssistantOutput([
textDelta('streamed text'),
message(content),
textDelta('later partial'),
])).toEqual(content)
})
it('falls back to text deltas without including reasoning or tool-result content', () => {
const events = [
reasoningDelta('thinking'),
textDelta('partial '),
toolResult('tool output'),
textDelta('answer'),
message([]),
]
@@ -1201,10 +1201,9 @@ describe('continuable review regressions', () => {
})
it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => {
// Step 1 streams text plus a tool call; step 2 hits max-tokens having
// assembled only a tool-call block, so the loop appends an EMPTY
// assistant/message to host usage. The terminal edge reports the epoch's
// real answer text, not the internal usage marker.
// A tool-only max-tokens step records an empty assistant/message for
// usage. The terminal event retains the previous assistant content,
// including its tool call but not the intervening tool result.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
@@ -264,8 +264,8 @@ describe('SubagentService', () => {
stopReason: 'completed',
}))
// "No output" has ONE encoding on the end edge: the field is absent,
// never an empty array, matching the continuable epoch edge.
// The lifecycle event omits lastAssistantMessage when output is empty,
// matching the continuable epoch event.
const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' })
subagents.registerProvider(silent)
const silentRun = await subagents.start('silent', baseRequest())