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