refactor(subagent): unify async readiness and cancellation
This commit is contained in:
@@ -1,28 +1,28 @@
|
||||
# @deepseek-ai/dsh-tool-subagent
|
||||
|
||||
The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees.
|
||||
The `subagent` tool lets the model delegate one self-contained task and collect the child's final output. It is a thin consumer of `ctx.subagents`; changing the configured provider changes the transport without changing the model-facing execution contract.
|
||||
|
||||
## Provider selection is config, not model-facing
|
||||
## Provider selection
|
||||
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
|
||||
Each plugin instance binds to exactly one provider. The model sees `{ description, prompt }`, not a provider selector. To expose multiple transports, load the plugin multiple times with distinct `toolName` values.
|
||||
|
||||
## The description states the provider's conversation-history descriptor
|
||||
The description is derived from `provider.inheritsParentContext`: spawn and ACP tell the model to provide a standalone prompt, while fork says the child already sees completed conversation turns. The plugin follows `subagent/provider-added` and `subagent/provider-removed`, so concurrent Cordis plugin loading does not create a registration-order dependency.
|
||||
|
||||
The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-conversation provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), while fork tells the model the child is seeded with the conversation's completed turns and its prompt should state only what is new. The descriptor concerns conversation history only, not tool or authority inheritance. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling plugins concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes).
|
||||
## Lifecycle
|
||||
|
||||
| Config key | Meaning |
|
||||
`execute` passes the tool execution's abort signal directly as the required `SubagentStartRequest.signal`, awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The same signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort.
|
||||
|
||||
A non-`completed` stop reason becomes an `isError` tool result; partial child output is never reported as success. The current tool blocks the parent turn until collection finishes; background and polling modes are deferred.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
|
||||
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
|
||||
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. |
|
||||
| `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. |
|
||||
| `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. |
|
||||
| `maxDepth` | Maximum absolute delegation-tree depth for every child this tool starts; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. |
|
||||
| `provider` | Required `ctx.subagents` provider name. |
|
||||
| `toolName` | Model-facing tool name (default `subagent`). Must be unique per plugin instance. |
|
||||
| `agentOptions` | Default child agent options, currently including `model`. |
|
||||
| `persona` | Per-child persona; requires provider `persona` capability. |
|
||||
| `toolFilter` | Per-child global-tool restriction; requires provider `toolFilter` capability. |
|
||||
| `maxDepth` | Absolute delegation-depth cap; requires provider `depthLimit` capability. |
|
||||
|
||||
`toolFilter` uses [`ToolRegistry.restrict()`](../../core/tools/README.md)'s live global-view semantics and is not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
|
||||
## Lifecycle (synchronous collect)
|
||||
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
`toolFilter` changes the child's visible global tool layer; it is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
|
||||
@@ -242,24 +242,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const request: SubagentStartRequest = {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
signal: exec.signal ?? new AbortController().signal,
|
||||
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
|
||||
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
|
||||
}
|
||||
|
||||
const run: SubagentRun = ctx.subagents.start(config.provider, request)
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is
|
||||
// aborted while the child is in flight, cancel the child too.
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before this
|
||||
// line, so a step cancelled before the tool ran would never reach the
|
||||
// child. Cancel explicitly in that case — the bridge must honor an
|
||||
// already-aborted signal, not lean on each provider re-checking it.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
@@ -271,7 +261,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
return [{ type: 'text', text: outputText(result.output) }]
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
// Always reach child quiescence — never leak a live idle child/session.
|
||||
await run.dispose()
|
||||
}
|
||||
|
||||
@@ -113,11 +113,9 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'weird',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('weird-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
})
|
||||
@@ -140,13 +138,11 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'capture',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -171,13 +167,11 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'bare',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('bare-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -302,11 +296,9 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('spy-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
@@ -326,11 +318,9 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('spy-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
@@ -341,7 +331,7 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(disposed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bridges the tool abort signal to run.cancel()', async () => {
|
||||
it('passes the tool abort signal as the provider cancellation channel', async () => {
|
||||
const cancelled = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -351,17 +341,17 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
start: async (request) => {
|
||||
if (request.signal.aborted) throw new Error('start aborted')
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
|
||||
request.signal.addEventListener('abort', () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
}, { once: true })
|
||||
return {
|
||||
id: AgentId('spy-child'),
|
||||
started: Promise.resolve(),
|
||||
result,
|
||||
cancel: () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -370,12 +360,7 @@ describe('dsh-tool-subagent', () => {
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
// Abort AFTER the tool body has had a chance to register its abort listener
|
||||
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the
|
||||
// body runs, so the listener is not registered synchronously). A few
|
||||
// microtask turns let execute() reach `addEventListener('abort')`, so this
|
||||
// exercises the LIVE onAbort bridge — distinct from the already-aborted
|
||||
// sync path the next test covers.
|
||||
// Let provider.start install its listener before aborting.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
@@ -384,13 +369,8 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
|
||||
// `addEventListener('abort')` does not fire for a signal already aborted
|
||||
// before the listener is added, so a step cancelled before the tool ran
|
||||
// would never reach the child unless the bridge re-checks `signal.aborted`.
|
||||
// A provider that leans only on the abort EVENT (this spy never inspects
|
||||
// request.signal) proves the bridge itself must cancel.
|
||||
const cancelled = vi.fn()
|
||||
it('passes an already-aborted signal so provider startup rejects', async () => {
|
||||
const sawAborted = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -399,19 +379,9 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
|
||||
return {
|
||||
id: AgentId('spy-child'),
|
||||
started: Promise.resolve(),
|
||||
result,
|
||||
cancel: () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: async () => {},
|
||||
}
|
||||
start: async (request) => {
|
||||
if (request.signal.aborted) sawAborted()
|
||||
throw new Error('start aborted')
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'spy' })
|
||||
@@ -419,7 +389,7 @@ describe('dsh-tool-subagent', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort() // already aborted BEFORE the tool runs
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
expect(cancelled).toHaveBeenCalledTimes(1)
|
||||
expect(sawAborted).toHaveBeenCalledTimes(1)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
@@ -469,13 +439,11 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'capture2',
|
||||
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture2-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -519,7 +487,7 @@ describe('dsh-tool-subagent', () => {
|
||||
})
|
||||
|
||||
it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
|
||||
let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined
|
||||
let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -528,13 +496,11 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'capture3',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture3-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -559,13 +525,11 @@ describe('dsh-tool-subagent', () => {
|
||||
name: 'capture4',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture4-child'),
|
||||
started: Promise.resolve(),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user