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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 会话,其一次性运行不会进入基于追踪的枚举结果。
|
||||
|
||||
|
||||
74
packages/subagent/subagent/src/assistant-output.ts
Normal file
74
packages/subagent/subagent/src/assistant-output.ts
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
92
packages/subagent/subagent/tests/assistant-output.spec.ts
Normal file
92
packages/subagent/subagent/tests/assistant-output.spec.ts
Normal file
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user