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:
Yichen Jiang
2026-07-09 21:22:54 +08:00
parent e7e382f9d1
commit 184e164091
83 changed files with 3909 additions and 1627 deletions
+122 -3
View File
@@ -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,