feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced read/kill/wait/list, attachSurface misconfiguration fence, reported-flag notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/ task_kill, completion-notice injection, background prompt habit). Producers opt in via their own enableRunInBackground config: bash (stream kind; seam slimmed to resolve/run/start returning a BashProcess handle, bash_output/bash_kill deleted) and subagent (final-output kind; done settles after run.dispose()). Owner disposal drains tasks through the new awaited ctx.agents.onCleanup seam in the loop's disposal chain. Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
This commit is contained in:
@@ -4,7 +4,7 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen
|
||||
|
||||
## Provider selection is config, not model-facing
|
||||
|
||||
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.
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt, run_in_background? }` — 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.
|
||||
|
||||
## The description states the provider's context contract
|
||||
|
||||
@@ -14,10 +14,13 @@ The tool description and the `prompt` parameter description are DERIVED from the
|
||||
|---|---|
|
||||
| `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`. |
|
||||
| `enableRunInBackground` | Expose `run_in_background` in this instance's schema (default `true`). Disabled, the parameter is absent entirely — delegation through this instance stays strictly synchronous. |
|
||||
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) |
|
||||
|
||||
## Lifecycle (synchronous collect)
|
||||
## Foreground 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.
|
||||
## Background delegation (a generic task)
|
||||
|
||||
`run_in_background: true` refuses an already-aborted `exec.signal`, starts the run, registers `{ kind: 'subagent', label: description, owner: parent, cancel, done }` with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and returns `started background subagent task <id>` — the parent keeps working and collects/stops the child through the generic `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`). The tool-call signal is deliberately NOT wired to the run after the id is returned; cancellation belongs to `task_kill` (its logged `reason` is forwarded to `run.cancel`) and the runtime's owner-disposal cleanup. The task is final-output-only (no incremental transcript — the child session remains the detailed trace), and its `done` settles only after `run.dispose()` (child quiescence), so owner disposal cannot resolve before the child is actually gone. Mapping (exported for tests): `runOutcome` — `completed` carries the final text as the task output; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as status-line detail — and `settleRun`, which disposes on both result paths and contains an infrastructure rejection as `failed`. A missing `ctx.tasks` fails the call loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`). See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
@@ -32,13 +33,15 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* EXACTLY ONE provider name (`Config.provider`). To expose more than one
|
||||
* transport, load the plugin more than once, each bound to a different provider
|
||||
* — there is no provider/type parameter in the model-facing schema. The model
|
||||
* sees only `{ description, prompt }`.
|
||||
* sees only `{ description, prompt }` (plus `run_in_background` when enabled).
|
||||
*
|
||||
* The tool DESCRIPTION is derived from the bound provider's context contract
|
||||
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
|
||||
@@ -20,13 +20,24 @@
|
||||
* provider goes away — so no load-order requirement exists and an HMR reload
|
||||
* of the backend re-derives the wording from the fresh provider.
|
||||
*
|
||||
* Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
|
||||
* FOREGROUND collection is synchronous: `execute` starts a run and awaits
|
||||
* `run.result` inside a `try/finally` that always disposes the run, so the
|
||||
* owned child agent/session is torn down on every path (success, error, abort)
|
||||
* and never leaks as a live idle child. A non-`completed` stop reason maps to an
|
||||
* `isError` tool result (by throwing) rather than returning partial output as
|
||||
* success.
|
||||
*
|
||||
* BACKGROUND delegation (`run_in_background: true`, exposed only when this
|
||||
* instance's `enableRunInBackground` config allows) is a generic background
|
||||
* TASK: the run is registered with `ctx.tasks` (kind `subagent`, final-output
|
||||
* only — the child session remains the detailed trace) and collected/stopped
|
||||
* through the generic `task_output`/`task_list`/`task_kill` tools. The
|
||||
* tool-call abort signal is deliberately NOT wired to a background child:
|
||||
* after the id is returned the parent step may end while the child works —
|
||||
* cancellation belongs to `task_kill` and the owner-disposal cleanup. The
|
||||
* task's `done` settles only after `run.dispose()` (child quiescence), which
|
||||
* is what makes owner-disposal cleanup an actual no-leak guarantee.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-subagent
|
||||
*/
|
||||
|
||||
@@ -36,6 +47,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
export const name = 'tool-subagent'
|
||||
export const inject = ['tools', 'subagents']
|
||||
@@ -52,6 +64,14 @@ export interface Config {
|
||||
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
|
||||
*/
|
||||
toolName?: string
|
||||
/**
|
||||
* Expose `run_in_background` in this instance's schema (default true).
|
||||
* Disabled, the parameter is absent entirely — schema and capability never
|
||||
* disagree; delegation through this instance stays strictly synchronous.
|
||||
* Backgrounding also needs the `ctx.tasks` runtime at call time; a missing
|
||||
* one fails the call loud with the load-these-packages message.
|
||||
*/
|
||||
enableRunInBackground?: boolean
|
||||
/**
|
||||
* Default per-child agent options (model) applied to every spawned child.
|
||||
* Omitted fields fall back to the child loop's own defaults. There is no
|
||||
@@ -64,6 +84,7 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
agentOptions: z.object({
|
||||
model: z.string(),
|
||||
}),
|
||||
@@ -102,6 +123,54 @@ function stopReasonError(result: SubagentResult): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a settled subagent result onto the generic task-outcome vocabulary:
|
||||
* `completed` carries the final text as the task's idempotent output;
|
||||
* `aborted` is the task-level `killed`; everything else — `error`,
|
||||
* `max-tokens`, `refusal`, and unknown merge-extensible reasons — is `failed`
|
||||
* with the reason as the status-line detail (partial output is NOT reported
|
||||
* as output, mirroring the synchronous path's report-the-reason rule).
|
||||
* Exported for tests.
|
||||
* @param result - the child's terminal result.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function runOutcome(result: SubagentResult): TaskOutcome { switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return { status: 'completed', output: outputText(result.output) }
|
||||
case 'aborted':
|
||||
return { status: 'killed' }
|
||||
case 'error':
|
||||
case 'max-tokens':
|
||||
case 'refusal':
|
||||
return { status: 'failed', detail: result.stopReason }
|
||||
// Merge-extensible union: an unknown terminal reason is a failure with
|
||||
// the raw reason as detail, never partial output as success.
|
||||
default:
|
||||
return { status: 'failed', detail: String(result.stopReason) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a background run at QUIESCENCE: await the child's result, ALWAYS
|
||||
* dispose the run (the owned child agent/session is released on every path),
|
||||
* and only then report the mapped outcome — so the task registry's `done`,
|
||||
* and therefore owner-disposal cleanup, cannot resolve before the child is
|
||||
* actually gone. A rejected `run.result` (infrastructure fault — no
|
||||
* SubagentResult exists) reports `failed` with the error as detail rather
|
||||
* than rejecting the producer contract. Exported for tests.
|
||||
* @param run - the live background run to settle and release.
|
||||
* @returns the task outcome, after the run's resources are released.
|
||||
*/
|
||||
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
|
||||
try {
|
||||
return runOutcome(await run.result)
|
||||
} catch (error: unknown) {
|
||||
return { status: 'failed', detail: String(error) }
|
||||
} finally {
|
||||
await run.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
|
||||
* A fresh child needs a standalone prompt; a forked child already sees the
|
||||
@@ -150,9 +219,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
let disposeTool: (() => void) | undefined
|
||||
const mount = (provider: SubagentProvider): void => {
|
||||
const wording = providerWording(provider.inheritsParentContext)
|
||||
const backgroundEnabled = config.enableRunInBackground !== false
|
||||
disposeTool = ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'subagent',
|
||||
description: wording.description,
|
||||
description: wording.description + (backgroundEnabled
|
||||
? ' Set `run_in_background: true` to get a task id immediately and keep working; collect the final answer with `task_output` (wait: true when you are blocked on it) and stop it with `task_kill`.'
|
||||
: ''),
|
||||
parameters: {
|
||||
description: {
|
||||
type: 'string',
|
||||
@@ -164,6 +236,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
required: true,
|
||||
description: wording.promptDescription,
|
||||
},
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: {
|
||||
type: 'boolean' as const,
|
||||
description: 'Run the subagent as a background task and return a task id immediately (collect with task_output, stop with task_kill).',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const parent = exec.agent
|
||||
@@ -174,6 +252,47 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
|
||||
if (args.run_in_background === true) {
|
||||
// The generic runtime owns everything task-shaped; without it a task
|
||||
// id would be uncollectable — fail loud with the fix, not a dangle.
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// A step already cancelled must not spawn a child. After the id is
|
||||
// returned the tool-call signal is deliberately NOT wired to the run
|
||||
// (the child outlives this step; cancellation belongs to task_kill
|
||||
// and owner-disposal cleanup), so the request carries NO signal.
|
||||
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
|
||||
const run = ctx.subagents.start(config.provider, {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
})
|
||||
const done = settleRun(run)
|
||||
let id: string
|
||||
try {
|
||||
id = tasks.register({
|
||||
kind: 'subagent',
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
cancel: (reason) => { run.cancel(reason ?? 'background subagent task killed') },
|
||||
done,
|
||||
// No readOutput: a subagent task is final-output-only — the child
|
||||
// session remains the detailed trace.
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A failed registration must not leak the just-started child: the
|
||||
// model never received an id, so nothing could ever task_kill it.
|
||||
// Cancel, await `done` (which settles only after run.dispose() —
|
||||
// child quiescence), then fail the call with the real cause.
|
||||
run.cancel('background task registration failed')
|
||||
await done
|
||||
throw error
|
||||
}
|
||||
return [{ type: 'text', text: `started background subagent task ${id}` }]
|
||||
}
|
||||
|
||||
const request: SubagentStartRequest = {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
|
||||
@@ -5,9 +5,13 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import * as mock from '@deepseek-ai/dsh-subagent-mock'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { runOutcome, settleRun } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
|
||||
@@ -60,12 +64,21 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(text(result)).toBe('child says hi')
|
||||
})
|
||||
|
||||
it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
|
||||
it('exposes description + prompt + run_in_background to the model (no provider/type parameter)', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
|
||||
expect(schema).toBeDefined()
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background'])
|
||||
expect(schema!.description).toContain('task_output')
|
||||
})
|
||||
|
||||
it('omits run_in_background entirely when the instance disables it (schema and capability never disagree)', async () => {
|
||||
const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
|
||||
expect(schema!.description).not.toContain('task_output')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -452,3 +465,180 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-tool-subagent background mode', () => {
|
||||
/** A parent agent carrying a real session token, registered in ctx.agents (owner-cleanup wiring requires a live registry entry). */
|
||||
function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
const agent = { id: AgentId(`agent-${sessionId}`), inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
async function backgroundSetup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
|
||||
const ctx = await setup(toolConfig, mockConfig)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('returns a task id immediately and the answer is collected through task_output', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
|
||||
const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
|
||||
expect(start.isError).toBe(false)
|
||||
expect(text(start)).toBe('started background subagent task subagent-1')
|
||||
|
||||
const collected = await ctx.tools.execute({
|
||||
callId: CallId('collect-1'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(collected)).toBe('background answer\n[status: completed]')
|
||||
|
||||
// Final-output reads are idempotent (not consumed).
|
||||
const again = await ctx.tools.execute({
|
||||
callId: CallId('collect-2'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1' },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(again)).toBe('background answer\n[status: completed]')
|
||||
})
|
||||
|
||||
it('fails loud when the tasks runtime is not loaded', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('refuses to start when the tool signal is already aborted', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('subagent delegation aborted')
|
||||
})
|
||||
|
||||
it('forwards task_kill reasons to run.cancel (and defaults one when absent)', async () => {
|
||||
// A provider whose runs settle only on cancel — the mock settles on a
|
||||
// microtask, too fast to observe a LIVE kill through the real tools.
|
||||
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const cancels: (string | undefined)[] = []
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'hanging',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
|
||||
return {
|
||||
id: AgentId(`hang-${cancels.length}`),
|
||||
result: new Promise((res) => { settle = res }),
|
||||
cancel(reason?: string) { cancels.push(reason); settle({ output: [], stopReason: 'aborted' }) },
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
})
|
||||
// Direct apply (schema bypass): schemastery would default agentOptions to
|
||||
// an (truthy) empty object — the raw config exercises the omitted branch
|
||||
// on the background start request.
|
||||
tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' })
|
||||
|
||||
const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
expect(text(startOne)).toBe('started background subagent task subagent-1')
|
||||
expect(text(startTwo)).toBe('started background subagent task subagent-2')
|
||||
|
||||
const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
|
||||
const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
|
||||
expect(text(withReason)).toBe('requested cancellation of task subagent-1')
|
||||
expect(text(withoutReason)).toBe('requested cancellation of task subagent-2')
|
||||
expect(cancels).toEqual(['superseded', 'background subagent task killed'])
|
||||
|
||||
// The aborted children settle as killed tasks.
|
||||
const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
|
||||
expect(text(killed)).toBe('(no new output)\n[status: killed]')
|
||||
})
|
||||
|
||||
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
|
||||
const output = [{ type: 'text' as const, text: 'partial' }]
|
||||
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
|
||||
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
|
||||
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
|
||||
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
|
||||
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
|
||||
// Merge-extensible: an unknown reason is failed-with-detail, never success.
|
||||
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
|
||||
})
|
||||
|
||||
it('settleRun disposes the run before reporting, on both result paths', async () => {
|
||||
const order: string[] = []
|
||||
const completed = await settleRun({
|
||||
id: AgentId('child-1'),
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
order.push('reported')
|
||||
expect(completed).toEqual({ status: 'completed', output: 'ok' })
|
||||
expect(order).toEqual(['dispose', 'reported'])
|
||||
|
||||
// An infrastructure rejection still disposes and reports failed.
|
||||
let disposed = false
|
||||
const failed = await settleRun({
|
||||
id: AgentId('child-2'),
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
cancel() {},
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background registration failure (no orphaned child)', () => {
|
||||
it('cancels and disposes the just-started run when register() throws', async () => {
|
||||
// TaskService is loaded but NO control surface is attached, so
|
||||
// ctx.tasks.register throws AFTER the provider run already started.
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(parent)
|
||||
|
||||
const events: string[] = []
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'probe',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let settle!: (value: { output: never[]; stopReason: 'aborted' }) => void
|
||||
return {
|
||||
id: AgentId('probe-child'),
|
||||
result: new Promise((res) => { settle = res }),
|
||||
cancel(reason?: string) { events.push(`cancel:${reason}`); settle({ output: [], stopReason: 'aborted' }) },
|
||||
dispose() { events.push('dispose'); return Promise.resolve() },
|
||||
}
|
||||
},
|
||||
})
|
||||
tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('probe-1'),
|
||||
name: 'subagent_probe',
|
||||
arguments: { description: 'd', prompt: 'p', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// The child was cancelled AND disposed before the call settled — the
|
||||
// model never got an id, so nothing else could ever collect or kill it.
|
||||
expect(events).toEqual(['cancel:background task registration failed', 'dispose'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user