fix(subagent): keep output past an empty terminal message with one selection rule
A max-tokens step that assembled only tool-call blocks appends an EMPTY-content assistant/message (the usage host). Three consumers each hand-rolled output selection and all let it erase the child's real answer: the in-process readResult and the Activation subagent/end capture took the last message unfiltered, and the SDK backend let any message beat its streamed-text fallback; the in-process driver also had no streamed-text fallback for cancelled turns. dsh-subagent now owns the canonical rule in src/assistant-output.ts (last non-empty assistant message, else the accumulated text-delta stream) and all three consumers apply it. Regression tests in all three packages fail under the previous selections. Closes #1514
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: 762030629c09305c48adebc71244655a5faa6585
|
||||
README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff
|
||||
README.md: d2d5356fd82a47ecf5cd6b633e5338dde7047901
|
||||
README.zh.md: 2fdc3ae6e8376ef7c7aaf11c8e85dd909ef92d2c
|
||||
|
||||
@@ -56,7 +56,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.
|
||||
`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 by the exported `finalAssistantOutput` helper: 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).
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -56,7 +56,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` 共用同一条选取规则,由导出的 `finalAssistantOutput` 辅助函数实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`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 会话,其一次性运行不会进入基于追踪的枚举结果。
|
||||
|
||||
|
||||
51
packages/subagent/subagent/src/assistant-output.ts
Normal file
51
packages/subagent/subagent/src/assistant-output.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Canonical selection of a child's final assistant output from its session
|
||||
* events. 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).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/assistant-output
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The content one event contributes as a candidate final answer: an
|
||||
* `assistant/message` with non-empty content. An empty-content message hosts
|
||||
* only usage and contributes none.
|
||||
* @param event - any session event.
|
||||
* @returns the message content, or `undefined` when this event is not a
|
||||
* non-empty assistant message.
|
||||
*/
|
||||
export function assistantMessageOutput(event: SessionEvent): ContentBlock[] | undefined {
|
||||
if (event.type !== 'assistant/message') return undefined
|
||||
const content = event.data.message.content
|
||||
return content.length > 0 ? content : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the final assistant output from one child-owned event suffix: the
|
||||
* last non-empty assistant message, else the accumulated `text-delta` stream.
|
||||
* @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 {
|
||||
let message: ContentBlock[] | undefined
|
||||
const partial: string[] = []
|
||||
for (const event of events) {
|
||||
const content = assistantMessageOutput(event)
|
||||
if (content !== undefined) {
|
||||
message = content
|
||||
} else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
|
||||
partial.push(event.data.chunk.text)
|
||||
}
|
||||
}
|
||||
if (message !== undefined) return message
|
||||
const text = partial.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : undefined
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { assistantMessageOutput, 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'
|
||||
|
||||
@@ -173,7 +174,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 +221,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: 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.
|
||||
*/
|
||||
readonly output: ContentBlock[]
|
||||
/**
|
||||
* The structured result after a requested `outputSchema` was successfully
|
||||
|
||||
60
packages/subagent/subagent/tests/assistant-output.spec.ts
Normal file
60
packages/subagent/subagent/tests/assistant-output.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { assistantMessageOutput, 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
|
||||
}
|
||||
|
||||
describe('assistantMessageOutput', () => {
|
||||
it('returns content only for a non-empty assistant message', () => {
|
||||
const content: ContentBlock[] = [{ type: 'text', text: 'answer' }]
|
||||
expect(assistantMessageOutput(message(content))).toBe(content)
|
||||
expect(assistantMessageOutput(message([]))).toBeUndefined()
|
||||
expect(assistantMessageOutput(textDelta('chunk'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
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 the streamed text', () => {
|
||||
const events = [
|
||||
textDelta('streamed '),
|
||||
textDelta('text'),
|
||||
message([{ type: 'text', text: 'complete answer' }]),
|
||||
]
|
||||
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
|
||||
})
|
||||
|
||||
it('falls back to accumulated text deltas when no non-empty message exists', () => {
|
||||
const events = [
|
||||
reasoningDelta('thinking'),
|
||||
textDelta('partial '),
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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,45 @@ 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 () => {
|
||||
// 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.
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user