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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user