fix(subagent): preserve published run failures
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/tool-subagent/README.md
|
||||
README.md: db6a96e1417eba565ce649393a5937754279be0e
|
||||
README.zh.md: c4c3175635d287d15ba4cd71c11b87818dcdc3e2
|
||||
README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a
|
||||
README.zh.md: 9e5c16ebc4760744c41965525e871da28b789612
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user