diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index c4deb098c4..76844279f6 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -94,6 +94,10 @@ flowchart LR
cfg --> plugin_tui_subagent_spawn
plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"]
cfg --> plugin_tui_subagent_fork
+ plugin_tui_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"]
+ cfg --> plugin_tui_subagent_control
+ plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"]
+ cfg --> plugin_tui_tool_subagent_control
plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent
plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"]
@@ -185,6 +189,8 @@ flowchart LR
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
+| `subagent-control` | `@deepseek-ai/dsh-subagent-control` |
+| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml
index df7ed94258..4982a521a7 100644
--- a/apps/cli/config/base.cordis.yml
+++ b/apps/cli/config/base.cordis.yml
@@ -257,6 +257,15 @@
config:
providerName: fork
+# Continuable background children: the control service owns durable child ids
+# and Task-backed activations; the control tool registers the one global
+# `send_message` shared by both delegation tools.
+- id: subagent-control
+ name: '@deepseek-ai/dsh-subagent-control'
+
+- id: tool-subagent-control
+ name: '@deepseek-ai/dsh-tool-subagent-control'
+
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 58368f04d9..4cfdf29725 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -101,6 +101,7 @@
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-storage-json": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
+ "@deepseek-ai/dsh-subagent-control": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
@@ -120,6 +121,7 @@
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
+ "@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 0e83259ea2..9028df3814 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1988,7 +1988,7 @@ sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMes
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md)
-Source: [`packages/subagent/subagent-control/src/index.ts:152`](../../packages/subagent/subagent-control/src/index.ts)
+Source: [`packages/subagent/subagent-control/src/index.ts:156`](../../packages/subagent/subagent-control/src/index.ts)
## `ctx.subagents` — `SubagentService`
diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md
index 38774195e1..ecf343a264 100644
--- a/examples/headless-agent/composition.md
+++ b/examples/headless-agent/composition.md
@@ -37,6 +37,10 @@ flowchart LR
cfg --> plugin_headless_subagent_spawn
plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"]
cfg --> plugin_headless_subagent_fork
+ plugin_headless_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"]
+ cfg --> plugin_headless_subagent_control
+ plugin_headless_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"]
+ cfg --> plugin_headless_tool_subagent_control
plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_headless_tool_subagent
plugin_headless_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"]
@@ -70,6 +74,8 @@ flowchart LR
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
+| `subagent-control` | `@deepseek-ai/dsh-subagent-control` |
+| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml
index 3a74bd4976..73673aee8d 100644
--- a/examples/headless-agent/cordis.yml
+++ b/examples/headless-agent/cordis.yml
@@ -86,6 +86,15 @@
config:
providerName: fork
+# Continuable background children: the control service owns durable child ids
+# and Task-backed activations; the control tool registers the one global
+# `send_message` shared by both delegation tools.
+- id: subagent-control
+ name: '@deepseek-ai/dsh-subagent-control'
+
+- id: tool-subagent-control
+ name: '@deepseek-ai/dsh-tool-subagent-control'
+
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts
index 09fe8eb5b7..31b4fc77c3 100644
--- a/packages/sdk/helper/src/features/builtin/index.ts
+++ b/packages/sdk/helper/src/features/builtin/index.ts
@@ -209,7 +209,13 @@ config:
id: 'subagent',
summary: 'Delegate work to child agents',
mode: 'multiple',
- baseResources: [{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }],
+ // The control pair rides every resumable in-process option: background
+ // delegation on spawn/fork is continuable and advertises send_message.
+ baseResources: [
+ { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' },
+ { kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' },
+ { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' },
+ ],
options: [
{
id: 'spawn',
diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts
index fa1974eb22..b1aa00ab02 100644
--- a/packages/subagent/subagent-control/src/index.ts
+++ b/packages/subagent/subagent-control/src/index.ts
@@ -86,6 +86,10 @@ interface ActiveActivation {
taskId: TaskId | undefined
/** Filled when the provider publishes; `undefined` while starting or resuming. */
run: SubagentRun | undefined
+ /** The activation-owned cancellation authority, created before any await. */
+ readonly controller: AbortController
+ /** The producer's settlement (run disposed, outcome produced); assigned when the Task registers. */
+ done: Promise | undefined
/** Resolved by the completion listener when the Task's terminal snapshot is recorded. */
readonly terminal: PromiseWithResolvers
}
@@ -164,7 +168,21 @@ export class SubagentControlService extends Service {
if (activation.taskId === snapshot.id) activation.terminal.resolve()
}
})
- ctx.effect(() => () => { this.activations.clear() }, 'subagentControl.activations()')
+ // TaskService deliberately keeps producer Tasks alive across a
+ // control-surface or producer reload, so this service's disposal must not
+ // strand the activations it can no longer route to: cancel each one and
+ // await producer settlement (run disposal) before releasing the map. The
+ // effect-scoped onTaskDone listener above is already gone by then, so
+ // terminal publication is resolved here instead of waiting forever.
+ ctx.effect(() => async () => {
+ const active = [...this.activations.values()]
+ this.activations.clear()
+ for (const activation of active) {
+ activation.controller.abort('subagent control service disposed')
+ activation.terminal.resolve()
+ }
+ await Promise.allSettled(active.map(activation => activation.done ?? Promise.resolve()))
+ }, 'subagentControl.activations()')
}
/**
@@ -368,6 +386,8 @@ export class SubagentControlService extends Service {
const activation: ActiveActivation = {
taskId: undefined,
run: undefined,
+ controller: new AbortController(),
+ done: undefined,
terminal: Promise.withResolvers(),
}
this.activations.set(childId, activation)
@@ -378,21 +398,21 @@ export class SubagentControlService extends Service {
label,
owner,
run: (): TaskHooks => {
- const controller = new AbortController()
const done = (async (): Promise => {
try {
- const run = await begin(controller.signal)
+ const run = await begin(activation.controller.signal)
activation.run = run
return await settleRun(run)
} catch (error: unknown) {
// A pre-publication abort rejects only after the provider's
// creation transaction rolled back to quiescence, so recording
// `killed` here honors the settlement-after-rollback contract.
- return controller.signal.aborted
+ return activation.controller.signal.aborted
? { status: 'killed' }
: { status: 'failed', detail: String(error) }
}
})()
+ activation.done = done
void Promise.allSettled([done, activation.terminal.promise]).then(() => {
/* v8 ignore else -- service teardown clears the map while a producer is still settling. */
if (this.activations.get(childId) === activation) this.activations.delete(childId)
@@ -401,7 +421,7 @@ export class SubagentControlService extends Service {
cancel: (reason?: string) => {
// Cancellation targets the whole activation: every message that
// joined this turn shares the `killed` outcome.
- controller.abort(reason ?? 'subagent activation killed')
+ activation.controller.abort(reason ?? 'subagent activation killed')
},
done,
// No readOutput: the child session owns intermediate detail.
diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts
index c76cc5ad41..391b94a30a 100644
--- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts
+++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts
@@ -482,6 +482,51 @@ describe('SubagentControlService.sendMessage', () => {
})
})
+describe('service disposal with live activations', () => {
+ it('cancels and settles a starting activation on service disposal instead of stranding it', async () => {
+ const ctx = new Context()
+ await mountAgentLoopTestDependencies(ctx)
+ const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-hmr-'))
+ roots.push(root)
+ await ctx.plugin(JsonlSessionPersistence, { root })
+ await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SubagentService)
+ await ctx.plugin(TaskService)
+ await ctx.plugin(ToolTasks, {})
+ // A provider that stays pending until its signal aborts, so the activation
+ // is observably mid-start when the control service is disposed.
+ let sawAbort = false
+ ctx.subagents.registerProvider({
+ name: 'pending',
+ capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
+ inheritsParentContext: false,
+ start: request => new Promise((_resolve, reject) => {
+ request.signal.addEventListener('abort', () => {
+ sawAbort = true
+ reject(new Error('startup aborted'))
+ }, { once: true })
+ }),
+ resume: () => Promise.reject(new Error('unreachable')),
+ })
+ const controlFiber = await ctx.plugin(SubagentControlService)
+ ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
+ const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
+
+ const control = ctx.get('subagentControl')!
+ const started = control.startContinuable({
+ provider: 'pending',
+ label: 'will be interrupted',
+ request: { prompt: message('go'), parent },
+ })
+ // TaskService keeps the producer Task; the disposing control service must
+ // cancel its activation and await settlement rather than strand it.
+ await controlFiber.dispose()
+ expect(sawAbort).toBe(true)
+ const snapshot = await waitTerminal(ctx, started.taskId, parent)
+ expect(snapshot.status).toBe('killed')
+ })
+})
+
describe('outcome mapping helpers', () => {
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
const output = [{ type: 'text' as const, text: 'partial' }]
diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts
index 51e97f10d1..560e0cb20b 100644
--- a/packages/subagent/tool-subagent/src/index.ts
+++ b/packages/subagent/tool-subagent/src/index.ts
@@ -288,8 +288,10 @@ export function apply(ctx: Context, config: Config): void {
// The schema above tells the model to follow up with
// `send_message`; starting a durable child the model cannot
// continue would make that advertisement false. Sibling load order
- // is undetermined at mount, so the check lives at the operation.
- if (ctx.tools.get('send_message') === undefined) {
+ // is undetermined at mount, so the check lives at the operation,
+ // and it resolves in the CALLER's scope so a restriction that
+ // removes send_message from this agent also blocks the start.
+ if (ctx.tools.get('send_message', parent) === undefined) {
throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)')
}
// The control service owns the durable child id, descriptor
diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
index 9c3ddb461f..f631133970 100644
--- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
+++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
@@ -903,6 +903,22 @@ describe('dsh-tool-subagent continuable background mode', () => {
// Nothing was started: no Task exists for the parent.
expect(ctx.tasks.list(parent)).toEqual([])
})
+
+ it('resolves send_message availability in the CALLER scope, not the global registry', async () => {
+ // A scoped restriction that keeps this delegation tool but removes
+ // send_message means this agent cannot execute the promised follow-up;
+ // the availability check must see the caller's surface.
+ const { ctx, parent } = await continuableSetup()
+ parent.ctx.tools.restrict({ deny: ['send_message'] })
+ const result = await callSubagent(
+ ctx,
+ { description: 'd', prompt: 'p', run_in_background: true },
+ { agent: parent },
+ )
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control')
+ expect(ctx.tasks.list(parent)).toEqual([])
+ })
})
describe('background preflight failure (no orphaned child, by construction)', () => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 534f592f65..80d30aeae9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -384,6 +384,9 @@ importers:
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../packages/subagent/subagent
+ '@deepseek-ai/dsh-subagent-control':
+ specifier: workspace:^
+ version: link:../../packages/subagent/subagent-control
'@deepseek-ai/dsh-subagent-fork':
specifier: workspace:^
version: link:../../packages/subagent/subagent-fork
@@ -441,6 +444,9 @@ importers:
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:^
version: link:../../packages/subagent/tool-subagent
+ '@deepseek-ai/dsh-tool-subagent-control':
+ specifier: workspace:^
+ version: link:../../packages/subagent/tool-subagent-control
'@deepseek-ai/dsh-tool-tasks':
specifier: workspace:^
version: link:../../packages/tasks/tool-tasks