fix(subagent): preserve published run failures

This commit is contained in:
Dudu-0223
2026-07-31 14:02:03 +08:00
committed by Tianyi Cui
parent 8b0a7a5d8d
commit a977ef30ee
57 changed files with 444 additions and 208 deletions

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/tool-subagent/README.md
README.md: db6a96e1417eba565ce649393a5937754279be0e
README.zh.md: c4c3175635d287d15ba4cd71c11b87818dcdc3e2
README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a
README.zh.md: 9e5c16ebc4760744c41965525e871da28b789612

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.
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.
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).

View File

@@ -8,7 +8,7 @@
每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:全新子 agent智能体需要独立提示词而 fork 子 agent 已能看到父级已完成轮次。
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`并渲染为相同的最终文本中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`并渲染为相同的最终文本中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose 都 reject出错的结果会保留两项 diagnostic。
设置 `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)。

View File

@@ -134,6 +134,47 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
type ForegroundToolResult = {
readonly kind: 'foreground'
readonly runId: SubagentRun['id']
readonly output: JsonValue[]
}
/**
* Collect and release one foreground run without letting disposal replace an
* independent result failure.
*/
async function settleForegroundRun(run: SubagentRun): Promise<ForegroundToolResult> {
const [execution] = await Promise.allSettled([
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)
}
return {
kind: 'foreground',
runId: run.id,
// Content blocks already cross durable JSON boundaries elsewhere;
// the registry performs the authoritative lossless snapshot here.
output: result.output as unknown as JsonValue[],
}
}),
])
const [disposal] = await Promise.allSettled([Promise.resolve().then(() => run.dispose())])
if (execution.status === 'rejected') {
if (disposal.status === 'rejected') {
throw new AggregateError(
[execution.reason, disposal.reason],
`subagent run failed: ${String(execution.reason)}; dispose failed: ${String(disposal.reason)}`,
)
}
throw execution.reason
}
if (disposal.status === 'rejected') throw disposal.reason
return execution.value
}
/**
* Model-facing wording from the provider's conversation-history descriptor
* ({@link SubagentProvider.inheritsParentContext}).
@@ -335,25 +376,7 @@ export function apply(ctx: Context, config: Config): void {
...request,
signal: exec.signal,
})
try {
const result = await run.result
const error = stopReasonError(result)
if (error !== undefined) {
// The registry converts this throw to isError; partial output is not success.
throw new Error(error)
}
return {
kind: 'foreground' as const,
runId: run.id,
// Content blocks already cross durable JSON boundaries elsewhere;
// the registry performs the authoritative lossless snapshot here.
output: result.output as unknown as JsonValue[],
}
} finally {
// Dispose before returning so no child session outlives the call.
await run.dispose()
}
return settleForegroundRun(run)
},
}))
}

View File

@@ -412,6 +412,61 @@ describe('dsh-tool-subagent', () => {
expect(disposed).toHaveBeenCalledTimes(1)
})
it('preserves independent foreground result and disposal failures', async () => {
const disposed = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => ({
id: SessionId('spy-child'),
localAgent: undefined,
result: Promise.reject(new Error('published run failed')),
dispose: async () => {
disposed()
throw new Error('published handle disposal failed')
},
}),
})
await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('published run failed')
expect(text(result)).toContain('published handle disposal failed')
expect(disposed).toHaveBeenCalledTimes(1)
})
it('reports a foreground disposal failure after a completed result', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => ({
id: SessionId('spy-child'),
localAgent: undefined,
result: Promise.resolve({
output: [{ type: 'text', text: 'completed before disposal' }],
stopReason: 'completed',
}),
dispose: () => Promise.reject(new Error('published handle disposal failed')),
}),
})
await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('published handle disposal failed')
})
it('passes the tool abort signal as the provider cancellation channel', async () => {
const cancelled = vi.fn()
const ctx = new Context()