Merge pull request #2127 from deepseek-harness/fix/subagent-empty-terminal-message-output

fix(subagent): keep output past an empty terminal message with one selection rule
This commit is contained in:
hypatiamay
2026-08-11 11:19:44 +08:00
committed by GitHub
47 changed files with 530 additions and 100 deletions
+7 -10
View File
@@ -24,6 +24,7 @@ import {
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { AssistantOutputFold } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -232,8 +233,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []
// 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 }
@@ -241,7 +243,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
sessionUpdate(params: SessionNotification): Promise<void> {
const update = params.update
if (update.sessionUpdate === 'agent_message_chunk') {
output.push(acpContentText(update.content))
fold.pushText(acpContentText(update.content))
}
// Other updates (thoughts, tool calls, plans) are consumed but not
// surfaced — the subagent returns only its final answer.
@@ -284,13 +286,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
// The accumulated child text as harness ContentBlocks (empty array when the
// child streamed nothing). Read at every return so a partial answer survives
// a later cancel/error.
const collectOutput = (): ContentBlock[] => {
const text = output.join('')
return text.length > 0 ? [{ type: 'text', text }] : []
}
// Read at every return so a partial answer survives a later cancel/error.
const collectOutput = (): ContentBlock[] => fold.collect() ?? []
// Establish the remote session before publishing a handle. Any failure owns
// the still-private process and therefore reaps it before rejecting.
@@ -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: 0bbcfa105ecf024a2492d39d3bf8d28956110050
README.zh.md: 8c8551f85951aa8475ab2ce95771e4d54e0ed89a
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 `assistant/message`, 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`,或该活动中断前已经累积的 `text-delta`;部分答案在取消和错误路径上都得以保留
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空`assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta`。取消或发生错误后,部分输出仍然可用
`dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。
+6 -16
View File
@@ -16,7 +16,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
@@ -163,24 +163,14 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
}
const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
// The child's final answer: the last complete assistant message when one
// exists, else the text streamed so far (a partial answer surviving cancel).
let lastMessage: ContentBlock[] | undefined
const partial: string[] = []
// The child's final answer under the seam's canonical selection rule
// (`AssistantOutputFold`); a partial answer survives cancel and error paths.
const fold = new AssistantOutputFold()
const observe = (notification: HarnessNotification): void => {
if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return
const event = notification.params.event as SessionEvent
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
partial.push(event.data.chunk.text)
} else if (event.type === 'assistant/message') {
lastMessage = event.data.message.content
}
}
const collectOutput = (): ContentBlock[] => {
if (lastMessage !== undefined) return lastMessage
const text = partial.join('')
return text.length > 0 ? [{ type: 'text', text }] : []
fold.push(notification.params.event as SessionEvent)
}
const collectOutput = (): ContentBlock[] => fold.collect() ?? []
// Race the child turn against local cancellation; the shared settlement
// flattens failures under the seam's never-reject contract.
@@ -176,6 +176,20 @@ 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 () => {
// 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
// not assistant output and must not erase the streamed answer.
const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('hello from fake runtime')
await run.dispose()
await ctx.fiber.dispose()
})
it('reports a settled-without-turn child as an error', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('dsh-sdk', request())
@@ -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: 209f1e9526ff4a01af6f4c96955068de4b2b06c0
README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14
README.md: 69def8bf8f41e3685d017ac4b003b26a37f064ef
README.zh.md: bf5e7cb5cc8517ee7020695ef10e3b58d613541b
@@ -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 last assistant message and 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 消息最终持久化的轮次原因,并排除任何 fork 初始内容。
5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
@@ -22,6 +22,7 @@ import {
assertSubagentMaxDepth,
captureDelegatedPolicyOverrides,
childSessionMeta,
finalAssistantOutput,
resolveChildAgentOptions,
resolveChildDepth,
} from '@deepseek-ai/dsh-subagent'
@@ -206,9 +207,9 @@ function readResult(
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = findLastMessageTurnEnd(own)
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
// The seam's canonical selection rule; a partial answer survives cancel and truncation.
const output: ContentBlock[] = finalAssistantOutput(own) ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead.
@@ -1,4 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
@@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -155,6 +156,31 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
// 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'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
const disposeNoop = ctx.tools.register(defineContentToolFixture({
name: 'noop', description: 'probe', parameters: {},
execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) },
}))
const run = await startInProcessRun(request(parent), {})
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('partial one')
await run.dispose()
disposeNoop()
})
it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
@@ -278,7 +304,12 @@ describe('startInProcessRun', () => {
const signalled = await startInProcessRun(request(parent, controller.signal), {})
await new Promise(resolve => setTimeout(resolve, 30))
controller.abort('stop child')
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
// No step completed a message, so the text streamed before the abort is
// the cancelled run's output.
await expect(signalled.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial' }],
stopReason: 'aborted',
})
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
const child = parent.ctx.agents.get(signalled.id)
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
+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: 42a10adeccb8e299e25ff0a5e0a918ef09b79617
README.zh.md: 34e2ed6c1ca23df9b3158f3caea10cd19bafa841
README.md: 28f649ef54bbf88feda24a9ce197c2c366f8349b
README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79
+1 -1
View File
@@ -64,7 +64,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat
`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.
`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
@@ -64,7 +64,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()` 拒绝。
`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 会话,其一次性运行不会进入基于追踪的枚举结果。
@@ -0,0 +1,74 @@
/**
* 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
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Incremental fold of the selection rule, for backends that observe a child's
* output as it streams: session-event backends {@link push} each event, and
* transports without session events (ACP content chunks) {@link pushText} raw
* text into the same streamed fallback.
*/
export class AssistantOutputFold {
private message: ContentBlock[] | undefined
private partial: string[] = []
/**
* Fold one session event: a non-empty assistant message becomes the
* candidate final answer, and a `text-delta` chunk extends the streamed
* fallback; every other event contributes nothing.
* @param event - the next observed session event.
*/
push(event: SessionEvent): void {
if (event.type === 'assistant/message') {
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.pushText(event.data.chunk.text)
}
}
/**
* Extend the streamed fallback with text observed outside session events.
* @param text - the next streamed text piece (an empty piece is a no-op).
*/
pushText(text: string): void {
if (text.length > 0) this.partial.push(text)
}
/**
* Select the final output folded so far.
* @returns the last non-empty assistant message, else the accumulated
* streamed text, or `undefined` when the child produced neither.
*/
collect(): ContentBlock[] | undefined {
if (this.message !== undefined) return this.message
const text = this.partial.join('')
return text.length > 0 ? [{ type: 'text', text }] : undefined
}
}
/**
* Apply the selection rule to one complete child-owned event suffix.
* @param events - the child-owned events (after any seed or epoch boundary).
* @returns the selected output, or `undefined` when the child produced none.
*/
export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
// TODO: this folds the complete suffix once per run/epoch settlement. If a
// long continuable epoch ever profiles hot here, scan backward with early
// exit for the last non-empty message and fold text deltas only on the
// no-message fallback.
const fold = new AssistantOutputFold()
for (const event of events) fold.push(event)
return fold.collect()
}
+1
View File
@@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts'
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
export * from './out-of-process.ts'
export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts'
export { SubagentRunId } from './types.ts'
export type {
ContinuableCreateRequest,
+4 -15
View File
@@ -20,6 +20,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { finalAssistantOutput } from './assistant-output.ts'
import { SubagentRunId } from './types.ts'
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
@@ -128,7 +129,8 @@ export function observeRun(
emit('subagent/end', {
...identity,
stopReason: result.stopReason,
lastAssistantMessage: result.output,
// Omit the field when no output exists, matching continuable epochs.
...result.output.length === 0 ? {} : { lastAssistantMessage: result.output },
}, parent)
},
() => {
@@ -173,7 +175,7 @@ export function createActivationObserver(
},
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = lastAssistantOutput(own)
const output = finalAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
@@ -220,19 +222,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
}
}
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param events - this epoch's own event suffix.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
const message = events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
+11 -2
View File
@@ -64,7 +64,11 @@ export interface SubagentRunEndInfo {
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
/**
* The child's final assistant output, selected by the same rule as
* {@link SubagentResult.output}; absent on infrastructure rejection or when
* the child produced none.
*/
readonly lastAssistantMessage?: ContentBlock[]
}
@@ -213,7 +217,12 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
export interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
/**
* 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[]
/**
* The structured result after a requested `outputSchema` was successfully
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AssistantOutputFold, finalAssistantOutput } from '../src/assistant-output.ts'
function message(content: ContentBlock[]): SessionEvent {
return { type: 'assistant/message', data: { message: { content } } } as SessionEvent
}
function textDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text } } } as SessionEvent
}
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 = [
message([{ type: 'text', text: 'step one' }]),
message([{ type: 'text', text: 'step two' }]),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }])
})
it('prefers a non-empty message over text streamed before and after it', () => {
const events = [
textDelta('earlier partial'),
message([{ type: 'text', text: 'complete answer' }]),
textDelta('later partial'),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
})
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([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'partial answer' }])
})
it('returns undefined when the child produced neither messages nor text', () => {
expect(finalAssistantOutput([])).toBeUndefined()
expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined()
})
})
describe('AssistantOutputFold', () => {
it('folds raw text pieces into the same streamed fallback (ACP chunk transport)', () => {
const fold = new AssistantOutputFold()
fold.pushText('partial ')
fold.pushText('')
fold.pushText('answer')
expect(fold.collect()).toEqual([{ type: 'text', text: 'partial answer' }])
})
it('collects undefined until any output is folded', () => {
expect(new AssistantOutputFold().collect()).toBeUndefined()
})
})
@@ -12,10 +12,10 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService, {
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
@@ -1200,6 +1200,44 @@ describe('continuable review regressions', () => {
expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }])
})
it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => {
// 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'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: false, properties: {} },
render: () => [{ type: 'text', text: 'noop' }],
},
execute: () => Promise.resolve({}),
}))
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
expect(ends[0]!.stopReason).toBe('max-tokens')
expect(ends[0]!.lastAssistantMessage).toEqual([
{ type: 'text', text: 'partial one' },
{ type: 'tool-call', id: 't1', name: 'noop', arguments: '{}' },
])
})
it('reports a resumed epoch that opened no turn without the previous answer', async () => {
const { ctx, parent } = await setup([textResponse('first answer')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
@@ -15,6 +15,7 @@ import SubagentService, {
type SubagentProvider,
type SubagentResult,
type SubagentRun,
type SubagentRunEndInfo,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -263,6 +264,17 @@ describe('SubagentService', () => {
stopReason: 'completed',
}))
// 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())
await silentRun.result
await Promise.resolve()
const silentEnd = ended.mock.calls.map(call => call[0] as SubagentRunEndInfo).find(info => info.provider === 'silent')
expect(silentEnd).toBeDefined()
expect('lastAssistantMessage' in silentEnd!).toBe(false)
const failure = Promise.withResolvers<SubagentResult>()
subagents.registerProvider({
name: 'infra',
@@ -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/tool-subagent/README.md
README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a
README.zh.md: 1fd88363b3ade9d57c194580f81295eed139ac50
README.md: ac3ec0563cce9128608ca31860b034a103dc1a3a
README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0
+1 -1
View File
@@ -8,7 +8,7 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. If result collection and disposal both reject, the errored result preserves both diagnostics.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics.
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
+1 -1
View File
@@ -8,7 +8,7 @@
每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。
设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
+18 -2
View File
@@ -134,6 +134,21 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
/**
* Append the child's preserved partial answer to a stop-reason error so a
* truncated or cancelled child's real text still reaches the parent model.
* @param error - the stop-reason headline.
* @param output - the child's selected output (`SubagentResult.output`).
* @returns the headline, extended with the partial text when any exists.
*/
function withPartialText(error: string, output: ContentBlock[]): string {
const text = output
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('')
return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}`
}
type ForegroundToolResult = {
readonly kind: 'foreground'
readonly runId: SubagentRun['id']
@@ -149,8 +164,9 @@ async function settleForegroundRun(run: SubagentRun): Promise<ForegroundToolResu
run.result.then((result): ForegroundToolResult => {
const error = stopReasonError(result)
if (error !== undefined) {
// The registry converts this throw to isError; partial output is not success.
throw new Error(error)
// The registry converts this throw to isError; partial output is not
// success, but the preserved partial answer still reaches the parent.
throw new Error(withPartialText(error, result.output))
}
return {
kind: 'foreground',
@@ -154,6 +154,9 @@ describe('dsh-tool-subagent', () => {
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(result.isError).toBe(true)
expect(text(result)).toContain(fragment)
// The failure is not partial success, but the child's preserved partial
// answer still reaches the parent model inside the error result.
expect(text(result)).toContain('scripted subagent reply')
})
it('registers under a configurable toolName so multiple providers can coexist', async () => {