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/subagent-inprocess/README.md
|
||||
README.md: 800ba24b65cedbbff31008a94b45957d5a65657e
|
||||
README.zh.md: f6e0bd6fcb2340d195469ad57e2e96941c684fdd
|
||||
README.md: ea6443e9a862c03ce4e43b32d5c1b1d8996f670f
|
||||
README.zh.md: fb5710a363faa828d0c7252723cc552094eb3eb2
|
||||
|
||||
@@ -6,13 +6,13 @@ This package is the shared run driver for the two in-process providers' one-shot
|
||||
|
||||
## Start contract
|
||||
|
||||
`startInProcessRun(request, options): Promise<SubagentRun>` fulfills only after the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, so the caller never receives a half-created handle.
|
||||
`startInProcessRun(request, options): Promise<SubagentRun>` fulfills as soon as the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, while turn or infrastructure failures after publication settle through the returned run without hiding the child id.
|
||||
|
||||
The driver follows this sequence:
|
||||
|
||||
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
|
||||
2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and a one-shot `agent/step` contribution that appends the resolved `subagent/descriptor` event after the initial `turn/start` and before the first request.
|
||||
3. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
3. Publish the child, retain the returned `AgentHandle`, and return its holder-owned run. The run's `result` drives one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output.
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
@@ -21,9 +21,9 @@ When the optional sandbox-policy or approval service is composed, the driver sna
|
||||
|
||||
## Cancellation and ownership
|
||||
|
||||
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
|
||||
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the published run immediately installs its own listener and checks the signal again, closing the handoff race. Once publication has occurred, an abort preserves the returned child id, prevents unsubmitted work, and resolves an incomplete result as `aborted`; an abort during the turn cancels the child.
|
||||
|
||||
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
|
||||
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and awaits both `result` and the returned `AgentHandle.dispose()`; the handle's memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. A result rejection remains on `result`; `dispose()` rejects only when handle disposal fails, after both operations settle. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
|
||||
|
||||
## Spawn and fork inputs
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
## 启动契约
|
||||
|
||||
`startInProcessRun(request, options): Promise<SubagentRun>` 只在子 agent 发布到 `ctx.agents` 后才兑现。启动被拒绝时,agent 工厂的未发布创建事务已经完全停稳,因此调用方绝不会收到创建到一半的句柄。
|
||||
`startInProcessRun(request, options): Promise<SubagentRun>` 会在子 agent 发布到 `ctx.agents` 后立即兑现。启动被拒绝时,agent 工厂的未发布创建事务已经完全停稳;发布后的轮次或基础设施故障则通过返回的 run 结算,且不会隐藏 child id。
|
||||
|
||||
驱动器按以下顺序运行:
|
||||
|
||||
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
|
||||
2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制、结构化输出运行时,以及一次性的 `agent/step` contribution;该 contribution 会在初始 `turn/start` 之后、首次请求之前追加已解析的 `subagent/descriptor` 事件。
|
||||
3. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
|
||||
3. 发布子 agent,保留返回的 `AgentHandle`,并返回由持有方拥有的 run。该 run 的 `result` 会通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
|
||||
4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
@@ -20,9 +20,9 @@
|
||||
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
## 取消与所有权
|
||||
|
||||
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。
|
||||
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;已发布的 run 会立即安装自己的监听器并再次检查信号,从而消除交接竞态。一旦完成发布,中止会保留已返回的 child id、阻止尚未提交的工作,并以 `aborted` 兑现未完成的结果;轮次期间发生中止时,则会取消子 agent。
|
||||
|
||||
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
|
||||
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并同时等待 `result` 和返回的 `AgentHandle.dispose()`;该句柄通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。`result` 的 rejection 仍归 `result` 通道;只有句柄释放失败时,`dispose()` 才会在两项操作都结算后拒绝。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
|
||||
|
||||
## Spawn 与 fork 输入
|
||||
|
||||
|
||||
@@ -85,12 +85,14 @@ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescripto
|
||||
|
||||
/**
|
||||
* Establish and drive one in-process one-shot child. Fulfillment means the agent
|
||||
* is already published in the registry; rejection means the agent factory's
|
||||
* creation transaction and any partially-created child have reached quiescence.
|
||||
* Every start appends its resolved descriptor inside the child's initial turn.
|
||||
* is already published in the registry and transfers its turn, cancellation,
|
||||
* and disposal work through the returned run. Rejection means the agent
|
||||
* factory's unpublished creation transaction reached quiescence without
|
||||
* publishing a child. Every start appends its resolved descriptor inside the
|
||||
* child's initial turn.
|
||||
* @param request - the trusted typed start request, including its required signal.
|
||||
* @param options - the optional fork seed.
|
||||
* @returns a ready holder-owned run.
|
||||
* @returns a published holder-owned run.
|
||||
*/
|
||||
export async function startInProcessRun(
|
||||
request: ResolvedSubagentStartRequest,
|
||||
@@ -139,7 +141,7 @@ export async function startInProcessRun(
|
||||
signal: request.signal,
|
||||
setup,
|
||||
})
|
||||
return driveTurn(
|
||||
return drivePublishedRun(
|
||||
handle,
|
||||
request.signal,
|
||||
request.prompt,
|
||||
@@ -150,36 +152,35 @@ export async function startInProcessRun(
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one turn on a published child and wrap it as a run. The caller has
|
||||
* already created the agent; this owns the signal-handoff race, the live abort
|
||||
* listener, result collection past `boundary`, and disposal.
|
||||
* Wrap a published child in the single run lifecycle that owns signal handoff,
|
||||
* one turn, result settlement, and quiescent disposal.
|
||||
*/
|
||||
function driveTurn(
|
||||
function drivePublishedRun(
|
||||
handle: AgentHandle,
|
||||
signal: AbortSignal,
|
||||
prompt: ContentBlock[],
|
||||
childId: SessionId,
|
||||
boundary: number,
|
||||
structured: StructuredAttachment | undefined,
|
||||
): SubagentRun | Promise<never> {
|
||||
): SubagentRun {
|
||||
const child = handle.agent
|
||||
// Agent creation detaches its creation-only abort listener before returning.
|
||||
// Close the narrow handoff race before installing the live-run listener.
|
||||
if (signal.aborted) {
|
||||
return handle.dispose().then(() => { throw prePublicationAbort() })
|
||||
}
|
||||
|
||||
const flags = { cancelled: false }
|
||||
const onAbort = (): void => {
|
||||
flags.cancelled = true
|
||||
child.cancel({ kind: 'parent' })
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
// Agent creation detaches its creation-only listener before returning. The
|
||||
// post-registration check closes that handoff without treating an already
|
||||
// published child as a failed start.
|
||||
if (signal.aborted) onAbort()
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
|
||||
await child.whenIdle()
|
||||
if (!flags.cancelled) {
|
||||
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
|
||||
await child.whenIdle()
|
||||
}
|
||||
return readResult(
|
||||
child,
|
||||
boundary,
|
||||
@@ -195,10 +196,14 @@ function driveTurn(
|
||||
id: childId,
|
||||
localAgent: child,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
async dispose(): Promise<void> {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
flags.cancelled = true
|
||||
return handle.dispose()
|
||||
const settlements = await Promise.allSettled([handle.dispose(), result])
|
||||
const disposal = settlements[0]
|
||||
// The result channel owns run faults; disposal reports only failure to
|
||||
// release the published handle after both operations settle.
|
||||
if (disposal.status === 'rejected') throw disposal.reason
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,40 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps published run and handle disposal failures on separate channels', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const runError = new Error('published run failed')
|
||||
const disposalError = new Error('published handle disposal failed')
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
const parentWithFailedDisposal = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
get: () => undefined,
|
||||
agents: {
|
||||
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
|
||||
const handle = await ctx.agents.create(options)
|
||||
handle.agent.followup = () => { throw runError }
|
||||
return {
|
||||
...handle,
|
||||
dispose: async () => {
|
||||
await handle.dispose()
|
||||
throw disposalError
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = await startInProcessRun(request(parentWithFailedDisposal), {})
|
||||
expect(ctx.agents.get(run.id)).toBeDefined()
|
||||
await expect(run.result).rejects.toBe(runError)
|
||||
await expect(run.dispose()).rejects.toBe(disposalError)
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
it('reports the message-turn outcome when a later non-message turn completes during flush', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
|
||||
let injected = false
|
||||
@@ -272,7 +306,7 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
it('closes the abort handoff after the factory detaches its creation listener', async () => {
|
||||
it('treats abort after factory publication as a cancelled run with an id', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
@@ -288,15 +322,17 @@ describe('startInProcessRun', () => {
|
||||
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
|
||||
const handle = await ctx.agents.create(options)
|
||||
// `create()` has detached its creation-only listener, but the
|
||||
// provider continuation has not installed its live-run listener.
|
||||
// published run has not installed its live listener yet.
|
||||
controller.abort('handoff race')
|
||||
return handle
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
await expect(startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {}))
|
||||
.rejects.toThrow('aborted before child publication')
|
||||
const run = await startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {})
|
||||
expect(ctx.agents.get(run.id)).toBeDefined()
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
await run.dispose()
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
@@ -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: da03249d2957a67a0a0a1b3b935e90a9398125c8
|
||||
README.zh.md: 74aebb4efbae05375b016f9a39ab8cd134eb030a
|
||||
README.md: a27c64ad3b7c4260d769f030db5dacd20e772ac6
|
||||
README.zh.md: d18e1159336515e9184dbc8f07dc92811a93d4e9
|
||||
|
||||
@@ -28,13 +28,13 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
|
||||
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
|
||||
| `list()` | Return provider names in insertion order. |
|
||||
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
|
||||
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. |
|
||||
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
|
||||
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
|
||||
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode and `running`/`inactive` activity, plus per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
|
||||
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
|
||||
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
|
||||
|
||||
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
|
||||
|
||||
@@ -63,9 +63,9 @@ The seam owns the depth vocabulary shared by implementations and consumers: the
|
||||
|
||||
## One-shot ownership and lifecycle
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary and the only Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path.
|
||||
`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 the child resources to quiesce.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,7 +81,7 @@ A continuation-managed parent Activation records each child Session id in an `ow
|
||||
|
||||
## Lifecycle events
|
||||
|
||||
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may become ready after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered.
|
||||
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may settle after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered.
|
||||
|
||||
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 |
|
||||
| `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 |
|
||||
| `list()` | 按插入顺序返回提供方名称。 |
|
||||
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
|
||||
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 |
|
||||
| `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
|
||||
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
|
||||
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式和 `running`/`inactive` 活动状态,以及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
|
||||
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
|
||||
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
|
||||
|
||||
后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。
|
||||
|
||||
@@ -63,9 +63,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 一次性所有权与生命周期
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界,也是唯一由 Task 支撑的后台路径。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。
|
||||
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。
|
||||
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
|
||||
|
||||
@@ -81,7 +81,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 生命周期事件
|
||||
|
||||
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才进入就绪状态,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。
|
||||
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才结算,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。
|
||||
|
||||
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Public operations express caller intent: `start` returns one ready owned
|
||||
* Public operations express caller intent: `start` returns one published owned
|
||||
* one-shot run, `startContinuable` establishes a durable continuable child, and
|
||||
* `followup` delivers later content without exposing whether the child is
|
||||
* resident. Continuable children never become a {@link SubagentRun}: the
|
||||
@@ -130,18 +130,18 @@ declare module 'cordis' {
|
||||
*/
|
||||
'subagent/provider-removed'(name: string): void
|
||||
/**
|
||||
* A provider established a ready child. For in-process providers,
|
||||
* A provider established a published child. For in-process providers,
|
||||
* `ctx.agents.get(info.id)` resolves during this notification.
|
||||
* Scope-filtered dispatch keys the carrier by the delegating parent, so a
|
||||
* parent-scoped listener observes only its own delegations. Paired with
|
||||
* `subagent/end`.
|
||||
* @param info - the provider and ready child identity.
|
||||
* @param info - the provider and published child identity.
|
||||
* @dshScopeScan unsupported
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
/**
|
||||
* A ready child settled. Scope-filtered dispatch uses the same delegating
|
||||
* A published child settled. Scope-filtered dispatch uses the same delegating
|
||||
* parent carrier as `subagent/start`, so the lifecycle pair reaches the
|
||||
* same scoped audience.
|
||||
* @param info - the run identity and terminal outcome.
|
||||
@@ -298,13 +298,14 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a ready child on the named provider. Capability and semantic
|
||||
* Establish a published child on the named provider. Capability and semantic
|
||||
* checks run before delegation. Provider ownership lasts until its promise
|
||||
* fulfills; a rejection therefore has no run for the caller to dispose and
|
||||
* emits no run lifecycle events.
|
||||
* emits no run lifecycle events. Post-publication turn and infrastructure
|
||||
* failures settle through the returned run.
|
||||
* @param name - the provider to use.
|
||||
* @param request - child label, prompt, parent, signal, and optional capabilities.
|
||||
* @returns the ready holder-owned run.
|
||||
* @returns the published holder-owned run.
|
||||
*/
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
|
||||
const provider = this.expectProvider(name)
|
||||
|
||||
@@ -43,7 +43,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
}
|
||||
if (eventName === 'subagent/start') {
|
||||
const info = args[0] as SubagentRunInfo
|
||||
// Provider availability is an admission-time relationship. A ready
|
||||
// Provider availability is an admission-time relationship. A published
|
||||
// one-shot run may outlive provider removal, and a cold-resumed Activation
|
||||
// carries durable provider provenance without dispatching through it.
|
||||
if (info.provider.length === 0 || String(info.runId).length === 0 || String(info.id).length === 0) {
|
||||
|
||||
@@ -106,7 +106,7 @@ export function createLifecycleEmitter(
|
||||
* @param emit - the contained lifecycle emitter.
|
||||
* @param provider - the provider that established the run.
|
||||
* @param parent - the delegating parent keying scoped dispatch.
|
||||
* @param run - the ready run whose settlement closes the pair.
|
||||
* @param run - the published run whose settlement closes the pair.
|
||||
* @returns the same run, unchanged.
|
||||
*/
|
||||
export function observeRun(
|
||||
|
||||
@@ -29,7 +29,7 @@ export function SubagentRunId(id: string): SubagentRunId {
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe-only identifying detail for a ready subagent run, carried by
|
||||
* Observe-only identifying detail for a published subagent run, carried by
|
||||
* `subagent/start`. One-shot runs and continuable Activation epochs share this
|
||||
* payload, so an observer sees the same vocabulary for both.
|
||||
*/
|
||||
@@ -107,8 +107,8 @@ export interface SubagentStartRequest {
|
||||
* Cancellation signal from the spawning context (the tool's `exec.signal`).
|
||||
* This is the canonical cancellation channel both before and after startup:
|
||||
* a provider rejects `start()` after cleaning partial resources when it
|
||||
* fires before publication, and cancels a published child when it fires
|
||||
* afterward.
|
||||
* fires before the run is published, and cancels the published run's
|
||||
* remaining turn work when it fires afterward.
|
||||
*/
|
||||
readonly signal: AbortSignal
|
||||
readonly agentOptions?: AgentOptions
|
||||
@@ -227,12 +227,13 @@ export interface SubagentResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE-SHOT child handle returned only after readiness. Consumers await
|
||||
* {@link result} and must always {@link dispose} to cancel remaining work and
|
||||
* reach quiescence. A run is one disposable foreground delegation with one
|
||||
* result; continuable conversations have no run — the continuation manager
|
||||
* holds their `AgentHandle` directly and orders every turn through the child's
|
||||
* own inbox.
|
||||
* ONE-SHOT child handle returned after publication. Prompt submission, turn
|
||||
* work, and infrastructure faults after that boundary belong to {@link result}.
|
||||
* Consumers await that result and must always {@link dispose} to cancel
|
||||
* remaining work and reach quiescence. A run is one disposable foreground
|
||||
* delegation with one result; continuable conversations have no run — the
|
||||
* continuation manager holds their `AgentHandle` directly and orders every
|
||||
* turn through the child's own inbox.
|
||||
*/
|
||||
export interface SubagentRun {
|
||||
/**
|
||||
@@ -279,13 +280,14 @@ export interface SubagentProvider {
|
||||
*/
|
||||
readonly inheritsParentContext: boolean
|
||||
/**
|
||||
* Establish a ONE-SHOT child and return its handle only after publication.
|
||||
* Establish a ONE-SHOT child and return its handle after publication.
|
||||
* The service has already validated that every requested start-time
|
||||
* capability is supported and resolved `request.descriptor`, so a
|
||||
* session-backed implementation appends that descriptor inside the child's
|
||||
* initial turn. If setup fails or `request.signal` aborts before fulfillment,
|
||||
* the provider owns and cleans all partial resources before this promise
|
||||
* rejects. Ownership transfers to the caller only on fulfillment.
|
||||
* initial turn. Before fulfillment, the provider owns setup and cleans any
|
||||
* unpublished partial resources before rejecting. Ownership transfers on
|
||||
* fulfillment; subsequent turn or infrastructure failure settles through
|
||||
* the returned run.
|
||||
*/
|
||||
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
|
||||
/**
|
||||
|
||||
@@ -43,9 +43,15 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) {
|
||||
function callTool(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
agent?: unknown,
|
||||
signal: AbortSignal = testToolSignal,
|
||||
) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
signal,
|
||||
callId: CallId(`call-${++calls}`),
|
||||
name,
|
||||
arguments: args,
|
||||
@@ -123,6 +129,17 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards the tool cancellation signal to child enumeration', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const signal = new AbortController().signal
|
||||
const listChildren = vi.spyOn(ctx.subagents, 'listChildren').mockResolvedValue([])
|
||||
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent, signal)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(listChildren).toHaveBeenCalledWith(parent.id, signal)
|
||||
})
|
||||
|
||||
it('lists a real settled continuable child and omits a real one-shot sibling', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('once'), textResponse('done')])
|
||||
const oneShot = await ctx.subagents.start('spawn', {
|
||||
|
||||
@@ -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