review: one fold implementation, uniform end-edge absence, partial text in tool errors, snapshot scenario
Address ds-review-bot on #2127: - assistant-output: the rule has ONE implementation, the incremental AssistantOutputFold (push/pushText/collect); finalAssistantOutput folds a complete suffix, the SDK backend folds notification events, and the ACP backend folds raw chunk text into the same streamed fallback. - subagent/end.lastAssistantMessage: 'no output' is encoded once — absent, never [], on both lifecycle shapes (observeRun now omits empty output). - tool-subagent: a non-completed foreground result stays isError but appends the child's preserved partial text after the stop-reason headline. - Authored keyless snapshot scenario subagent-max-tokens-partial pins the assembled transcript: the child's committed log carries the usage-only empty message and the parent's tool result carries the partial answer. - Rule-boundary sentence (message wins over later streamed text) and the consumer half recorded in the Agent Note; comments trimmed to pointers.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: d2d5356fd82a47ecf5cd6b633e5338dde7047901
|
||||
README.zh.md: 2fdc3ae6e8376ef7c7aaf11c8e85dd909ef92d2c
|
||||
README.md: 843fd0af4a86ea3a10d4a4ee101aa05478a2300e
|
||||
README.zh.md: 497f2a928c8eaeff8ef0486f5344c3d257be7391
|
||||
|
||||
@@ -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. `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).
|
||||
`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).
|
||||
|
||||
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()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `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 消息,否则取轮次被截断前已流式的文本(契约归 [`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,12 @@
|
||||
/**
|
||||
* 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).
|
||||
* 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).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/assistant-output
|
||||
*/
|
||||
@@ -15,37 +15,57 @@ 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.
|
||||
* 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 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
|
||||
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.partial.push(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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the final assistant output from one child-owned event suffix: the
|
||||
* last non-empty assistant message, else the accumulated `text-delta` stream.
|
||||
* 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 {
|
||||
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
|
||||
const fold = new AssistantOutputFold()
|
||||
for (const event of events) fold.push(event)
|
||||
return fold.collect()
|
||||
}
|
||||
|
||||
@@ -69,7 +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 { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
export type {
|
||||
ContinuableCreateRequest,
|
||||
|
||||
@@ -129,7 +129,9 @@ export function observeRun(
|
||||
emit('subagent/end', {
|
||||
...identity,
|
||||
stopReason: result.stopReason,
|
||||
lastAssistantMessage: result.output,
|
||||
// One encoding for "no output" across both lifecycle shapes: the
|
||||
// field is absent, matching the continuable epoch edge.
|
||||
...result.output.length === 0 ? {} : { lastAssistantMessage: result.output },
|
||||
}, parent)
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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'
|
||||
import { AssistantOutputFold, finalAssistantOutput } from '../src/assistant-output.ts'
|
||||
|
||||
function message(content: ContentBlock[]): SessionEvent {
|
||||
return { type: 'assistant/message', data: { message: { content } } } as SessionEvent
|
||||
@@ -15,15 +15,6 @@ 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 = [
|
||||
@@ -58,3 +49,17 @@ describe('finalAssistantOutput', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
// "No output" has ONE encoding on the end edge: the field is absent,
|
||||
// never an empty array, matching the continuable epoch edge.
|
||||
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