fix(subagent): complete output selection contract
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: 3bc5bc1c07077f21a4245c3ce08470bc976c1458
|
||||
README.zh.md: 90fde6b4403891edf910c45327c9b0f629980303
|
||||
README.md: b9757bb04609d3cdd1459d5845e5c59388b269f6
|
||||
README.zh.md: 9d28d057e08ddb12530ba2894eda8046a5c0ff5a
|
||||
|
||||
@@ -60,7 +60,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented once by the exported `AssistantOutputFold`/`finalAssistantOutput` helpers: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract).
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract).
|
||||
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。
|
||||
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数唯一实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
|
||||
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Canonical selection of a child's final assistant output. Every surface that
|
||||
* reports "the child's answer" — backend run results and
|
||||
* `subagent/end.lastAssistantMessage` — applies this one rule so observers
|
||||
* agree: the last NON-EMPTY assistant message wins; an empty-content message
|
||||
* hosts only usage (the loop appends one when a max-tokens step assembled no
|
||||
* executable blocks) and never erases real output; without any non-empty
|
||||
* message, the text streamed so far is the answer (a partial surviving
|
||||
* cancel, error, and truncation paths).
|
||||
* Canonical selection of a child's final assistant output. Backend run results
|
||||
* and `subagent/end.lastAssistantMessage` apply the same rule: select the last
|
||||
* non-empty assistant message. An empty-content message records usage only
|
||||
* when the loop appends it after a max-tokens step with no executable blocks,
|
||||
* so it does not replace earlier output. If no non-empty message exists,
|
||||
* select the accumulated assistant text. Selection is independent of the
|
||||
* run's stop reason.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/assistant-output
|
||||
*/
|
||||
@@ -35,7 +34,7 @@ export class AssistantOutputFold {
|
||||
const content = event.data.message.content
|
||||
if (content.length > 0) this.message = content
|
||||
} else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
|
||||
this.partial.push(event.data.chunk.text)
|
||||
this.pushText(event.data.chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +43,7 @@ export class AssistantOutputFold {
|
||||
* @param text - the next streamed text piece (an empty piece is a no-op).
|
||||
*/
|
||||
pushText(text: string): void {
|
||||
this.partial.push(text)
|
||||
if (text.length > 0) this.partial.push(text)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -129,8 +129,7 @@ export function observeRun(
|
||||
emit('subagent/end', {
|
||||
...identity,
|
||||
stopReason: result.stopReason,
|
||||
// One encoding for "no output" across both lifecycle shapes: the
|
||||
// field is absent, matching the continuable epoch edge.
|
||||
// Omit the field when no output exists, matching continuable epochs.
|
||||
...result.output.length === 0 ? {} : { lastAssistantMessage: result.output },
|
||||
}, parent)
|
||||
},
|
||||
|
||||
@@ -218,10 +218,10 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
|
||||
*/
|
||||
export interface SubagentResult {
|
||||
/**
|
||||
* The child's final assistant output: the content of the last NON-EMPTY
|
||||
* assistant message (an empty-content message hosts only usage and is
|
||||
* skipped), else the text streamed before the turn was cut short, or `[]`
|
||||
* when the child produced none.
|
||||
* The child's final assistant output is the content of its last non-empty
|
||||
* assistant message. Empty-content messages, including usage-only messages,
|
||||
* are skipped. Without a non-empty message, the output is its accumulated
|
||||
* assistant text stream, or `[]` when the child produced neither.
|
||||
*/
|
||||
readonly output: ContentBlock[]
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,22 @@ function reasoningDelta(text: string): SessionEvent {
|
||||
return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent
|
||||
}
|
||||
|
||||
function toolResult(text: string): SessionEvent {
|
||||
return {
|
||||
type: 'tool/result',
|
||||
data: {
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
content: [{ type: 'text', text }],
|
||||
isError: false,
|
||||
}],
|
||||
},
|
||||
},
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
describe('finalAssistantOutput', () => {
|
||||
it('selects the last non-empty message past a later empty usage-only message', () => {
|
||||
const events = [
|
||||
@@ -25,19 +41,30 @@ describe('finalAssistantOutput', () => {
|
||||
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }])
|
||||
})
|
||||
|
||||
it('prefers a non-empty message over the streamed text', () => {
|
||||
it('prefers a non-empty message over text streamed before and after it', () => {
|
||||
const events = [
|
||||
textDelta('streamed '),
|
||||
textDelta('text'),
|
||||
textDelta('earlier partial'),
|
||||
message([{ type: 'text', text: 'complete answer' }]),
|
||||
textDelta('later partial'),
|
||||
message([]),
|
||||
]
|
||||
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
|
||||
})
|
||||
|
||||
it('falls back to accumulated text deltas when no non-empty message exists', () => {
|
||||
it('treats textless assistant content as a non-empty message', () => {
|
||||
const content: ContentBlock[] = [{ type: 'reasoning', text: 'complete reasoning' }]
|
||||
expect(finalAssistantOutput([
|
||||
textDelta('streamed text'),
|
||||
message(content),
|
||||
textDelta('later partial'),
|
||||
])).toEqual(content)
|
||||
})
|
||||
|
||||
it('falls back to text deltas without including reasoning or tool-result content', () => {
|
||||
const events = [
|
||||
reasoningDelta('thinking'),
|
||||
textDelta('partial '),
|
||||
toolResult('tool output'),
|
||||
textDelta('answer'),
|
||||
message([]),
|
||||
]
|
||||
|
||||
@@ -1201,10 +1201,9 @@ describe('continuable review regressions', () => {
|
||||
})
|
||||
|
||||
it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => {
|
||||
// Step 1 streams text plus a tool call; step 2 hits max-tokens having
|
||||
// assembled only a tool-call block, so the loop appends an EMPTY
|
||||
// assistant/message to host usage. The terminal edge reports the epoch's
|
||||
// real answer text, not the internal usage marker.
|
||||
// A tool-only max-tokens step records an empty assistant/message for
|
||||
// usage. The terminal event retains the previous assistant content,
|
||||
// including its tool call but not the intervening tool result.
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('t1', 'noop', {}, 'partial one'),
|
||||
[
|
||||
|
||||
@@ -264,8 +264,8 @@ describe('SubagentService', () => {
|
||||
stopReason: 'completed',
|
||||
}))
|
||||
|
||||
// "No output" has ONE encoding on the end edge: the field is absent,
|
||||
// never an empty array, matching the continuable epoch edge.
|
||||
// The lifecycle event omits lastAssistantMessage when output is empty,
|
||||
// matching the continuable epoch event.
|
||||
const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' })
|
||||
subagents.registerProvider(silent)
|
||||
const silentRun = await subagents.start('silent', baseRequest())
|
||||
|
||||
Reference in New Issue
Block a user