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

10
packages/tasks/README.md Normal file
View File

@@ -0,0 +1,10 @@
# tasks/ — background task capability family
The shared background-task runtime: ONE home for task ids, owner isolation, polling, cancellation, wait, and completion notification, so bash, subagents, and every future long-running tool expose the same model-facing habit instead of cloning a private task protocol each. Rationale and the full design: [the background-task-runtime RFC](../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
| Package | ctx key | Role |
|---|---|---|
| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry service: branded `<kind>-N` ids, owner-fenced read/kill/wait/list, settlement bookkeeping, the awaited owner-cleanup path, and the `attachSurface` misconfiguration fence |
| [`tool-tasks`](tool-tasks/README.md) (`@deepseek-ai/dsh-tool-tasks`) | — | The model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection, and the background-habit prompt section |
The split is the state/surface boundary: the registry holds task state (an HMR reload of any tool plugin never orphans or kills a running task), while the tool surface is stateless presentation. Producers (`dsh-tool-bash`, `dsh-tool-subagent`) register running work via `ctx.tasks.register` and keep their own execution concerns; whether a producer exposes `run_in_background` is that producer's own `enableRunInBackground` config, never rewritten by this family.

View File

@@ -0,0 +1,25 @@
# @deepseek-ai/dsh-tasks
The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service (no interface/implementation split — one sensible in-process implementation exists; a durable job backend would own that extraction) that gives every long-running tool the same ids, isolation, and lifecycle.
## Service API
- `register(registration): TaskId` — a producer hands over running work: `kind` (also the id prefix), `label`, optional `owner: Agent`, `cancel(reason?)`, `done: Promise<TaskOutcome>` (settles at QUIESCENCE, never rejects), optional `readOutput()` (stream kinds; absence = final-output-only). Throws while no control surface is attached — the loud fence against a deployment exposing `run_in_background` with no way to collect or stop the work — and is ATOMIC: a failed registration mutates nothing (no stored task, no counter bump, no owner-cleanup bookkeeping), so producers can reliably cancel their just-started work and rethrow.
- `get(id, caller?)` / `list(caller?)` — non-consuming snapshots; `list` returns only caller-owned plus unowned tasks (a global listing would leak foreign labels).
- `read(id, caller?): TaskRead` — stream kinds consume the per-task cursor (v1's single intended reader is the owning model — a non-consuming multi-reader surface would be a cursor/snapshot API extension, not a `read` change); final kinds read the terminal output idempotently.
- `kill(id, caller?, reason?)``'requested'` (live task: producer `cancel` runs first — a throw fails the kill loud and leaves the task untouched — then `stopping`) or `'already-terminal'`. Every successful kill marks the task `reported` (the killer saw the end → completion notice suppressed).
- `wait(id, timeoutMs, caller?, signal?)` — resolves with the terminal snapshot (marked `reported`), or the live snapshot at timeout; an aborted signal rejects the WAIT only.
- `onTaskDone(listener)` — exactly once per task with the terminal snapshot; effect-scoped, per-listener containment, silent after service disposal.
- `attachSurface(name)` — declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped.
Every read/kill/wait/get compares the task's owner session (`owner.session.header.id`) with the caller's and rejects a foreign one — ids are predictable (`bash-1`), so the fence, not id secrecy, is the isolation boundary.
## Lifecycle
- Registrations are NOT effect-scoped to the registering fiber: tasks belong to their owning agent + producing backend, so producer/surface HMR reloads never touch them.
- An owned task attaches (once per owner) an awaited cleanup via `ctx.agents.onCleanup`: on the owner's disposal the registry cancels its live tasks, awaits each `done`, and drops the snapshots — `AgentHandle.dispose()` resolves only after quiescence.
- Service disposal closes the listener registry first (late teardown kills stay silent), cancels every live task with containment, and awaits settlement.
## Non-goals (v1)
Durable/cross-restart tasks, non-consuming observation cursors, and foreground→background promotion are deliberate deferrals — see the [runtime RFC](../../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) § Alternatives.

View File

@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-tasks",
"description": "Background task registry (ctx.tasks) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,457 @@
/**
* The background task registry (`ctx.tasks`): ONE home for the semantics every
* long-running tool needs — branded task ids, owner-scoped isolation, status
* snapshots, incremental/final output reads, cancellation, wait-for-terminal,
* completion listeners, and the awaited owner-cleanup path. Producers
* (`dsh-tool-bash` background commands, `dsh-tool-subagent` background
* delegations, future long-running tools) register running work via
* {@link TaskService.register} and keep their own execution concerns; the
* model-facing control surface (`@deepseek-ai/dsh-tool-tasks`) drives the
* generic read/list/kill/wait operations.
*
* A CONCRETE service, not an interface/implementation seam pair: there is one
* sensible in-process implementation today, and the capability-seam convention
* says not to split preemptively (see the background-task-runtime RFC).
*
* Cross-session isolation lives IN the registry: task ids are runtime-global
* and predictable (`bash-1`, `subagent-1`), so every read/kill/wait compares
* the task's owner session against the caller and rejects a foreign one —
* every surface gets the fence for free instead of re-implementing it.
*
* Task registrations are NOT effect-scoped to the registering fiber: a task
* belongs to its owning agent and producing backend, not to the tool plugin
* whose call started it, so an HMR reload of a producer or of the control
* surface never orphans or kills a running task. The registry's own disposal
* cancels every live task and awaits settlement — no orphans survive
* `fiber.dispose()`.
*
* @module @deepseek-ai/dsh-tasks
*/
import { Context, Service } from 'cordis'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { TaskId } from './types.ts'
import type { TaskDoneListener, TaskOutcome, TaskRead, TaskRegistration, TaskSnapshot, TaskStatus } from './types.ts'
export { TaskId } from './types.ts'
export type {
TaskDoneListener,
TaskOutcome,
TaskRead,
TaskRegistration,
TaskSnapshot,
TaskStatus,
} from './types.ts'
declare module 'cordis' {
interface Context {
tasks: TaskService
}
}
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
interface TrackedTask {
id: TaskId
kind: string
label: string
/** The owner's session id (`session.header.id`), or undefined for an unowned task. */
ownerSession: string | undefined
cancel: (reason?: string) => void
readOutput: (() => string) | undefined
status: TaskStatus
detail: string | undefined
output: string | undefined
startedAt: number
finishedAt: number | undefined
reported: boolean
/** Resolves once the terminal snapshot is recorded and listeners notified. */
settled: Promise<void>
/** Resolver for {@link settled} (called exactly once, by {@link TaskService.settle}). */
markSettled: () => void
/** Live {@link TaskService.wait} calls — a settlement with waiters marks the task reported. */
waiters: number
}
/** True for the three terminal {@link TaskStatus} values. */
function isTerminal(status: TaskStatus): boolean {
return status === 'completed' || status === 'killed' || status === 'failed'
}
/**
* The `tasks` service: the runtime-global background task registry. See the
* module doc for the ownership, isolation, and lifecycle contracts.
*/
export class TaskService extends Service {
private store = new Map<TaskId, TrackedTask>()
private counters = new Map<string, number>()
private surfaces = new Set<symbol>()
private listeners = new Set<TaskDoneListener>()
private listenersClosed = false
/** Owner agents that already have this registry's cleanup attached. */
private ownerCleanups = new Set<AgentId>()
/**
* The service's OWN construction-time context, for work that outlives the
* calling fiber: detached settlement continuations (logging), and the
* owner-cleanup registration on `ctx.agents` (which must survive a producer
* plugin's HMR reload, unlike the caller-fiber-scoped effects in
* {@link onTaskDone}/{@link attachSurface}).
*/
private readonly selfCtx: Context
constructor(ctx: Context) {
super(ctx, 'tasks')
this.selfCtx = ctx
ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
}
/**
* Register running background work and receive its task id (`<kind>-N`,
* per-kind counter). The registry attaches ONE continuation to
* `registration.done` that records the terminal snapshot, notifies
* {@link onTaskDone} listeners, and releases waiters; an owned task also
* gets the owner's awaited disposal cleanup attached (once per owner agent)
* through `ctx.agents.onCleanup`. Throws when no control surface is
* attached ({@link attachSurface}) — a task the model could never read or
* stop must fail loud at the start, not dangle — and for an empty
* kind/label. ATOMIC: a throw mutates no registry state, so a producer can
* cancel its just-started work and rethrow without leaving a stored task
* behind.
* @param registration - the producer's task contract (see {@link TaskRegistration}).
* @returns the registry-issued task id.
*/
register(registration: TaskRegistration): TaskId {
if (this.surfaces.size === 0) {
throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
}
if (registration.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
if (registration.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
// EVERYTHING that can throw runs before any mutation (counter, store):
// a failed registration must leave the registry exactly as it was — no
// stored-but-unreturned task the producer could never read or kill.
if (registration.owner !== undefined) this.ensureOwnerCleanup(registration.owner)
const count = (this.counters.get(registration.kind) ?? 0) + 1
this.counters.set(registration.kind, count)
const id = TaskId(`${registration.kind}-${count}`)
let markSettled!: () => void
const settled = new Promise<void>((resolve) => { markSettled = resolve })
const task: TrackedTask = {
id,
kind: registration.kind,
label: registration.label,
ownerSession: registration.owner?.session.header.id,
cancel: registration.cancel.bind(registration),
readOutput: registration.readOutput?.bind(registration),
status: 'running',
detail: undefined,
output: undefined,
startedAt: Date.now(),
finishedAt: undefined,
reported: false,
settled,
markSettled,
waiters: 0,
}
this.store.set(id, task)
void registration.done.then(
(outcome) => { this.settle(task, outcome) },
(error: unknown) => {
// Producer contract violation (`done` must never reject) — contained
// as a failed outcome so waiters, cleanup, and disposal never hang.
this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
this.settle(task, { status: 'failed', detail: String(error) })
},
)
return id
}
/**
* The caller-VISIBLE tasks (owned by the caller's session, or unowned), in
* registration order. Never lists another session's tasks — a global
* listing would leak their labels across the isolation fence.
* @param caller - the reading agent; undefined (a non-agent caller) sees only unowned tasks.
* @returns fresh snapshots; mutating them does not affect the registry.
*/
list(caller?: Agent): TaskSnapshot[] {
const session = caller?.session.header.id
return [...this.store.values()]
.filter(task => task.ownerSession === undefined || task.ownerSession === session)
.map(task => this.snapshot(task))
}
/**
* A non-consuming snapshot of one task — unlike {@link read}, never touches
* the stream cursor or the reported flag (the kill surface uses it to
* describe an already-terminal task WITHOUT eating a pending delta).
* Throws for an unknown id or a task owned by another session.
* @param id - the task to look up.
* @param caller - the reading agent, checked against the task's owner.
* @returns a fresh snapshot.
*/
get(id: TaskId, caller?: Agent): TaskSnapshot {
const task = this.expect(id)
this.assertAccess(task, caller)
return this.snapshot(task)
}
/**
* Read a task's output. Stream kinds (registered with `readOutput`) yield
* the CONSUMING delta since the previous read — one cursor per task, the
* owning model is v1's single intended reader; final-output kinds yield
* empty text while live and the terminal output idempotently once settled.
* A read that returns the terminal state marks the task {@link TaskSnapshot.reported}.
* Throws for an unknown id or a task owned by another session.
* @param id - the task to read.
* @param caller - the reading agent, checked against the task's owner.
* @returns the read text plus the post-read snapshot.
*/
read(id: TaskId, caller?: Agent): TaskRead {
const task = this.expect(id)
this.assertAccess(task, caller)
const text = task.readOutput !== undefined
? task.readOutput()
: isTerminal(task.status) ? task.output ?? '' : ''
if (isTerminal(task.status)) task.reported = true
return { text, snapshot: this.snapshot(task) }
}
/**
* Request cancellation of a task. A live task has its producer
* `cancel(reason)` invoked FIRST — a throw propagates (fail loud) and
* leaves the task untouched (still `running`, notice not suppressed) —
* then moves to `stopping` and settles through the normal `done` path; an
* already-terminal task is reported, not failed. Every SUCCESSFUL kill
* marks the task {@link TaskSnapshot.reported}: the killer has seen (or
* asked for) the end, so the completion notice is suppressed. Throws for
* an unknown id or a task owned by another session.
* @param id - the task to cancel.
* @param caller - the killing agent, checked against the task's owner.
* @param reason - the surface's logged reason, forwarded to the producer.
* @returns 'requested' when cancellation was asked of a live task, 'already-terminal' otherwise.
*/
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal' {
const task = this.expect(id)
this.assertAccess(task, caller)
if (isTerminal(task.status)) {
task.reported = true
return 'already-terminal'
}
// Producer cancel FIRST: a throw must leave the task untouched (still
// `running`, notice not suppressed) — the killer's tool call fails loud,
// but task_list and the eventual completion notice keep telling the
// truth about a cancellation that never happened. Cancel is synchronous
// and settlement lands on a later microtask, so the mutations below
// cannot race the settle path.
task.cancel(reason)
task.status = 'stopping'
task.reported = true
return 'requested'
}
/**
* Wait for a task to settle, bounded by a timeout. Resolves with the
* terminal snapshot (marked {@link TaskSnapshot.reported} — the wait
* response reports the end, so the completion notice is suppressed), or
* with the still-live snapshot when the timeout expires first. An abort of
* `signal` rejects the WAIT only — the task keeps running. Throws for an
* unknown id, a task owned by another session, or a non-positive timeout.
* @param id - the task to wait for.
* @param timeoutMs - max wait in milliseconds (positive, finite; the surface caps it).
* @param caller - the waiting agent, checked against the task's owner.
* @param signal - optional abort for the wait itself.
* @returns the snapshot at settlement, or at timeout when the task outlives the wait.
*/
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> {
const task = this.expect(id)
this.assertAccess(task, caller)
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
}
if (!isTerminal(task.status)) {
if (signal?.aborted) throw new Error('wait aborted')
task.waiters += 1
try {
await new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
}
const timer = setTimeout(() => { cleanup(); resolve() }, timeoutMs)
const onAbort = (): void => { cleanup(); reject(new Error('wait aborted')) }
signal?.addEventListener('abort', onAbort, { once: true })
void task.settled.then(() => { cleanup(); resolve() })
})
} finally {
task.waiters -= 1
}
}
if (isTerminal(task.status)) task.reported = true
return this.snapshot(task)
}
/**
* Register a completion listener, called exactly once per task with the
* terminal snapshot. Effect-scoped (disposed with the calling fiber);
* per-listener containment (one throwing listener is logged, never starves
* the rest); never fires after this service is disposed.
* @param listener - called with each settling task's terminal snapshot.
* @returns the disposer that unregisters the listener.
*/
onTaskDone(listener: TaskDoneListener): () => void {
const dispose = this.ctx.effect(() => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}, 'tasks.onTaskDone()')
return () => void dispose()
}
/**
* Declare that a control surface capable of reading/stopping tasks is
* loaded. {@link register} refuses to start a background task while NO
* surface is attached — the loud fence against a deployment exposing
* `run_in_background` without any way to collect or stop the work. The
* model-facing `@deepseek-ai/dsh-tool-tasks` attaches on load; a deployment
* with a custom (non-model) surface attaches its own. Effect-scoped:
* detached with the calling fiber.
* @param name - a diagnostic label for the surface (duplicate names count independently).
* @returns the disposer that detaches the surface.
*/
attachSurface(name: string): () => void {
// One token per attach call: duplicate names stay independent, and the
// single-shot effect disposer removes exactly its own attachment.
const token = Symbol(name)
const dispose = this.ctx.effect(() => {
this.surfaces.add(token)
return () => this.surfaces.delete(token)
}, 'tasks.attachSurface()')
return () => void dispose()
}
/** Look up a task or fail loud. */
private expect(id: TaskId): TrackedTask {
const task = this.store.get(id)
if (task === undefined) throw new Error(`unknown task ${id}`)
return task
}
/**
* The isolation fence: a task with an owner is reachable only by callers
* whose session id matches (`!== undefined` semantics — an unowned task is
* open, and a no-agent caller can never match an owned one).
*/
private assertAccess(task: TrackedTask, caller?: Agent): void {
if (task.ownerSession !== undefined && task.ownerSession !== caller?.session.header.id) {
throw new Error(`task ${task.id} belongs to another session`)
}
}
/** Project a fresh read-only snapshot from the mutable record. */
private snapshot(task: TrackedTask): TaskSnapshot {
return {
id: task.id,
kind: task.kind,
label: task.label,
...task.ownerSession !== undefined ? { ownerSession: task.ownerSession } : {},
status: task.status,
...task.detail !== undefined ? { detail: task.detail } : {},
startedAt: task.startedAt,
...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {},
reported: task.reported,
}
}
/**
* Record a task's terminal outcome (called exactly once — the single `done`
* continuation is the only caller), notify listeners with containment, then
* release waiters. A settlement observed by a pending {@link wait} marks
* the task reported BEFORE listeners run, so the notice surface can
* suppress its redundant "finished".
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
task.status = outcome.status
task.detail = outcome.detail
task.output = outcome.output
task.finishedAt = Date.now()
if (task.waiters > 0) task.reported = true
if (!this.listenersClosed) {
const snapshot = this.snapshot(task)
for (const listener of this.listeners) {
try {
listener(snapshot)
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
task.markSettled()
}
/**
* Attach the awaited owner-disposal cleanup for an owner agent, once: when
* the agent's disposal chain drains (`ctx.agents.drainCleanups`), the
* owner's still-live tasks are cancelled, awaited to settlement, and their
* snapshots dropped. Registered through {@link selfCtx} so the cleanup
* survives producer-plugin reloads. Fails loud when no agent registry is
* mounted — an owned background task without the cleanup seam would outlive
* its owner silently.
*/
private ensureOwnerCleanup(owner: Agent): void {
if (this.ownerCleanups.has(owner.id)) return
const agents = this.selfCtx.get('agents')
if (agents === undefined) {
throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
}
// Attach FIRST, record after: onCleanup throws for an unregistered agent,
// and marking the owner as covered before that would make every later
// registration for the same owner silently skip the cleanup.
agents.onCleanup(owner.id, async () => {
this.ownerCleanups.delete(owner.id)
await this.disposeOwned(owner.session.header.id)
})
this.ownerCleanups.add(owner.id)
}
/** Cancel (contained), await, and drop every task owned by one session. */
private async disposeOwned(ownerSession: string): Promise<void> {
const owned = [...this.store.values()].filter(task => task.ownerSession === ownerSession)
this.cancelForTeardown(owned, 'owner disposed')
await Promise.all(owned.map(task => task.settled))
for (const task of owned) this.store.delete(task.id)
}
/**
* Service teardown: close the listener registry FIRST (late completions
* from teardown kills stay silent), cancel every live task, and await
* quiescence. No orphan child work survives the tasks fiber.
*/
private async disposeAll(): Promise<void> {
this.listenersClosed = true
this.listeners.clear()
const all = [...this.store.values()]
this.cancelForTeardown(all, 'tasks service disposed')
await Promise.all(all.map(task => task.settled))
this.store.clear()
}
/**
* Teardown-path cancellation with per-task containment: unlike the
* model-facing {@link kill} (where a throwing producer `cancel` should fail
* the tool call loudly), a teardown must reach quiescence past a broken
* producer, so a throw is logged and the sweep continues.
*/
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
for (const task of tasks) {
if (isTerminal(task.status)) continue
task.status = 'stopping'
try {
task.cancel(reason)
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown: ${String(error)}`)
}
}
}
}
export default TaskService

View File

@@ -0,0 +1,155 @@
/**
* Task-registry vocabulary: the registration a producer hands to
* {@link TaskService.register} and the snapshots/reads consumers get back.
* Types only — the service lives in `./index.ts`.
*
* @module @deepseek-ai/dsh-tasks/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
/**
* Identifies one background task in the runtime-global registry. Generated by
* {@link TaskService.register} as `<kind>-N` (per-kind counter) — kind-prefixed
* so transcripts stay self-describing, sequential because the owner fence (not
* id secrecy) is the isolation boundary.
*/
export type TaskId = Branded<'TaskId'>
/**
* Brand a string as a {@link TaskId}.
* @param id - the raw task-id string (the registry generates `<kind>-N`).
* @returns the same string, branded; no validation is performed.
*/
export function TaskId(id: string): TaskId {
return id as TaskId
}
/**
* Task lifecycle. `running` → (`stopping` when cancellation was requested) →
* exactly one terminal {@link TaskOutcome.status} (`completed`, `killed`,
* `failed`). The vocabulary is generic and CLOSED — kind-specific meaning
* (exit codes, stop reasons) rides in {@link TaskSnapshot.detail}, so the
* registry never learns process or agent semantics.
*/
export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/**
* The terminal result a producer's {@link TaskRegistration.done} resolves
* with, mapped from the producer's own vocabulary (a process exit, a subagent
* stop reason) into the registry's closed status set.
*/
export interface TaskOutcome {
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
status: 'completed' | 'killed' | 'failed'
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
detail?: string
/**
* Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskRegistration.readOutput}),
* read idempotently after the task settles. Stream kinds leave it unset —
* their output is consumed incrementally through `readOutput`.
*/
output?: string
}
/**
* What a producer registers with {@link TaskService.register}: the running
* work's identity, its owner, and the three hooks the registry drives it
* through. The producer stays the owner of its execution concerns (process
* streams, child agents); the registry owns ids, isolation, status, and
* completion fan-out.
*/
export interface TaskRegistration {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
kind: string
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* The spawning agent. Its `session.header.id` becomes the task's owner
* token (read/kill/wait/list are fenced to that session), and its disposal
* cancels and awaits the task through the `ctx.agents.onCleanup` seam.
* `undefined` registers an UNOWNED task: open to any caller, alive until the
* tasks service disposes.
*/
owner?: Agent | undefined
/**
* Request termination. Idempotent, synchronous, and must lead to
* {@link done} settling; a throw propagates to the killer (fail loud — a
* cancel that cannot even be requested is a producer bug). The optional
* reason is `task_kill`'s logged reason, forwarded verbatim.
*/
cancel(reason?: string): void
/**
* Settles with the terminal outcome at QUIESCENCE — after the producer has
* released the task's resources (process exited, child agent disposed) —
* not merely when the work finished. Must never reject; a rejection is
* contained as a `failed` outcome and logged as a producer contract
* violation.
*/
done: Promise<TaskOutcome>
/**
* OPTIONAL incremental read (stream kinds): everything produced since the
* previous call, formatted by the producer (truncation/spill notices
* included). Consecutive calls never re-deliver output; the registry keeps
* ONE consuming cursor per task, so v1's single intended reader is the
* owning model. Absence marks a final-output-only kind (the method presence
* IS the capability).
*/
readOutput?(): string
}
/**
* A read-only projection of one task, safe to hand to listeners and tools —
* a fresh object per call, never live registry state.
*/
export interface TaskSnapshot {
/** The registry-issued id (`<kind>-N`). */
id: TaskId
/** The producer kind the task was registered with. */
kind: string
/** The producer-supplied one-line label. */
label: string
/**
* The owner's session id (`session.header.id`), for surfaces that must
* reach the owning agent (the completion-notice injector); absent for
* unowned tasks. Session ids are runtime-shared identifiers, not secrets —
* the read/kill/wait/list FENCE is what isolation rests on.
*/
ownerSession?: string
/** Current lifecycle state. */
status: TaskStatus
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
detail?: string
/** Epoch ms when the task was registered. */
startedAt: number
/** Epoch ms when the task settled; absent while `running`/`stopping`. */
finishedAt?: number
/**
* True once the terminal state has been (or is being) reported to the owner
* through an explicit surface response — a `kill` call, or a `read`/`wait`
* that returned the terminal state (including a wait pending at settlement).
* Completion-notice surfaces suppress their notice when set, so the model
* never gets a redundant "finished" for a task it just collected or killed.
*/
reported: boolean
}
/**
* One {@link TaskService.read}: the output text this read yields (may be
* empty — the surface decides how to render "nothing new") plus the snapshot
* taken after the read.
*/
export interface TaskRead {
/**
* Stream kinds: the consuming delta since the previous read. Final-output
* kinds: empty while live, the terminal {@link TaskOutcome.output} (or
* empty) once settled — idempotent, never consumed.
*/
text: string
/** The task's state at read time. */
snapshot: TaskSnapshot
}
/** Completion callback registered via {@link TaskService.onTaskDone}. */
export type TaskDoneListener = (snapshot: TaskSnapshot) => void

View File

@@ -0,0 +1,465 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
function stubAgent(rawId: string): Agent {
const id = AgentId(rawId)
return {
id,
options: {},
session: new Session(SessionId(`${id}-session`)),
status: 'idle',
send() {},
steer() {},
inject() {},
cancel() {},
whenIdle() { return Promise.resolve() },
}
}
/** A controllable producer: settle its `done` on demand, record cancels. */
function producer(overrides: Partial<TaskRegistration> = {}) {
let settle!: (outcome: TaskOutcome) => void
let reject!: (error: unknown) => void
const cancels: (string | undefined)[] = []
const registration: TaskRegistration = {
kind: 'bash',
label: 'sleep 60',
cancel(reason) { cancels.push(reason) },
done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
...overrides,
}
return { registration, settle, reject, cancels }
}
async function harness() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
ctx.tasks.attachSurface('test-surface')
return ctx
}
/** Let the settlement continuation (a `done.then`) run. */
const tick = () => new Promise<void>(r => setTimeout(r, 0))
describe('TaskService.register', () => {
it('refuses to register while no control surface is attached', async () => {
const ctx = new Context()
await ctx.plugin(TaskService)
expect(() => ctx.tasks.register(producer().registration))
.toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
})
it('rejects an empty kind and an empty label', async () => {
const ctx = await harness()
expect(() => ctx.tasks.register(producer({ kind: '' }).registration)).toThrow('invalid task kind')
expect(() => ctx.tasks.register(producer({ label: '' }).registration)).toThrow('invalid task label')
})
it('issues kind-prefixed ids from per-kind counters', async () => {
const ctx = await harness()
expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
expect(ctx.tasks.register(producer().registration)).toBe('bash-2')
expect(ctx.tasks.register(producer({ kind: 'subagent' }).registration)).toBe('subagent-1')
})
})
describe('TaskService reads and settlement', () => {
it('stream kinds read a consuming delta; terminal reads mark reported', async () => {
const ctx = await harness()
const chunks = ['first', '', 'rest']
const p = producer({ readOutput: () => chunks.shift() ?? '' })
const id = ctx.tasks.register(p.registration)
expect(ctx.tasks.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } })
expect(ctx.tasks.read(id).text).toBe('')
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
const read = ctx.tasks.read(id)
expect(read.text).toBe('rest')
expect(read.snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', reported: true })
expect(read.snapshot.finishedAt).toBeTypeOf('number')
})
it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
const ctx = await harness()
const p = producer({ kind: 'subagent', label: 'research task' })
const id = ctx.tasks.register(p.registration)
expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } })
p.settle({ status: 'completed', output: 'final answer' })
await tick()
expect(ctx.tasks.read(id).text).toBe('final answer')
expect(ctx.tasks.read(id).text).toBe('final answer') // idempotent, not consumed
})
it('a settled task without output reads as empty text', async () => {
const ctx = await harness()
const p = producer({ kind: 'subagent' })
const id = ctx.tasks.register(p.registration)
p.settle({ status: 'failed', detail: 'max-tokens' })
await tick()
expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } })
})
it('throws for unknown task ids', async () => {
const ctx = await harness()
expect(() => ctx.tasks.read(TaskId('bash-99'))).toThrow('unknown task bash-99')
})
it('notifies onTaskDone once per task with containment across listeners', async () => {
const ctx = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(() => { throw new Error('listener boom') })
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
const p = producer()
const id = ctx.tasks.register(p.registration)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(seen).toHaveLength(1)
expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom'))
})
it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
const ctx = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const p = producer()
const id = ctx.tasks.register(p.registration)
p.reject(new Error('transport exploded'))
await tick()
expect(ctx.tasks.read(id).snapshot).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' })
expect(warn).toHaveBeenCalledWith(expect.stringContaining('producer contract violation'))
})
it('unregisters onTaskDone listeners with the contributing fiber (HMR safety)', async () => {
const ctx = await harness()
const seen: string[] = []
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
}, { inject: ['tasks'] }))
await fiber.dispose()
// The returned disposer detaches too (the non-fiber path).
const detach = ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
detach()
const p = producer()
ctx.tasks.register(p.registration)
p.settle({ status: 'completed' })
await tick()
expect(seen).toEqual([])
})
})
describe('TaskService.kill', () => {
it('cancels a live task with the forwarded reason and suppresses the notice', async () => {
const ctx = await harness()
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
const p = producer()
const id = ctx.tasks.register(p.registration)
expect(ctx.tasks.kill(id, undefined, 'no longer needed')).toBe('requested')
expect(p.cancels).toEqual(['no longer needed'])
expect(ctx.tasks.list()[0]).toMatchObject({ status: 'stopping', reported: true })
p.settle({ status: 'killed' })
await tick()
// The listener still fires (telemetry may care), but carries reported: true
// so the notice surface suppresses its redundant "finished".
expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
})
it('reports an already-terminal task instead of failing', async () => {
const ctx = await harness()
const p = producer()
const id = ctx.tasks.register(p.registration)
p.settle({ status: 'completed' })
await tick()
expect(ctx.tasks.kill(id)).toBe('already-terminal')
})
it('propagates a throwing producer cancel and leaves the task untouched', async () => {
const ctx = await harness()
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
let broken = true
let settle!: (outcome: TaskOutcome) => void
const id = ctx.tasks.register({
kind: 'bash',
label: 'flaky cancel',
cancel() { if (broken) throw new Error('cancel boom') },
done: new Promise<TaskOutcome>((res) => { settle = res }),
})
expect(() => ctx.tasks.kill(id)).toThrow('cancel boom')
// The failed kill mutated NOTHING: still running, notice not suppressed,
// and a later (successful) kill still works.
expect(ctx.tasks.get(id)).toMatchObject({ status: 'running', reported: false })
settle({ status: 'completed' })
await tick()
expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire
broken = false
expect(ctx.tasks.kill(id)).toBe('already-terminal')
})
})
describe('TaskService.wait', () => {
it('resolves with the terminal snapshot when the task settles, marked reported', async () => {
const ctx = await harness()
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
const p = producer()
const id = ctx.tasks.register(p.registration)
const wait = ctx.tasks.wait(id, 5_000)
p.settle({ status: 'completed', detail: 'exit code: 0' })
expect(await wait).toMatchObject({ status: 'completed', reported: true })
// The pending wait marked the task reported BEFORE listeners ran.
expect(seen[0]).toMatchObject({ id, reported: true })
})
it('returns the live snapshot on timeout without marking reported', async () => {
const ctx = await harness()
const id = ctx.tasks.register(producer().registration)
expect(await ctx.tasks.wait(id, 5)).toMatchObject({ status: 'running', reported: false })
})
it('returns immediately for an already-terminal task', async () => {
const ctx = await harness()
const p = producer()
const id = ctx.tasks.register(p.registration)
p.settle({ status: 'completed' })
await tick()
expect(await ctx.tasks.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true })
})
it('rejects a non-positive or non-finite timeout', async () => {
const ctx = await harness()
const id = ctx.tasks.register(producer().registration)
await expect(ctx.tasks.wait(id, 0)).rejects.toThrow('invalid wait timeout')
await expect(ctx.tasks.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout')
})
it('an aborted signal rejects the wait only — the task stays alive', async () => {
const ctx = await harness()
const id = ctx.tasks.register(producer().registration)
const controller = new AbortController()
const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
controller.abort()
await expect(wait).rejects.toThrow('wait aborted')
expect(ctx.tasks.list()[0]).toMatchObject({ status: 'running' })
const preAborted = new AbortController()
preAborted.abort()
await expect(ctx.tasks.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted')
})
})
describe('TaskService owner isolation', () => {
it('fences read/kill/wait to the owning session and keeps unowned tasks open', async () => {
const ctx = await harness()
const owner = stubAgent('owner')
ctx.agents.register(owner)
const other = stubAgent('other')
const owned = ctx.tasks.register(producer({ owner }).registration)
const open = ctx.tasks.register(producer().registration)
// The owner and the unowned task are reachable.
expect(ctx.tasks.read(owned, owner).snapshot.id).toBe(owned)
expect(ctx.tasks.read(open, other).snapshot.id).toBe(open)
// A different session and a no-agent caller are rejected.
expect(() => ctx.tasks.read(owned, other)).toThrow(`task ${owned} belongs to another session`)
expect(() => ctx.tasks.kill(owned, other)).toThrow('belongs to another session')
await expect(ctx.tasks.wait(owned, 10, other)).rejects.toThrow('belongs to another session')
expect(() => ctx.tasks.read(owned)).toThrow('belongs to another session')
})
it('list() shows only caller-owned plus unowned tasks', async () => {
const ctx = await harness()
const alice = stubAgent('alice')
const bob = stubAgent('bob')
ctx.agents.register(alice)
ctx.agents.register(bob)
const aliceTask = ctx.tasks.register(producer({ owner: alice }).registration)
const bobTask = ctx.tasks.register(producer({ owner: bob }).registration)
const openTask = ctx.tasks.register(producer({ kind: 'subagent' }).registration)
expect(ctx.tasks.list(alice).map(t => t.id)).toEqual([aliceTask, openTask])
expect(ctx.tasks.list(bob).map(t => t.id)).toEqual([bobTask, openTask])
expect(ctx.tasks.list().map(t => t.id)).toEqual([openTask])
})
it('rejects an owned registration when no agent registry is mounted', async () => {
const ctx = new Context()
await ctx.plugin(TaskService)
ctx.tasks.attachSurface('test-surface')
expect(() => ctx.tasks.register(producer({ owner: stubAgent('a') }).registration))
.toThrow('background task ownership requires the agent registry')
// The failed registration mutated nothing: no stored task, counter untouched.
expect(ctx.tasks.list()).toEqual([])
expect(ctx.tasks.register(producer().registration)).toBe('bash-1')
})
it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => {
const ctx = await harness()
const ghost = stubAgent('ghost') // never registered in ctx.agents
// onCleanup rejects the unregistered agent BEFORE any registry mutation.
expect(() => ctx.tasks.register(producer({ owner: ghost }).registration))
.toThrow('is not registered')
expect(ctx.tasks.list(ghost)).toEqual([])
// Once the agent actually exists, the same owner gets a WORKING cleanup —
// the failed attempt must not have marked it as already covered.
ctx.agents.register(ghost)
const cancels: (string | undefined)[] = []
let settle!: (outcome: TaskOutcome) => void
const id = ctx.tasks.register({
kind: 'bash',
label: 'after retry',
owner: ghost,
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
})
expect(id).toBe('bash-1') // the failed attempt burned no counter
await ctx.agents.drainCleanups(ghost.id)
expect(cancels).toEqual(['owner disposed'])
expect(ctx.tasks.list(ghost)).toEqual([])
})
})
describe('TaskService owner cleanup', () => {
it('drains the owner: cancels live tasks, awaits settlement, drops snapshots', async () => {
const ctx = await harness()
const owner = stubAgent('owner')
ctx.agents.register(owner)
// The producer settles only when cancelled — models a child that stops on request.
let settle!: (outcome: TaskOutcome) => void
const cancels: (string | undefined)[] = []
ctx.tasks.register({
kind: 'subagent',
label: 'long research',
owner,
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
})
const terminal = producer({ owner })
ctx.tasks.register(terminal.registration)
terminal.settle({ status: 'completed' })
await tick()
await ctx.agents.drainCleanups(owner.id)
expect(cancels).toEqual(['owner disposed'])
// Snapshots dropped: nothing of the owner's remains, listing is empty.
expect(ctx.tasks.list(owner)).toEqual([])
})
it('attaches one cleanup per owner and re-attaches after a drain', async () => {
const ctx = await harness()
const owner = stubAgent('owner')
ctx.agents.register(owner)
const first = producer({ owner })
const second = producer({ owner })
ctx.tasks.register(first.registration)
ctx.tasks.register(second.registration)
first.settle({ status: 'completed' })
second.settle({ status: 'completed' })
await tick()
await ctx.agents.drainCleanups(owner.id)
// A fresh task after the drain gets a fresh cleanup (the set was consumed).
const third = producer({ owner })
ctx.tasks.register(third.registration)
third.settle({ status: 'completed' })
await tick()
expect(ctx.tasks.list(owner)).toHaveLength(1)
await ctx.agents.drainCleanups(owner.id)
expect(ctx.tasks.list(owner)).toEqual([])
})
it('contains a throwing producer cancel on the cleanup path', async () => {
const ctx = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const owner = stubAgent('owner')
ctx.agents.register(owner)
let settle!: (outcome: TaskOutcome) => void
ctx.tasks.register({
kind: 'bash',
label: 'broken producer',
owner,
cancel() { throw new Error('cancel boom') },
done: new Promise<TaskOutcome>((res) => { settle = res }),
})
const drain = ctx.agents.drainCleanups(owner.id)
settle({ status: 'failed', detail: 'gave up' })
await drain
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancel boom'))
expect(ctx.tasks.list(owner)).toEqual([])
})
})
describe('TaskService disposal', () => {
it('cancels live tasks, awaits settlement, and silences listeners', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(TaskService)
const surface = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.attachSurface('test-surface')
}, { inject: ['tasks'] }))
void surface
const seen: string[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
let settle!: (outcome: TaskOutcome) => void
const cancels: (string | undefined)[] = []
ctx.tasks.register({
kind: 'bash',
label: 'sleep 600',
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
})
await fiber.dispose()
expect(cancels).toEqual(['tasks service disposed'])
// The teardown kill settles AFTER the listener registry closed: silent.
expect(seen).toEqual([])
})
it('detaching the last surface re-arms the register fence', async () => {
const ctx = new Context()
await ctx.plugin(TaskService)
const detachA1 = ctx.tasks.attachSurface('a')
const detachA2 = ctx.tasks.attachSurface('a') // duplicate name counts independently
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.attachSurface('b')
}, { inject: ['tasks'] }))
detachA1()
detachA1() // second call of the same disposer is a no-op
expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // a ×1 + b remain
detachA2()
expect(() => ctx.tasks.register(producer().registration)).not.toThrow() // b remains
await fiber.dispose() // detaches b with its fiber (HMR safety)
expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../core/agent"
}
]
}

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-tool-tasks
The model-facing background task control surface over `ctx.tasks`: three kind-agnostic tools, the completion-notice injection, and the prompt section that teaches the background habit. Loading this plugin calls `ctx.tasks.attachSurface('tool-tasks')`, which is what arms producers' `register()`.
## Tools
- `task_output(task_id, wait?, timeout_ms?)` — non-blocking read by default (stream kinds: the consuming delta since the previous read; final kinds: the final answer once terminal); every response ends with a `[status: …]` line (generic status + producer detail, e.g. `[status: completed, exit code: 0]`). `wait: true` blocks until settlement, bounded by `waitTimeoutMs`/`maxWaitTimeoutMs` config; a timed-out wait returns `[status: running]` and leaves the task alive.
- `task_list()` — the caller's tasks, `<id> [<kind>] <status> — <label>` per line.
- `task_kill(task_id, reason?)` — requests cancellation and returns immediately; the logged `reason` is forwarded to the producer. An already-terminal task is described via a non-consuming snapshot (never eats a pending delta).
ACP render intent: all three are `generic` cards (`read`/`read`/`execute`) — a task read is not a terminal.
## Completion notices
On `onTaskDone`, injects `background task <id> (<kind>: <label>) finished [status: …]. Read its output with task_output.` into the owning agent's session (`agent.inject()` — durable context for the next request, not a wake-up). Suppressed when the snapshot is `reported` (the model already killed it, or a read/wait returned the end) — never a redundant "finished". The disposed-owner race is contained; a missing agent registry drops the notice.
## Config
| key | default | meaning |
|---|---|---|
| `waitTimeoutMs` | `30000` | wait duration when `task_output` sets `wait` without `timeout_ms` |
| `maxWaitTimeoutMs` | `600000` | hard cap; larger model-supplied `timeout_ms` values are clamped |
A config whose default exceeds the cap fails loud at load.

View File

@@ -0,0 +1,43 @@
{
"name": "@deepseek-ai/dsh-tool-tasks",
"description": "Model-facing background task control tools (task_output, task_list, task_kill) over the ctx.tasks registry",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,183 @@
/**
* The model-facing background task control tools: `task_output`, `task_list`,
* `task_kill`. Kind-agnostic — a background bash command and a background
* subagent read, list, and die through the same three schemas — with every
* task concern (ids, isolation, cursors, settlement) behind the `ctx.tasks`
* registry (`@deepseek-ai/dsh-tasks`).
*
* This plugin IS the control surface: it calls `ctx.tasks.attachSurface()` on
* load, which is what re-arms producers' `register()` (the registry refuses
* background work while no surface could collect or stop it).
*
* Completion notices: when a task settles, a short notice is injected into
* the owning agent's session (`agent.inject()` — durable context for the NEXT
* model request, not a wake-up). A task whose terminal state the model
* already saw (`snapshot.reported` — an explicit kill, or a read/wait that
* returned the end) is suppressed, so the model never gets a redundant
* "finished" for work it just collected.
*
* @module @deepseek-ai/dsh-tool-tasks
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-system-prompt'
export const name = 'tool-tasks'
export const inject = ['tools', 'tasks', 'systemPrompt']
/** Config: the `task_output` wait bounds (defaulted, capped — never hardcoded). */
export interface Config {
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
waitTimeoutMs?: number
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
maxWaitTimeoutMs?: number
}
export const Config: z<Config> = z.object({
waitTimeoutMs: z.number().min(1).default(30_000),
maxWaitTimeoutMs: z.number().min(1).default(600_000),
})
/**
* Render a snapshot's status line — generic status plus the producer's
* kind-specific detail: `[status: completed, exit code: 0]`,
* `[status: failed, max-tokens]`, `[status: running]`. Exported for tests
* and for producers that want a consistent line in their own results.
* @param snapshot - the task state to render.
* @returns the bracketed status line.
*/
export function statusLine(snapshot: TaskSnapshot): string {
return snapshot.detail !== undefined
? `[status: ${snapshot.status}, ${snapshot.detail}]`
: `[status: ${snapshot.status}]`
}
/**
* Reject an empty `task_id`. Type/presence come from the SchemaSpec
* validation; only the non-empty constraint, which the DSL cannot express,
* is checked here.
*/
function validateTaskId(value: string): TaskId {
if (value.length === 0) {
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
}
return TaskId(value)
}
/** Pending-state presentation shared by the three control tools (generic cards by design — a task read/kill is not a terminal). */
function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
}
export function apply(ctx: Context, config: Config): void {
const waitDefault = config.waitTimeoutMs ?? 30_000
const waitCap = config.maxWaitTimeoutMs ?? 600_000
if (waitDefault > waitCap) {
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
}
// The registry's misconfiguration fence: producers can register background
// work only while a surface capable of collecting/stopping it is attached.
ctx.tasks.attachSurface('tool-tasks')
// The cross-call HABIT the per-tool descriptions cannot carry. Order 106:
// right after tool:bash (105), before deployment product sections.
ctx.systemPrompt.section({
name: 'tool:tasks',
order: 106,
text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
})
// Background completion → inject a notice into the owning agent's session.
// `ctx.get('agents')` (not static inject): this listener runs from a
// detached settlement continuation on the tasks fiber — a foreign fiber —
// where the `ctx.agents` property proxy would throw; `ctx.get` is the
// topology-independent lookup. No registry mounted → drop the notice.
ctx.tasks.onTaskDone((snapshot) => {
// A reported terminal state was already surfaced by an explicit
// read/wait/kill response — a notice would be a redundant "finished".
if (snapshot.reported || snapshot.ownerSession === undefined) return
const agent = ctx.get('agents')?.list().find(a => a.session.header.id === snapshot.ownerSession)
if (!agent) return
try {
agent.inject(
[{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
)
} catch (error: unknown) {
// The ONE expected failure: the agent was disposed between settlement
// and this injection (inject throws `agent "<id>" is disposed`). That
// race is benign — drop the notice. Anything else must surface.
if (error instanceof Error && error.message.includes('is disposed')) return
throw error
}
})
ctx.tools.register(defineTool({
name: 'task_output',
description: 'Read output/status from a background task (started by a tool with `run_in_background`). '
+ 'Stream tasks (bash) return only output produced since your previous task_output call; '
+ 'final-output tasks (subagent) return the final answer once the task finishes. '
+ 'Every response ends with a [status: ...] line. Non-blocking by default; '
+ 'set `wait: true` to block until the task finishes (bounded by a capped timeout) when you are genuinely blocked on its result.',
parameters: {
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
wait: { type: 'boolean', description: 'Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive.' },
timeout_ms: { type: 'number', description: 'Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum.' },
},
async execute(args, exec) {
const id = validateTaskId(args.task_id)
if (args.wait === true) {
const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
}
const read = ctx.tasks.read(id, exec.agent)
const body = read.text.length > 0 ? read.text : '(no new output)'
const separator = body.endsWith('\n') ? '' : '\n'
return [{ type: 'text', text: `${body}${separator}${statusLine(read.snapshot)}` }]
},
presentCall: args => presentTaskCall(`Read output from background task ${args.task_id}`, 'read', args.task_id),
}))
ctx.tools.register(defineTool({
name: 'task_list',
description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
parameters: {},
// execute is synchronous (registry reads + string shaping) but the
// ToolDefinition contract wants a Promise — hence resolve(), not async.
execute(_args, exec) {
const tasks = ctx.tasks.list(exec.agent)
const text = tasks.length === 0
? '(no background tasks)'
: tasks.map(t => `${t.id} [${t.kind}] ${t.status}${t.label}`).join('\n')
return Promise.resolve([{ type: 'text', text }])
},
presentCall: () => presentTaskCall('List background tasks', 'read'),
}))
ctx.tools.register(defineTool({
name: 'task_kill',
description: 'Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.',
parameters: {
task_id: { type: 'string', required: true, description: 'Task id returned by the tool that started the background work.' },
reason: { type: 'string', description: 'Optional short reason, recorded in the log and forwarded to the task.' },
},
execute(args, exec) {
const id = validateTaskId(args.task_id)
const result = ctx.tasks.kill(id, exec.agent, args.reason)
if (result === 'already-terminal') {
// ctx.tasks.get, NOT .read: a read would consume a stream task's
// pending delta just to describe the terminal state.
const snapshot = ctx.tasks.get(id, exec.agent)
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
}
return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
},
presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
}))
}

View File

@@ -0,0 +1,306 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TaskService from '@deepseek-ai/dsh-tasks'
import type { TaskOutcome, TaskRegistration, TaskSnapshot } from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
async function setup(config: ToolTasks.Config = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const agentsFiber = await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
const toolsFiber = await ctx.plugin(ToolTasks, config)
return { ctx, agentsFiber, toolsFiber }
}
/**
* A fake agent whose session token is `sessionId`, registered in `ctx.agents`
* (the notice path finds the owner by scanning the registry for a matching
* `session.header.id` — the agent id is deliberately DIFFERENT so a
* wrong-field match fails the test).
*/
function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
ctx.agents.register(agent)
return agent
}
/** A controllable producer registration (settle `done` on demand, record cancels). */
function producer(overrides: Partial<TaskRegistration> = {}) {
let settle!: (outcome: TaskOutcome) => void
const cancels: (string | undefined)[] = []
const registration: TaskRegistration = {
kind: 'bash',
label: 'sleep 60',
cancel(reason) { cancels.push(reason) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
...overrides,
}
return { registration, settle, cancels }
}
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
const tick = () => new Promise<void>(r => setTimeout(r, 0))
describe('tool-tasks setup', () => {
it('attaches the control surface on load and detaches it with the fiber', async () => {
const { ctx, toolsFiber } = await setup()
expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
await toolsFiber.dispose()
expect(() => ctx.tasks.register(producer().registration)).toThrow('no control surface is attached')
})
it('rejects a config whose default wait exceeds the cap', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TaskService)
await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
.rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
})
it('renders status lines with and without producer detail', () => {
const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot
expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]')
})
it('applies the built-in wait bounds when apply() receives a bare config', async () => {
// Bypasses the schemastery defaults on purpose: apply() must stand on its
// own `??` fallbacks when embedded programmatically without the schema.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TaskService)
ToolTasks.apply(ctx, {})
expect(ctx.tools.get('task_output')).toBeDefined()
expect(() => ctx.tasks.register(producer().registration)).not.toThrow()
})
})
describe('task_output', () => {
it('reads a consuming delta with a trailing status line', async () => {
const { ctx } = await setup()
const chunks = ['line one\n', '']
ctx.tasks.register(producer({ readOutput: () => chunks.shift() ?? '' }).registration)
// A body already ending in a newline gets no doubled separator.
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('line one\n[status: running]')
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
})
it('returns the final output of a settled final-output task', async () => {
const { ctx } = await setup()
const p = producer({ kind: 'subagent', label: 'research' })
ctx.tasks.register(p.registration)
expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
await tick()
expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
})
it('wait: true blocks until settlement and reports the terminal state', async () => {
const { ctx } = await setup()
const p = producer({ kind: 'subagent', label: 'research' })
ctx.tasks.register(p.registration)
const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true })
p.settle({ status: 'completed', output: 'done deal' })
expect(text(await pending)).toBe('done deal\n[status: completed]')
})
it('wait: true times out against the configured cap and leaves the task alive', async () => {
const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
ctx.tasks.register(producer().registration)
// A model-supplied timeout far above the cap is clamped: this returns
// promptly (≤ the 20ms cap), not after ten minutes.
const result = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true, timeout_ms: 600_000 })
expect(text(result)).toBe('(no new output)\n[status: running]')
})
it('rejects an empty or unknown task id as an errored result', async () => {
const { ctx } = await setup()
expect((await call(ctx, 'task_output', { task_id: '' })).isError).toBe(true)
const unknown = await call(ctx, 'task_output', { task_id: 'bash-99' })
expect(unknown.isError).toBe(true)
expect(text(unknown)).toContain('unknown task bash-99')
})
})
describe('task_list', () => {
it('lists caller-visible tasks and renders the empty case', async () => {
const { ctx } = await setup()
expect(text(await call(ctx, 'task_list', {}))).toBe('(no background tasks)')
const alice = fakeAgent(ctx, 'sess-alice')
ctx.tasks.register(producer({ owner: alice, label: 'pnpm test' }).registration)
ctx.tasks.register(producer({ kind: 'subagent', label: 'open research' }).registration)
const p = producer({ owner: alice, label: 'build' })
ctx.tasks.register(p.registration)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(text(await call(ctx, 'task_list', {}, alice))).toBe([
'bash-1 [bash] running — pnpm test',
'subagent-1 [subagent] running — open research',
'bash-2 [bash] completed — build',
].join('\n'))
// A different caller sees only the unowned task.
const bob = fakeAgent(ctx, 'sess-bob')
expect(text(await call(ctx, 'task_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
})
})
describe('task_kill', () => {
it('requests cancellation with the forwarded reason', async () => {
const { ctx } = await setup()
const p = producer()
ctx.tasks.register(p.registration)
const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
expect(text(result)).toBe('requested cancellation of task bash-1')
expect(p.cancels).toEqual(['superseded'])
})
it('reports an already-terminal task without consuming its pending delta', async () => {
const { ctx } = await setup()
let delta = 'unread tail'
const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
ctx.tasks.register(p.registration)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(text(await call(ctx, 'task_kill', { task_id: 'bash-1' })))
.toBe('task bash-1 had already finished [status: completed, exit code: 0]')
// The kill described the task via a non-consuming snapshot: the delta is intact.
expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
})
it('rejects an empty task id as an errored result', async () => {
const { ctx } = await setup()
expect((await call(ctx, 'task_kill', { task_id: '' })).isError).toBe(true)
})
})
describe('tool-owned UI presentation (presentCall)', () => {
it('renders generic cards for all three control tools', async () => {
const { ctx } = await setup()
expect(ctx.tools.get('task_output')?.presentCall?.({ task_id: 'bash-1' }))
.toEqual({ card: 'generic', title: 'Read output from background task bash-1', kind: 'read', rawInput: 'bash-1' })
expect(ctx.tools.get('task_list')?.presentCall?.({}))
.toEqual({ card: 'generic', title: 'List background tasks', kind: 'read' })
expect(ctx.tools.get('task_kill')?.presentCall?.({ task_id: 'subagent-2' }))
.toEqual({ card: 'generic', title: 'Kill background task subagent-2', kind: 'execute', rawInput: 'subagent-2' })
})
})
describe('completion notices', () => {
it('injects a notice into the owning agent when an unreported task settles', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const p = producer({ owner, label: 'pnpm test' })
ctx.tasks.register(p.registration)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
expect(inject).toHaveBeenCalledWith(
[{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
)
})
it('suppresses the notice for a task the model already killed', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const p = producer({ owner })
ctx.tasks.register(p.registration)
await call(ctx, 'task_kill', { task_id: 'bash-1' }, owner)
p.settle({ status: 'killed' })
await tick()
expect(inject).not.toHaveBeenCalled()
})
it('suppresses the notice when a wait returned the terminal state', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const p = producer({ owner, kind: 'subagent' })
ctx.tasks.register(p.registration)
const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true }, owner)
p.settle({ status: 'completed', output: 'answer' })
expect(text(await pending)).toContain('answer')
expect(inject).not.toHaveBeenCalled()
})
it('drops the notice for unowned tasks and for a disposed owner (benign race)', async () => {
const { ctx } = await setup()
// Unowned: settles with nobody to notify — nothing throws.
const unowned = producer()
ctx.tasks.register(unowned.registration)
unowned.settle({ status: 'completed' })
await tick()
// Disposed owner: inject throws the disposed message — contained.
const inject = vi.fn(() => { throw new Error('agent "agent-sess-1" is disposed') })
const owner = fakeAgent(ctx, 'sess-1', inject)
const p = producer({ owner })
ctx.tasks.register(p.registration)
p.settle({ status: 'completed' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
})
it('propagates a non-disposed inject failure (a real bug must surface)', async () => {
const { ctx } = await setup()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
const p = producer({ owner })
ctx.tasks.register(p.registration)
p.settle({ status: 'completed' })
await tick()
// The throw escapes the notice listener and is contained (logged) by the
// registry's per-listener containment — visible, not swallowed.
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
})
it('drops the notice when no live agent matches and when the agent registry is gone', async () => {
const { ctx, agentsFiber } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
// Owner known at registration, unregistered before settlement → no match.
const p1 = producer({ owner })
ctx.tasks.register(p1.registration)
// A second task whose settlement happens after the whole registry is gone.
const p2 = producer({ owner })
ctx.tasks.register(p2.registration)
await agentsFiber.dispose()
p1.settle({ status: 'completed' })
p2.settle({ status: 'failed' })
await tick()
expect(inject).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../tasks"
}
]
}