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:
@@ -15,6 +15,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Background task family: the `ctx.tasks` registry + the generic `task_*` control tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
|
||||
@@ -6,6 +6,6 @@ The canonical three-package capability seam (see [capability seams](../../docs/r
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
| `tool-bash/` | Model-facing `bash` tool schema | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible.
|
||||
The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible. Background runs are generic tasks, not bash-private state: `tool-bash` registers a started `BashProcess` handle with the [`ctx.tasks` registry](../tasks/README.md), whose `task_*` tools collect and stop it.
|
||||
|
||||
@@ -23,7 +23,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
|
||||
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
## Sandboxing
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* `LocalBashExecutor`: the local-subprocess implementation of the
|
||||
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
|
||||
* own process group (see `./run.ts` for the plumbing and the agent-tool
|
||||
* survey notes), tracks background tasks, and kills everything on dispose.
|
||||
* survey notes), tracks live background processes for disposal quiescence
|
||||
* ONLY (task semantics live in `ctx.tasks`), and kills everything on dispose.
|
||||
*
|
||||
* TODO(permissions/sandbox): execution policy does NOT belong here — use
|
||||
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
|
||||
@@ -16,8 +17,8 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
|
||||
import type { RunInternals, RunningBash } from './run.ts'
|
||||
|
||||
@@ -47,15 +48,6 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface TrackedTask extends BashTask {
|
||||
running: RunningBash
|
||||
/** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
|
||||
stdoutOffset: number
|
||||
stderrOffset: number
|
||||
/** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
|
||||
owner: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-subprocess bash executor. Defaults follow the agent-tool survey
|
||||
* consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
|
||||
@@ -71,8 +63,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
private tasks = new Map<BashTaskId, TrackedTask>()
|
||||
private nextTaskId = 1
|
||||
/**
|
||||
* Live background processes, tracked for DISPOSAL only: an entry leaves
|
||||
* the map the moment its process settles (callers keep reading through
|
||||
* their own {@link BashProcess} handle — the buffers live on it).
|
||||
*/
|
||||
private live = new Map<BashProcess, RunningBash>()
|
||||
/** Test seam: spill knobs forwarded to runBash. */
|
||||
internals: RunInternals = {}
|
||||
|
||||
@@ -91,17 +87,14 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
ctx.effect(() => async () => {
|
||||
// Kill every live process group and WAIT for the processes to close so
|
||||
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
|
||||
// held until the SIGKILL escalation lands. The base class already
|
||||
// silenced listeners, so these kills complete without notices.
|
||||
// held until the SIGKILL escalation lands.
|
||||
const pending: Promise<void>[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === 'running') {
|
||||
task.status = 'killed'
|
||||
task.running.kill()
|
||||
pending.push(task.done)
|
||||
}
|
||||
for (const [proc, running] of this.live) {
|
||||
proc.status = 'killed'
|
||||
running.kill()
|
||||
pending.push(proc.done)
|
||||
}
|
||||
this.tasks.clear()
|
||||
this.live.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'local bash teardown')
|
||||
}
|
||||
@@ -125,9 +118,6 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
// means none). env merges AFTER the scrub in run.ts.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
// Carry the owner through verbatim (required-but-nullable on the spec):
|
||||
// the executor never interprets it — the consumer's access policy does.
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +135,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return { ...outcome, timeoutMs: spec.timeoutMs }
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashTask {
|
||||
// No timeout for background tasks (matches Claude Code, which detaches
|
||||
// the timeout when backgrounding); callers stop tasks via kill() — or
|
||||
// via spec.signal, which the seam contract honors for background runs
|
||||
// too (runBash wires it to the group kill). spec.timeoutMs is ignored
|
||||
// here by design.
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// No timeout for background processes (matches Claude Code, which
|
||||
// detaches the timeout when backgrounding); callers stop them via the
|
||||
// handle's kill() — or via spec.signal, which the seam contract honors
|
||||
// for background runs too (runBash wires it to the group kill).
|
||||
// spec.timeoutMs is ignored here by design.
|
||||
const running = runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
@@ -162,79 +152,54 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
env: spec.env,
|
||||
}, this.internals)
|
||||
|
||||
const id = BashTaskId(`bash-${this.nextTaskId++}`)
|
||||
const task: TrackedTask = {
|
||||
id,
|
||||
let stdoutOffset = 0
|
||||
let stderrOffset = 0
|
||||
const proc: BashProcess = {
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
owner: spec.owner,
|
||||
running,
|
||||
stdoutOffset: 0,
|
||||
stderrOffset: 0,
|
||||
done: running.done.then((outcome) => {
|
||||
// Abort-killed tasks report as killed, not completed.
|
||||
if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
|
||||
task.exitCode = outcome.exitCode
|
||||
task.signal = outcome.signal
|
||||
this.notifyTaskDone(task)
|
||||
// Abort-killed processes report as killed, not completed.
|
||||
if (proc.status === 'running') proc.status = outcome.aborted ? 'killed' : 'completed'
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.live.delete(proc)
|
||||
}, (error: unknown) => {
|
||||
// Spawn-level failure (bad workdir, …): the task never ran. String()
|
||||
// Spawn-level failure (bad workdir, …): the process never ran. The
|
||||
// error is surfaced through the read path, not a rejection. String()
|
||||
// suffices — runBash only rejects with Error instances.
|
||||
task.status = 'killed'
|
||||
task.running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
|
||||
this.notifyTaskDone(task)
|
||||
proc.status = 'killed'
|
||||
running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
|
||||
this.live.delete(proc)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = running.stdout.readFrom(stdoutOffset)
|
||||
const err = running.stderr.readFrom(stderrOffset)
|
||||
stdoutOffset = out.nextOffset
|
||||
stderrOffset = err.nextOffset
|
||||
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
},
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
running.kill()
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
// Unknown id and known-but-ownerless both read as undefined — the consumer
|
||||
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
|
||||
return this.tasks.get(id)?.owner
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
|
||||
const out = task.running.stdout.readFrom(task.stdoutOffset)
|
||||
const err = task.running.stderr.readFrom(task.stderrOffset)
|
||||
task.stdoutOffset = out.nextOffset
|
||||
task.stderrOffset = err.nextOffset
|
||||
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
|
||||
return {
|
||||
task,
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
}
|
||||
|
||||
kill(id: BashTaskId): boolean {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
if (task.status !== 'running') return false
|
||||
task.status = 'killed'
|
||||
task.running.kill()
|
||||
return true
|
||||
this.live.set(proc, running)
|
||||
return proc
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
@@ -18,34 +17,20 @@ async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1]
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/** Poll until a pid no longer exists. */
|
||||
async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
||||
/**
|
||||
* Poll a handle's consuming readOutput until the ACCUMULATED delta contains
|
||||
* `expected`; returns the accumulation (reads never re-deliver, so the caller
|
||||
* gets everything produced up to the match).
|
||||
*/
|
||||
async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let all = ''
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
all += proc.readOutput().delta
|
||||
if (all.includes(expected)) return all
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function readUntil(
|
||||
bash: LocalBashExecutor,
|
||||
id: BashTaskId,
|
||||
expected: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<BashTaskRead> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last: BashTaskRead | undefined
|
||||
while (Date.now() < deadline) {
|
||||
last = bash.readOutput(id)
|
||||
if (last.delta.includes(expected)) return last
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
|
||||
throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(all)}`)
|
||||
}
|
||||
|
||||
describe('LocalBashExecutor.run', () => {
|
||||
@@ -88,15 +73,6 @@ describe('LocalBashExecutor.run', () => {
|
||||
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
|
||||
})
|
||||
|
||||
it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {
|
||||
const { bash } = await setup() // setup pins graceMs: 200 via config
|
||||
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
bash.kill(task.id)
|
||||
await task.done
|
||||
expect(task.signal).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 60_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
|
||||
@@ -136,229 +112,175 @@ describe('LocalBashExecutor.run', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBashExecutor background tasks', () => {
|
||||
it('start returns immediately with a registered running task', async () => {
|
||||
describe('LocalBashExecutor.start (background process handles)', () => {
|
||||
it('start returns immediately with a running handle that settles as completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const before = Date.now()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
|
||||
const proc = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
|
||||
expect(Date.now() - before).toBeLessThan(150)
|
||||
expect(task.status).toBe('running')
|
||||
expect(bash.get(task.id)).toBe(task)
|
||||
expect(bash.list()).toContain(task)
|
||||
await task.done
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.exitCode).toBe(0)
|
||||
expect(proc.command).toBe('sleep 0.2; echo done')
|
||||
expect(proc.status).toBe('running')
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('assigns sequential ids', async () => {
|
||||
it('threads stdin and extra env into a background process', async () => {
|
||||
const { bash } = await setup()
|
||||
const first = bash.start(bash.resolve({ command: 'true' }))
|
||||
const second = bash.start(bash.resolve({ command: 'true' }))
|
||||
expect(first.id).toBe('bash-1')
|
||||
expect(second.id).toBe('bash-2')
|
||||
await Promise.all([first.done, second.done])
|
||||
})
|
||||
|
||||
it('threads stdin and extra env into a background task', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({
|
||||
const proc = bash.start(bash.resolve({
|
||||
command: 'cat; echo "[$DSH_BG_VAR]"',
|
||||
stdin: 'bg-stdin\n',
|
||||
env: { DSH_BG_VAR: 'bg-env' },
|
||||
}))
|
||||
const read = await readUntil(bash, task.id, '[bg-env]')
|
||||
expect(read.delta).toContain('bg-stdin')
|
||||
await task.done
|
||||
expect(task.exitCode).toBe(0)
|
||||
const output = await readUntil(proc, '[bg-env]')
|
||||
expect(output).toContain('bg-stdin')
|
||||
await proc.done
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('readOutput returns increments without re-delivery', async () => {
|
||||
it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
|
||||
const first = await readUntil(bash, task.id, 'first\n')
|
||||
expect(first.delta).toBe('first\n')
|
||||
expect(first.lossy).toBe(false)
|
||||
await task.done
|
||||
const second = bash.readOutput(task.id)
|
||||
const proc = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
|
||||
const first = await readUntil(proc, 'first\n')
|
||||
expect(first).toBe('first\n')
|
||||
await proc.done
|
||||
// Read-after-exit returns the remaining buffered output — once.
|
||||
const second = proc.readOutput()
|
||||
expect(second.delta).toBe('second\n')
|
||||
const third = bash.readOutput(task.id)
|
||||
expect(third.delta).toBe('')
|
||||
expect(second.lossy).toBe(false)
|
||||
expect(proc.readOutput().delta).toBe('')
|
||||
})
|
||||
|
||||
it('readOutput marks stderr sections', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
|
||||
await task.done
|
||||
const read = bash.readOutput(task.id)
|
||||
expect(read.delta).toBe('out\n[stderr]\nerr\n')
|
||||
const proc = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
|
||||
await proc.done
|
||||
expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput reports stderr-only deltas without a leading newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo err >&2' }))
|
||||
await task.done
|
||||
expect(bash.readOutput(task.id).delta).toBe('[stderr]\nerr\n')
|
||||
const proc = bash.start(bash.resolve({ command: 'echo err >&2' }))
|
||||
await proc.done
|
||||
expect(proc.readOutput().delta).toBe('[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput flags lossy reads and reports spill paths', async () => {
|
||||
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
|
||||
await proc.done
|
||||
expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput flags lossy reads and reports stdout spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
|
||||
await task.done
|
||||
const read = bash.readOutput(task.id)
|
||||
const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
// Window slid past offset 0 → lossy, spill path points at the full stream.
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stdoutSpillPath).toBeDefined()
|
||||
})
|
||||
|
||||
it('readOutput throws for unknown ids', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
||||
})
|
||||
|
||||
it('kill terminates the process group and reports status killed', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
expect(bash.kill(task.id)).toBe(true)
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('kill returns false for finished tasks and throws for unknown ids', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(bash.kill(task.id)).toBe(false)
|
||||
expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
||||
})
|
||||
|
||||
it('notifies onTaskDone listeners on completion', async () => {
|
||||
const { bash } = await setup()
|
||||
const seen: [string, string][] = []
|
||||
bash.onTaskDone(task => void seen.push([task.id, task.status]))
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(seen).toEqual([[task.id, 'completed']])
|
||||
})
|
||||
|
||||
it('notifies onTaskDone for killed tasks too', async () => {
|
||||
const { bash } = await setup()
|
||||
const listener = vi.fn()
|
||||
bash.onTaskDone(listener)
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
bash.kill(task.id)
|
||||
await task.done
|
||||
expect(listener).toHaveBeenCalledWith(task)
|
||||
expect(task.status).toBe('killed')
|
||||
})
|
||||
|
||||
it('marks tasks killed when the background spawn itself fails', async () => {
|
||||
const { bash } = await setup()
|
||||
const listener = vi.fn()
|
||||
bash.onTaskDone(listener)
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(listener).toHaveBeenCalledWith(task)
|
||||
expect(bash.readOutput(task.id).delta).toContain('spawn failed')
|
||||
})
|
||||
|
||||
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
|
||||
await task.done
|
||||
expect(bash.readOutput(task.id).delta).toBe('out\n[stderr]\nerr\n')
|
||||
})
|
||||
|
||||
it('readOutput reports stderr spill paths', async () => {
|
||||
const { bash } = await setup({ maxOutputBytes: 100 })
|
||||
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
|
||||
await task.done
|
||||
const read = bash.readOutput(task.id)
|
||||
const proc = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
|
||||
await proc.done
|
||||
const read = proc.readOutput()
|
||||
expect(read.lossy).toBe(true)
|
||||
expect(read.stderrSpillPath).toBeDefined()
|
||||
expect(read.delta).toContain('[stderr]')
|
||||
})
|
||||
|
||||
it('disposing with already-finished tasks only kills the running ones', async () => {
|
||||
it('kill() terminates the process group: true once, false after settlement', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
expect(proc.kill()).toBe(true)
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.signal).toBe('SIGTERM')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('kill() returns false for a naturally completed process', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'true' }))
|
||||
await proc.done
|
||||
expect(proc.status).toBe('completed')
|
||||
expect(proc.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('kill escalation uses the configured graceMs (a TERM-trapping process dies by SIGKILL)', async () => {
|
||||
const { bash } = await setup() // setup pins graceMs: 200 via config
|
||||
// The child echoes AFTER arming the trap, so waiting for the marker
|
||||
// guarantees SIGTERM is already ignored when the kill lands (a fixed sleep
|
||||
// is load-flaky: a slow spawn would take the SIGTERM before the trap).
|
||||
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
|
||||
await readUntil(proc, 'armed')
|
||||
proc.kill()
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.signal).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('a spec.signal abort settles the handle as killed, not completed', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const proc = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
|
||||
controller.abort()
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('a background spawn failure settles as killed with the error readable on stderr', async () => {
|
||||
const { bash } = await setup()
|
||||
const proc = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
|
||||
// done resolves (never rejects) even though the process never ran.
|
||||
await expect(proc.done).resolves.toBeUndefined()
|
||||
expect(proc.status).toBe('killed')
|
||||
expect(proc.readOutput().delta).toContain('spawn failed:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBashExecutor disposal', () => {
|
||||
it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'true' }))
|
||||
// The child prints its own pid ($$ = the detached bash group leader) so
|
||||
// the test can probe liveness through the public read surface alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
await fiber.dispose()
|
||||
// Disposal itself waited: the pid must already be gone, no grace left —
|
||||
// even for a TERM-trapping child held until the SIGKILL escalation landed.
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
expect(proc.status).toBe('killed')
|
||||
await proc.done
|
||||
})
|
||||
|
||||
it('settled processes already left the live map: dispose does not touch them', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'echo done' }))
|
||||
await finished.done
|
||||
expect(finished.status).toBe('completed')
|
||||
const running = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
|
||||
await fiber.dispose()
|
||||
await running.done
|
||||
// The teardown marks every LIVE entry killed; a settled process had
|
||||
// already left the map, so its status stays completed.
|
||||
expect(finished.status).toBe('completed')
|
||||
expect(running.status).toBe('killed')
|
||||
await running.done
|
||||
expect(running.signal).toBe('SIGTERM')
|
||||
expect(bash.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('disposing the executor fiber kills running tasks (no orphans)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
const listener = vi.fn()
|
||||
bash.onTaskDone(listener)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
const running = bash.get(task.id)!
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Grab the pid before dispose clears the registry.
|
||||
const pid = (running as unknown as { running: { pid: number } }).running.pid
|
||||
await fiber.dispose()
|
||||
await waitGone(pid)
|
||||
expect(bash.list()).toEqual([])
|
||||
// Listener silenced by base-class teardown — no late notifications.
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: lifecycle hardening', () => {
|
||||
it('start honors a pre-aborted or later-aborted AbortSignal', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
|
||||
controller.abort()
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('a throwing onTaskDone listener does not reject task.done or starve later listeners', async () => {
|
||||
const { bash } = await setup()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const second = vi.fn()
|
||||
try {
|
||||
bash.onTaskDone(() => { throw new Error('listener bug') })
|
||||
bash.onTaskDone(second)
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await expect(task.done).resolves.toBeUndefined()
|
||||
expect(second).toHaveBeenCalledWith(task)
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
const pid = (task as unknown as { running: { pid: number } }).running.pid
|
||||
|
||||
await fiber.dispose()
|
||||
// Disposal itself waited: the pid must already be gone, no grace left.
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
expect(task.status).toBe('killed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-bash
|
||||
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands, start background processes — without saying HOW.
|
||||
|
||||
This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
|
||||
@@ -8,7 +8,7 @@ This package is one third of the bash capability, split so each concern can evol
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
|
||||
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
|
||||
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schema over `ctx.bash` |
|
||||
|
||||
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
|
||||
|
||||
@@ -16,18 +16,14 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(request)` | Fill a caller's `BashExecRequest` (optional `workdir`/`timeoutMs`) into a fully-resolved `BashExecSpec` from the implementation's config defaults and caps — the explicit defaulting step consumers call before `run`/`start`. |
|
||||
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
|
||||
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
|
||||
| `get(id)` / `list()` | Task lookup. |
|
||||
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
|
||||
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
|
||||
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
|
||||
| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. |
|
||||
| `start(spec)` | Background execution. Returns a `BashProcess` handle immediately; **no timeout applies** (stop the process via the handle's `kill()` or the spec's AbortSignal). |
|
||||
|
||||
Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests.
|
||||
The seam is deliberately TASK-FREE: `start()` hands back only the `BashProcess` handle — `command`, `status`, `exitCode`/`signal`, a never-rejecting `done` quiescence promise, a consuming incremental `readOutput()` (reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files; reads stay valid after exit), and an idempotent `kill()`. Task ids, cross-session isolation, polling tools, and completion notices are the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md)'s job — the tool layer adapts the handle into a task registration — which keeps a remote/sandbox executor free of any session or registry dependency. Disposal must kill every running background process and await its exit (no orphan processes) — see the HMR-safety tests.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?) before execution. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()` returns `BashProcess`, whose `readOutput()` yields a `BashProcessRead`. See `src/types.ts` for the full contracts.
|
||||
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec: a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
@@ -22,11 +22,9 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
/**
|
||||
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
|
||||
* bash backend does — run commands, manage background tasks — without saying
|
||||
* HOW. Implementations subclass {@link BashExecutor} and register themselves
|
||||
* as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
|
||||
* is the first. Future implementations swap in sandboxes, containers, or
|
||||
* remote exec servers without touching the tool schemas that consume them
|
||||
* (`@deepseek-ai/dsh-tool-bash`).
|
||||
* bash backend does — run foreground commands, start background processes —
|
||||
* without saying HOW. Implementations subclass {@link BashExecutor} and
|
||||
* register themselves as the `bash` service; `@deepseek-ai/dsh-bash-local`
|
||||
* (local subprocesses) is the first. Future implementations swap in
|
||||
* sandboxes, containers, or remote exec servers without touching the tool
|
||||
* schemas that consume them (`@deepseek-ai/dsh-tool-bash`).
|
||||
*
|
||||
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
|
||||
* surveyed agents: pi hides execution behind a `BashOperations` interface
|
||||
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
|
||||
*
|
||||
* The seam is deliberately TASK-FREE: `start()` hands back a
|
||||
* {@link BashProcess} handle (incremental reads, kill, a quiescence promise)
|
||||
* and nothing else. Task ids, owner isolation, polling tools, and completion
|
||||
* notices are the generic `ctx.tasks` runtime's job (`@deepseek-ai/dsh-tasks`)
|
||||
* — the tool layer adapts the handle into a task registration. This keeps a
|
||||
* remote/sandbox executor free of any session or registry dependency.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
|
||||
|
||||
export { BashTaskId, OwnerToken } from './types.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
BashExecSpec,
|
||||
BashProcess,
|
||||
BashProcessRead,
|
||||
BashProcessStatus,
|
||||
BashRunResult,
|
||||
BashTask,
|
||||
BashTaskListener,
|
||||
BashTaskRead,
|
||||
BashTaskStatus,
|
||||
CollectedOutput,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -47,27 +52,19 @@ declare module 'cordis' {
|
||||
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
|
||||
* a failed command is the tool layer's job, not an exception.
|
||||
* - {@link start} returns immediately; no timeout applies to background
|
||||
* tasks (callers stop them via {@link kill} or the spec's AbortSignal).
|
||||
* Completion must fire the {@link onTaskDone} listeners exactly once per
|
||||
* task, and must NOT fire after the service is disposed.
|
||||
* - {@link readOutput} is incremental: consecutive reads never re-deliver
|
||||
* output. Implementations bound their buffers; reads that lost data flag
|
||||
* `lossy` and point at full-stream spill files when available.
|
||||
* - Disposal kills every running task and awaits their exit (no orphan
|
||||
* processes survive `fiber.dispose()`).
|
||||
* processes (callers stop them via {@link BashProcess.kill} or the spec's
|
||||
* AbortSignal). The handle's `done` settles at process close and never
|
||||
* rejects (a spawn failure settles as `killed` with the error readable on
|
||||
* stderr).
|
||||
* - {@link BashProcess.readOutput} is incremental: consecutive reads never
|
||||
* re-deliver output. Implementations bound their buffers; reads that lost
|
||||
* data flag `lossy` and point at full-stream spill files when available.
|
||||
* - Disposal kills every running background process and awaits their exit
|
||||
* (no orphan processes survive `fiber.dispose()`).
|
||||
*/
|
||||
export abstract class BashExecutor extends Service {
|
||||
private listeners = new Set<BashTaskListener>()
|
||||
private listenersClosed = false
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'bash')
|
||||
ctx.effect(() => () => {
|
||||
// Close the listener registry before subclass teardown so late task
|
||||
// completions (e.g. from kills issued during dispose) stay silent.
|
||||
this.listenersClosed = true
|
||||
this.listeners.clear()
|
||||
}, 'bash listener teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,86 +89,11 @@ export abstract class BashExecutor extends Service {
|
||||
abstract run(spec: BashExecSpec): Promise<BashRunResult>
|
||||
|
||||
/**
|
||||
* Start a background task and return its handle immediately.
|
||||
* Start a background process and return its handle immediately.
|
||||
* @param spec - a resolved spec from {@link resolve}, never a raw request.
|
||||
* @returns the live task handle; completion fires {@link onTaskDone}.
|
||||
* @returns the live process handle (reads, kill, quiescence promise).
|
||||
*/
|
||||
abstract start(spec: BashExecSpec): BashTask
|
||||
|
||||
/**
|
||||
* Look up a background task by id.
|
||||
* @param id - the task id to look up.
|
||||
* @returns the tracked task, or undefined for an id this executor never issued.
|
||||
*/
|
||||
abstract get(id: BashTaskId): BashTask | undefined
|
||||
|
||||
/**
|
||||
* The opaque OWNER token recorded for a background task at {@link start}
|
||||
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
|
||||
* OR a known-but-ownerless task. The executor stores and returns the token
|
||||
* verbatim — it never interprets it; the access POLICY (who may read/kill a
|
||||
* task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
|
||||
* `ownerOf(id)` to the caller's token. Collapsing unknown-id and
|
||||
* known-but-unowned into the same `undefined` is fine: the consumer's access
|
||||
* gate treats `undefined` as "open", and a genuinely unknown id then fails
|
||||
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
|
||||
* Storing ownership in the executor (disposed with ITS fiber) — not in the
|
||||
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
|
||||
* @param id - the background task id to look up ownership for.
|
||||
* @returns the token recorded at start, verbatim; undefined for an unknown
|
||||
* id or a known-but-ownerless task.
|
||||
*/
|
||||
abstract ownerOf(id: BashTaskId): OwnerToken | undefined
|
||||
|
||||
/**
|
||||
* All tracked background tasks (insertion order).
|
||||
* @returns every task this executor started, running or finished.
|
||||
*/
|
||||
abstract list(): BashTask[]
|
||||
|
||||
/**
|
||||
* Read output produced since the previous read. Throws for unknown ids.
|
||||
* @param id - the task to read from.
|
||||
* @returns the incremental read; consecutive reads never re-deliver output.
|
||||
*/
|
||||
abstract readOutput(id: BashTaskId): BashTaskRead
|
||||
|
||||
/**
|
||||
* Kill a running background task. Returns false when it had already
|
||||
* finished (no-op). Throws for unknown ids.
|
||||
* @param id - the task to kill.
|
||||
* @returns true when this call killed it, false when it had already finished.
|
||||
*/
|
||||
abstract kill(id: BashTaskId): boolean
|
||||
|
||||
/**
|
||||
* Register a background-task completion listener (disposed with the
|
||||
* calling fiber). Listeners never fire after this service is disposed.
|
||||
* @param listener - called exactly once per task completion.
|
||||
* @returns the disposer that unregisters the listener.
|
||||
*/
|
||||
onTaskDone(listener: BashTaskListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}, 'bash.onTaskDone()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** For implementations: notify listeners that `task` completed. Listener
|
||||
* exceptions are contained (logged) — one bad listener must not reject
|
||||
* `BashTask.done` or starve the listeners after it. */
|
||||
protected notifyTaskDone(task: BashTask): void {
|
||||
if (this.listenersClosed) return
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(task)
|
||||
} catch (error: unknown) {
|
||||
// Listener bugs are reported, never propagated into task.done.
|
||||
console.error('bash onTaskDone listener threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
abstract start(spec: BashExecSpec): BashProcess
|
||||
}
|
||||
|
||||
export default BashExecutor
|
||||
|
||||
@@ -3,43 +3,15 @@
|
||||
* service lives in `./index.ts`, implementations in sibling packages
|
||||
* (`@deepseek-ai/dsh-bash-local` first).
|
||||
*
|
||||
* Background TASK semantics (ids, ownership, polling protocol, completion
|
||||
* listeners) deliberately do NOT live here: the seam starts a background
|
||||
* PROCESS and returns a {@link BashProcess} handle; the caller (the tool
|
||||
* layer) registers that handle with the generic `ctx.tasks` runtime
|
||||
* (`@deepseek-ai/dsh-tasks`), which owns everything task-shaped.
|
||||
*
|
||||
* @module dsh-bash/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Identifies one background task within an executor (generated `bash-N`). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link BashTaskId}.
|
||||
* @param id - the raw task-id string (the executor generates `bash-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function BashTaskId(id: string): BashTaskId {
|
||||
return id as BashTaskId
|
||||
}
|
||||
|
||||
/**
|
||||
* A background task's opaque isolation key — the CONSUMER's owner identity, not
|
||||
* the bash seam's. The executor stores and returns it verbatim and never
|
||||
* interprets it; the access policy lives in the consumer (`dsh-tool-bash`),
|
||||
* which is the single boundary that casts its own id vocabulary into one. A
|
||||
* DISTINCT brand (not a `SessionId` alias) keeps the seam decoupled — a
|
||||
* sandboxed/remote executor inherits no session dependency.
|
||||
*/
|
||||
export type OwnerToken = Branded<'OwnerToken'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link OwnerToken}. Only the consuming boundary
|
||||
* (`dsh-tool-bash`) should cast its own id vocabulary in — see the type's doc.
|
||||
* @param id - the consumer's raw owner identity (the tool layer passes the owning agent's session id).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
|
||||
* filled by {@link BashExecutor.resolve} from the implementation's config.
|
||||
@@ -72,15 +44,6 @@ export interface BashExecRequest {
|
||||
* uses shell syntax like `FOO=bar cmd`).
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Opaque OWNER token for a background task — the consumer's isolation key
|
||||
* (the tool layer passes the owning agent's `session.header.id`). The
|
||||
* executor stores it on the task and exposes it via {@link BashExecutor.ownerOf};
|
||||
* the executor itself NEVER interprets it (no access policy lives in the
|
||||
* seam — that is the consumer's job). Absent for foreground runs and for an
|
||||
* ownerless background start (a non-agent caller).
|
||||
*/
|
||||
owner?: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +51,7 @@ export interface BashExecRequest {
|
||||
* {@link BashExecutor.start} act on. `workdir` and `timeoutMs` are REQUIRED:
|
||||
* defaulting and capping already happened in {@link BashExecutor.resolve}, so
|
||||
* the executor never hides a `?? config` fallback (explicit > implicit). For
|
||||
* background tasks, `start()` ignores `timeoutMs` (background runs have no
|
||||
* background processes, `start()` ignores `timeoutMs` (background runs have no
|
||||
* timeout) — the field is still required because the type is shared.
|
||||
*/
|
||||
export interface BashExecSpec {
|
||||
@@ -99,10 +62,10 @@ export interface BashExecSpec {
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the command's stdin (then close it), carried through
|
||||
* verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec
|
||||
* (unlike `owner`): it has no config default, so a missing one means "no
|
||||
* stdin" — the safe, ordinary case — not a silent footgun, so it stays a
|
||||
* plain optional rather than required-but-nullable (see the request field).
|
||||
* verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec:
|
||||
* it has no config default, so a missing one means "no stdin" — the safe,
|
||||
* ordinary case — not a silent footgun, so it stays a plain optional rather
|
||||
* than required-but-nullable (see the request field).
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
@@ -113,15 +76,6 @@ export interface BashExecSpec {
|
||||
* config default, absent means "no extra env".
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
|
||||
* being required on the resolved spec): {@link BashExecutor.resolve} carries
|
||||
* the request's `owner` through, defaulting a missing one to `undefined`. A
|
||||
* required field makes a forgotten owner a VISIBLE `undefined` rather than a
|
||||
* silently-absent property that yields an unowned (cross-session-readable)
|
||||
* task. `start()` stores it; `run()` (foreground) ignores it.
|
||||
*/
|
||||
owner: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
@@ -150,25 +104,11 @@ export interface BashRunResult {
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/** Lifecycle of a background task. */
|
||||
export type BashTaskStatus = 'running' | 'completed' | 'killed'
|
||||
/** Lifecycle of a background process. */
|
||||
export type BashProcessStatus = 'running' | 'completed' | 'killed'
|
||||
|
||||
/** A tracked background task handle. */
|
||||
export interface BashTask {
|
||||
readonly id: BashTaskId
|
||||
readonly command: string
|
||||
status: BashTaskStatus
|
||||
/** Exit code once finished (null = killed by signal / still running). */
|
||||
exitCode: number | null
|
||||
/** Terminating signal name, when signal-killed. */
|
||||
signal: NodeJS.Signals | null
|
||||
/** Resolves when the underlying process closes (never rejects). */
|
||||
readonly done: Promise<void>
|
||||
}
|
||||
|
||||
/** One incremental {@link BashExecutor.readOutput} read. */
|
||||
export interface BashTaskRead {
|
||||
task: BashTask
|
||||
/** One incremental {@link BashProcess.readOutput} read. */
|
||||
export interface BashProcessRead {
|
||||
/** Output produced since the previous read (stderr in a marked section). */
|
||||
delta: string
|
||||
/** True when truncation dropped unread bytes the delta cannot include. */
|
||||
@@ -179,5 +119,34 @@ export interface BashTaskRead {
|
||||
stderrSpillPath?: string
|
||||
}
|
||||
|
||||
/** Completion callback for background tasks. */
|
||||
export type BashTaskListener = (task: BashTask) => void
|
||||
/**
|
||||
* A live background process handle, returned by {@link BashExecutor.start}.
|
||||
* The HANDLE is the only access path (no executor-level id lookup): the
|
||||
* caller holds it, adapts it into a `ctx.tasks` registration, or drops it.
|
||||
* Reads stay valid after the process exits (the remaining buffered output is
|
||||
* still consumable); the executor's own disposal kills every running process
|
||||
* and awaits {@link done}.
|
||||
*/
|
||||
export interface BashProcess {
|
||||
/** The command line this process runs. */
|
||||
readonly command: string
|
||||
/** Process lifecycle state (settled exactly once). */
|
||||
status: BashProcessStatus
|
||||
/** Exit code once finished (null = killed by signal / still running). */
|
||||
exitCode: number | null
|
||||
/** Terminating signal name, when signal-killed. */
|
||||
signal: NodeJS.Signals | null
|
||||
/** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
|
||||
readonly done: Promise<void>
|
||||
/**
|
||||
* Read output produced since the previous read (consuming — consecutive
|
||||
* reads never re-deliver). Reads that lost data flag `lossy` and point at
|
||||
* full-stream spill files when available.
|
||||
*/
|
||||
readOutput(): BashProcessRead
|
||||
/**
|
||||
* Kill the process group. Returns false when it had already finished
|
||||
* (no-op); idempotent.
|
||||
*/
|
||||
kill(): boolean
|
||||
}
|
||||
|
||||
@@ -1,145 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/** Minimal concrete executor: records calls, lets tests drive completions. */
|
||||
/**
|
||||
* Minimal concrete executor: canned foreground results, a hand-built process
|
||||
* handle. The seam is TASK-FREE (start returns a {@link BashProcess} handle;
|
||||
* task semantics live in `ctx.tasks`), so this stub is all an implementation
|
||||
* owes the abstract class.
|
||||
*/
|
||||
class StubExecutor extends BashExecutor {
|
||||
tasks = new Map<BashTaskId, BashTask>()
|
||||
private owners = new Map<BashTaskId, OwnerToken | undefined>()
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/stub',
|
||||
timeoutMs: request.timeoutMs ?? 1000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
|
||||
async run(_spec: BashExecSpec): Promise<BashRunResult> {
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: 'ok', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashTask {
|
||||
const task: BashTask = {
|
||||
id: BashTaskId(`stub-${this.tasks.size + 1}`),
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
const proc: BashProcess = {
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: (): BashProcessRead => ({ delta: '', lossy: false }),
|
||||
kill: (): boolean => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.tasks.set(task.id, task)
|
||||
this.owners.set(task.id, spec.owner)
|
||||
return task
|
||||
return proc
|
||||
}
|
||||
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
return this.owners.get(id)
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
return { task, delta: '', lossy: false }
|
||||
}
|
||||
|
||||
kill(id: BashTaskId): boolean {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
if (task.status !== 'running') return false
|
||||
task.status = 'killed'
|
||||
return true
|
||||
}
|
||||
|
||||
/** Expose the protected notifier for tests. */
|
||||
fire(task: BashTask): void {
|
||||
this.notifyTaskDone(task)
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubExecutor)
|
||||
// ctx.bash resolves to the registered implementation.
|
||||
const bash = ctx.bash as StubExecutor
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
describe('BashExecutor service seam', () => {
|
||||
it('registers as ctx.bash and serves the abstract API', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 1' }))
|
||||
expect(bash.get(task.id)).toBe(task)
|
||||
expect(bash.list()).toEqual([task])
|
||||
expect(bash.kill(task.id)).toBe(true)
|
||||
expect(bash.kill(task.id)).toBe(false)
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('onTaskDone delivers completions to registered listeners', async () => {
|
||||
const { bash } = await setup()
|
||||
const seen: string[] = []
|
||||
bash.onTaskDone(task => void seen.push(task.id))
|
||||
const task = bash.start(bash.resolve({ command: 'x' }))
|
||||
bash.fire(task)
|
||||
expect(seen).toEqual([task.id])
|
||||
})
|
||||
|
||||
it('onTaskDone disposer unsubscribes the listener', async () => {
|
||||
const { bash } = await setup()
|
||||
const listener = vi.fn()
|
||||
const dispose = bash.onTaskDone(listener)
|
||||
dispose()
|
||||
bash.fire(bash.start(bash.resolve({ command: 'x' })))
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('listeners registered from a fiber are removed on dispose (HMR safety)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const listener = vi.fn()
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.bash.onTaskDone(listener)
|
||||
}, { inject: ['bash'] }))
|
||||
bash.fire(bash.start(bash.resolve({ command: 'one' })))
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
await fiber.dispose()
|
||||
bash.fire(bash.start(bash.resolve({ command: 'two' })))
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('silences listeners once the service fiber is disposed', async () => {
|
||||
it('a concrete subclass registers as ctx.bash and serves the abstract API', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
await inner.plugin(StubExecutor)
|
||||
}, {}))
|
||||
const bash = ctx.bash as StubExecutor
|
||||
const listener = vi.fn()
|
||||
bash.onTaskDone(listener)
|
||||
const task = bash.start(bash.resolve({ command: 'x' }))
|
||||
await ctx.plugin(StubExecutor)
|
||||
const spec = ctx.bash.resolve({ command: 'echo hi' })
|
||||
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000 })
|
||||
|
||||
await fiber.dispose()
|
||||
bash.fire(task)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
const result = await ctx.bash.run(spec)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('ok')
|
||||
|
||||
const proc = ctx.bash.start(spec)
|
||||
expect(proc.command).toBe('echo hi')
|
||||
expect(proc.status).toBe('running')
|
||||
expect(proc.readOutput()).toEqual({ delta: '', lossy: false })
|
||||
expect(proc.kill()).toBe(true)
|
||||
expect(proc.kill()).toBe(false) // already settled → no-op
|
||||
await proc.done
|
||||
})
|
||||
|
||||
it('loading a second implementation throws (one bash service per context — cordis standard)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubExecutor)
|
||||
class SecondExecutor extends StubExecutor {}
|
||||
await expect(ctx.plugin(SecondExecutor)).rejects.toThrow(/service "bash" has been registered/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# @deepseek-ai/dsh-tool-bash
|
||||
|
||||
The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees.
|
||||
The model-facing `bash` tool, registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees. Background runs are generic tasks: the tool registers the started process with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and the model collects/stops them through the shared `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`) — this package registers no companion tools of its own.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). The `ctx.tasks` runtime is looked up at call time: a background call without it fails loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`).
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
## Tools
|
||||
## Config
|
||||
|
||||
### `bash`
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `enableRunInBackground` | `true` | Expose `run_in_background` in the schema. Disabled, the parameter is absent entirely (schema and capability never disagree) and the description says background execution is unavailable. |
|
||||
|
||||
## The `bash` tool
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
@@ -16,35 +20,23 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
|
||||
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. Present only when `enableRunInBackground` allows. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
|
||||
Foreground result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
|
||||
|
||||
### `bash_output`
|
||||
## Background runs as tasks
|
||||
|
||||
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
|
||||
|
||||
### `bash_kill`
|
||||
|
||||
`task_id` → ask the executor to kill the background task. The concrete executor decides how to signal or stop the process; killing an already-finished task is a reported no-op, and unknown ids are errors.
|
||||
|
||||
### Task ownership (cross-session isolation)
|
||||
|
||||
The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task <id> belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
A `run_in_background` call refuses an already-aborted `exec.signal`, starts the process through the seam, registers `{ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput }` with `ctx.tasks`, and returns `started background task <id>`. The tool-call signal is deliberately NOT wired to the process after that — the parent step may end while the command runs; cancellation belongs to `task_kill` and the runtime's owner-disposal cleanup. The producer mapping is exported for tests: `processOutcome` (a killed process → `killed` with the signal as detail; everything else → `completed` with `exit code: N` — a nonzero exit is reported, not failed) and `renderProcessRead` (the incremental delta, plus a `[some output was dropped from memory; full output: …]` notice with spill paths on lossy reads). Ownership, isolation, listing, polling, waiting, kill semantics, and completion notices are all the task runtime's — see [`packages/tasks`](../../tasks/README.md).
|
||||
|
||||
## UI presentation
|
||||
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## Background completion notices
|
||||
|
||||
When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — its output is read via `task_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions
|
||||
|
||||
|
||||
@@ -26,9 +26,13 @@
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^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-agent-loop": "workspace:^",
|
||||
@@ -37,6 +41,8 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
/**
|
||||
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
|
||||
* schema + text shaping — every process concern lives behind the `ctx.bash`
|
||||
* executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
|
||||
* executor implementations swap in without touching what the model sees.
|
||||
* The model-facing `bash` tool. Pure schema + text shaping — every process
|
||||
* concern lives behind the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`),
|
||||
* so sandbox/permission/remote executor implementations swap in without
|
||||
* touching what the model sees.
|
||||
*
|
||||
* Background notifications: when a background task completes, a short notice
|
||||
* is injected into the owning agent's session (`agent.inject()` — the
|
||||
* documented context seam). Injection is durable context for the NEXT model
|
||||
* request, not a wake-up: an idle agent stays idle until something sends a
|
||||
* message, which is why the tool descriptions tell the model to poll with
|
||||
* `bash_output`.
|
||||
* Background runs are TASKS, not bash-private state: `run_in_background`
|
||||
* starts a process through the seam and registers its handle with the generic
|
||||
* `ctx.tasks` runtime (`@deepseek-ai/dsh-tasks`), which owns the id, the
|
||||
* owner fence, the completion notice, and the model-facing collect/stop
|
||||
* tools (`task_output`/`task_list`/`task_kill` from
|
||||
* `@deepseek-ai/dsh-tool-tasks`). Whether the parameter is exposed at all is
|
||||
* THIS plugin's `enableRunInBackground` config (default on) — the registry
|
||||
* never rewrites a producer's schema.
|
||||
*
|
||||
* Task ownership: a background task's OWNER is an opaque token — the owning
|
||||
* agent's `session.header.id` — passed to the executor at spawn
|
||||
* (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
|
||||
* (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
|
||||
* `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
|
||||
* and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
|
||||
* !== caller`); an unowned task (no token — started by a non-agent caller) is
|
||||
* open to anyone. Task ids are global and predictable (`bash-1`, …); under
|
||||
* multi-session ACP (RFC 011) this token check is the fence that stops one
|
||||
* session's agent from reading or killing another session's background task.
|
||||
*
|
||||
* Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
|
||||
* fiber), rather than in this plugin, is what makes ownership survive a
|
||||
* `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
|
||||
* a task spawned before it. (The `onTaskDone` listener is still effect-scoped
|
||||
* to this plugin's `apply`, so a
|
||||
* completion landing during the reload gap still drops its one notice — the
|
||||
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
* The tool-call abort signal is deliberately NOT wired to a background
|
||||
* process: after the task id is returned the parent step may end while the
|
||||
* work continues; cancellation belongs to `task_kill` and the owner-disposal
|
||||
* cleanup. A signal already aborted before the call refuses to start.
|
||||
*
|
||||
* TODO(permissions): commands run with the executor's full authority. The
|
||||
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
|
||||
@@ -39,17 +27,33 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type { BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/** Config: whether the model may background commands (the producer-opt-in flag). */
|
||||
export interface Config {
|
||||
/**
|
||||
* Expose `run_in_background` in the bash schema (default true). Disabled,
|
||||
* the parameter is absent entirely — schema and capability never disagree.
|
||||
* 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
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
@@ -76,18 +80,6 @@ function validateBashArgs(args: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an empty `task_id`. Type and presence are guaranteed by the
|
||||
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
|
||||
* DSL can't express, is left to check here.
|
||||
*/
|
||||
function validateTaskId(value: string): BashTaskId {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
return BashTaskId(value)
|
||||
}
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
if (!output.truncated) return output.text
|
||||
@@ -131,6 +123,39 @@ export function renderResult(result: BashRunResult): string {
|
||||
return body + markers.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape one background-process read into the `task_output` delta the model
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes. Empty-delta
|
||||
* rendering (`(no new output)`) is the control surface's job, not this
|
||||
* producer's. Exported for tests.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @returns the delta text with any loss notice appended.
|
||||
*/
|
||||
export function renderProcessRead(read: BashProcessRead): string {
|
||||
if (!read.lossy) return read.delta
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
|
||||
const notice = `[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`
|
||||
if (read.delta.length === 0) return notice
|
||||
return `${read.delta}${read.delta.endsWith('\n') ? '' : '\n'}${notice}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a settled background process onto the generic task-outcome vocabulary:
|
||||
* `killed` stays `killed` (detail: the signal when one is known), everything
|
||||
* else is `completed` with the exit code as detail — a nonzero exit is
|
||||
* REPORTED, not failed, exactly like the foreground rendering. Exported for
|
||||
* tests.
|
||||
* @param proc - the settled process handle.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
|
||||
// renders a bash call's pending and completed states. They are display-only and
|
||||
@@ -153,7 +178,7 @@ export function renderResult(result: BashRunResult): string {
|
||||
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
|
||||
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
|
||||
* immediately (it never streams a terminal; its output is polled via
|
||||
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
|
||||
* `task_output`), so it is NOT marked terminal and renders as an ordinary
|
||||
* execute card. For a foreground run the `terminal.cwd` (header) is the model
|
||||
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
|
||||
* against the session cwd; when omitted the bridge fills the session workspace
|
||||
@@ -253,11 +278,6 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string
|
||||
return { exitCode: 0 }
|
||||
}
|
||||
|
||||
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
|
||||
function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
|
||||
return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for a bash call. Precedence: an explicit model
|
||||
* `workdir` wins; otherwise default to the calling agent's session cwd
|
||||
@@ -278,18 +298,11 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Status line for background task reads. */
|
||||
function statusLine(task: BashTask): string {
|
||||
switch (task.status) {
|
||||
case 'running': return '[status: running]'
|
||||
case 'killed': return `[status: killed${task.signal !== null ? ` by ${task.signal}` : ''}]`
|
||||
case 'completed': return `[status: completed, exit code: ${task.exitCode ?? 0}]`
|
||||
}
|
||||
}
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// The bash tools' cross-call HABIT, which the per-tool descriptions cannot
|
||||
// carry (they describe one call each): the exit-code marker is only useful
|
||||
// The bash tool's cross-call HABIT, which the per-tool description cannot
|
||||
// carry (it describes one call): the exit-code marker is only useful
|
||||
// if the model actually checks it every time.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
@@ -297,72 +310,16 @@ export function apply(ctx: Context): void {
|
||||
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's owner TOKEN — the owning agent's `session.header.id`, or
|
||||
* `undefined` for a non-agent caller. Read `session.header.id` (NOT
|
||||
* `session.id`): every other subsystem keys off the header id (the ACP bridge,
|
||||
* both persistence backends), and the sibling `resolveWorkdir` already reads
|
||||
* `session.header.cwd`, so using `session.id` here would be the asymmetry smell
|
||||
* the conventions flag. The two are equal in production, but the header is the
|
||||
* canonical identity.
|
||||
*/
|
||||
const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
|
||||
exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined
|
||||
|
||||
/**
|
||||
* Authorize a `bash_output`/`bash_kill` call against the task's stored owner
|
||||
* token. Rejects when the task HAS an owner and it differs from the caller's
|
||||
* token — using `!== undefined` semantics, NOT truthiness, so an empty-string
|
||||
* token is still a real owner (never treated as unowned). An unowned task
|
||||
* (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also
|
||||
* `undefined` here and then fails loudly at the subsequent
|
||||
* `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
|
||||
* (`callerToken` undefined) cannot match an owned task and is rejected.
|
||||
*/
|
||||
const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
|
||||
const owner = ctx.bash.ownerOf(taskId)
|
||||
if (owner !== undefined && owner !== callerToken(exec)) {
|
||||
throw new Error(`task ${taskId} belongs to another session`)
|
||||
}
|
||||
}
|
||||
|
||||
// Background completion → inject a notice into the owning agent's session.
|
||||
// Find the live agent by its session id token via the agent registry, read
|
||||
// opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
|
||||
// this listener runs from `task.done.then` on the bash fiber — a foreign
|
||||
// fiber — where the `ctx.agents` property proxy would throw through the
|
||||
// traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
|
||||
// registry mounted (`undefined`) → drop the notice. Match on
|
||||
// `agent.session.header.id`, NOT the registry key: a config agent's id differs
|
||||
// from its session id, and the owner token IS the session id.
|
||||
ctx.bash.onTaskDone((task) => {
|
||||
const ownerToken = ctx.bash.ownerOf(task.id)
|
||||
if (ownerToken === undefined) return
|
||||
const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken)
|
||||
if (!agent) return
|
||||
try {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `background bash task ${task.id} finished ${statusLine(task)}. Read its output with bash_output.` }],
|
||||
{ source: { kind: 'plugin', plugin: 'tool-bash' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// The ONE expected failure: the agent was disposed between task
|
||||
// completion and this injection (ReactLoopAgent.inject throws
|
||||
// `agent "<id>" is disposed`). That race is benign — drop the notice.
|
||||
// Anything else is a real bug and must surface, not be swallowed.
|
||||
if (error instanceof Error && error.message.includes('is disposed')) return
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash',
|
||||
description: 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.',
|
||||
+ (backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'read its output with `task_output` and stop it with `task_kill`.'
|
||||
: 'Background execution is not available; long-running commands must finish within the timeout.'),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The bash command to execute.' },
|
||||
description: {
|
||||
@@ -374,7 +331,9 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
|
||||
} : {},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
validateBashArgs(args)
|
||||
@@ -389,65 +348,48 @@ export function apply(ctx: Context): void {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Stamp the owner token (the agent's session id) onto the spec so the
|
||||
// executor stores it on the task — the isolation fence for bash_output/
|
||||
// bash_kill. Foreground runs pass no owner (they finish inline; nothing
|
||||
// to fence).
|
||||
const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
|
||||
return [{ type: 'text', text: `started background task ${task.id}` }]
|
||||
// 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; after the id is returned
|
||||
// the tool-call signal is deliberately NOT wired to the process
|
||||
// (cancellation belongs to task_kill / owner cleanup), so the check
|
||||
// happens here, once, instead of passing the signal to start().
|
||||
if (exec.signal?.aborted) throw new Error('command aborted')
|
||||
const proc = ctx.bash.start(ctx.bash.resolve(request))
|
||||
let id: string
|
||||
try {
|
||||
id = tasks.register({
|
||||
kind: 'bash',
|
||||
label: args.command,
|
||||
...exec.agent ? { owner: exec.agent } : {},
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderProcessRead(proc.readOutput()),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A failed registration must not leak the just-started process: the
|
||||
// model never received an id, so nothing could ever task_kill it.
|
||||
// Kill, await quiescence, then fail the call with the real cause.
|
||||
proc.kill()
|
||||
await proc.done
|
||||
throw error
|
||||
}
|
||||
return [{ type: 'text', text: `started background task ${id}` }]
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve(request))
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
...request,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
}))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result) }]
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
presentResult: presentBashResult,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash_output',
|
||||
description: 'Read new output from a background bash task started with `bash` + `run_in_background`. '
|
||||
+ 'Returns only output produced since the previous bash_output call, plus the task status. '
|
||||
+ 'Tasks keep running while you do other work; poll again later for more output.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
||||
},
|
||||
// execute is synchronous (registry reads + string shaping) but the
|
||||
// ToolDefinition contract wants a Promise — hence resolve(), not async.
|
||||
execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
assertTaskAccess(id, exec)
|
||||
const read = ctx.bash.readOutput(id)
|
||||
let text = read.delta.length > 0 ? read.delta : '(no new output)'
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
|
||||
const fullOutput = paths.length > 0 ? paths.join(', ') : '(unavailable)'
|
||||
text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
|
||||
}
|
||||
text += `\n${statusLine(read.task)}`
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Read output from', args),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash_kill',
|
||||
description: 'Ask the executor to kill a running background bash task by task id.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
||||
},
|
||||
execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
assertTaskAccess(id, exec)
|
||||
const killed = ctx.bash.kill(id)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: killed ? `killed background task ${id}` : `task ${id} had already finished`,
|
||||
}])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Kill', args),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -7,15 +7,17 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL bash tool
|
||||
* through the agent loop, exercising the same seams a live model would
|
||||
* (tool/call + tool/result session events, agent.inject notifications).
|
||||
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
|
||||
* agent.inject completion notices).
|
||||
*/
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
@@ -25,6 +27,8 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -67,6 +71,16 @@ function resultText(event: SessionEvent): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Poll until `predicate` holds (background settlement races turn end). */
|
||||
async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`condition not met within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('bash tool through the agent loop', () => {
|
||||
it('foreground: model calls bash, sees the result, replies', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
@@ -116,53 +130,41 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start → poll → completion notice lands as context/message', async () => {
|
||||
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh TaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
||||
toolCallResponse('call-2', 'bash_output', {}, undefined),
|
||||
textResponse('Started it in the background.'),
|
||||
toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }),
|
||||
textResponse('Background task finished.'),
|
||||
])
|
||||
// The second tool call needs the REAL task id from the first result;
|
||||
// a tools/pre-execute listener rewrites the scripted arguments. (This uses
|
||||
// the low-level capability to mutate `exec` before dispatch — the
|
||||
// unadvertised mechanism behind a future first-class input-rewrite decision;
|
||||
// here it is a test shim to thread the generated id, not a product feature.)
|
||||
let taskId = ''
|
||||
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
|
||||
|
||||
// Intercept the first tool result to capture the generated task id, then
|
||||
// rewrite the second scripted call's arguments to use it.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool/result' && taskId === '') {
|
||||
const match = /task (bash-\d+)/.exec(resultText(event))
|
||||
if (match) taskId = match[1]!
|
||||
}
|
||||
})
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
if (exec.name === 'bash_output') {
|
||||
exec.arguments = { task_id: taskId }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Wait for the background task itself (completion may race turn end).
|
||||
const task = ctx.bash.get(BashTaskId(taskId))
|
||||
if (!task) throw new Error(`task ${taskId} not registered`)
|
||||
await task.done
|
||||
const firstResult = findEvent(events(agent), 'tool/result')
|
||||
expect(firstResult.data.isError).toBe(false)
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
const log = events(agent)
|
||||
const firstResult = findEvent(log, 'tool/result')
|
||||
expect(resultText(firstResult)).toBe(`started background task ${taskId}`)
|
||||
|
||||
const notice = findEvent(log, 'context/message')
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable context/message into the owning agent's session (settlement may
|
||||
// race turn end, so poll for it).
|
||||
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
|
||||
const notice = findEvent(events(agent), 'context/message')
|
||||
expect(notice.data.content.some(
|
||||
block => block.type === 'text' && block.text.includes(`background bash task ${taskId} finished`),
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
agent.send([{ type: 'text', text: 'collect it' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
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 * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { renderResult } from '@deepseek-ai/dsh-tool-bash'
|
||||
import { processOutcome, renderProcessRead, renderResult } from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
|
||||
|
||||
/** Foreground-only harness: no task runtime (backgrounding fails loud here). */
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -27,39 +30,36 @@ async function setup() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Full harness: the generic task runtime + its control surface, then the bash tool. */
|
||||
async function setupWithTasks() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} whose session token is `sessionId`, REGISTER it in
|
||||
* `ctx.agents` (the completion-notice path finds the owning agent by scanning
|
||||
* the registry for a matching `session.header.id`), and return it. The returned
|
||||
* agent is also passed to `execute` as `exec.agent` so it owns the spawned task.
|
||||
* The registration disposer is tracked so {@link unregisterFakeAgents} can drop
|
||||
* it (simulating the owning session disconnecting before a task completes).
|
||||
* Build a fake {@link Agent} whose session token is `sessionId` and REGISTER it
|
||||
* in `ctx.agents` (an owned task registration attaches the awaited owner
|
||||
* cleanup via `ctx.agents.onCleanup`, which requires a live registered agent).
|
||||
* The agent id is deliberately DIFFERENT from the session token so a
|
||||
* wrong-field match fails the test.
|
||||
*/
|
||||
const fakeAgentDisposers = new Map<Context, (() => void)[]>()
|
||||
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
|
||||
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
|
||||
// token (session.header.id) — a config agent has `agentId !== sessionId`. The
|
||||
// owner token IS the session id, so the notice path must find the agent by
|
||||
// `session.header.id`, NOT the registry key. Using distinct values here makes
|
||||
// the test fail if a regression matched on the wrong field (a same-value fake
|
||||
// would pass either way — the "hits the line but not the scenario" trap).
|
||||
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
const dispose = ctx.agents.register(agent)
|
||||
const list = fakeAgentDisposers.get(ctx) ?? []
|
||||
list.push(dispose)
|
||||
fakeAgentDisposers.set(ctx, list)
|
||||
function registerFakeAgent(ctx: Context, sessionId: string): 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
|
||||
}
|
||||
|
||||
/** Unregister every fake agent in this ctx (simulate the owning session disconnecting). */
|
||||
function unregisterFakeAgents(ctx: Context): void {
|
||||
for (const dispose of fakeAgentDisposers.get(ctx) ?? []) dispose()
|
||||
fakeAgentDisposers.delete(ctx)
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
|
||||
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 {
|
||||
@@ -83,56 +83,6 @@ async function callUntilText(
|
||||
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
|
||||
}
|
||||
|
||||
class LossyReadBashExecutor extends BashExecutor {
|
||||
private readonly task: BashTask = {
|
||||
id: BashTaskId('bash-lossy'),
|
||||
command: 'fake',
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
}
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
|
||||
run(): Promise<BashRunResult> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
|
||||
start(): BashTask {
|
||||
return this.task
|
||||
}
|
||||
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return id === this.task.id ? this.task : undefined
|
||||
}
|
||||
|
||||
ownerOf(): OwnerToken | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [this.task]
|
||||
}
|
||||
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
|
||||
return { task: this.task, delta: 'tail', lossy: true }
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
describe('bash tool', () => {
|
||||
it('returns stdout for a successful command', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -205,7 +155,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/ENOENT/)
|
||||
})
|
||||
|
||||
it('surfaces aborts as isError', async () => {
|
||||
it('surfaces foreground aborts as isError', async () => {
|
||||
const ctx = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
@@ -220,7 +170,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/aborted/)
|
||||
})
|
||||
|
||||
// Type and required-key violations are now rejected by the harness
|
||||
// Type and required-key violations are rejected by the harness
|
||||
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
|
||||
it.each([
|
||||
[{}, /missing required property "command"/],
|
||||
@@ -250,15 +200,18 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
it('registers all three schemas in the system prompt assembly', async () => {
|
||||
it('registers the bash schema with run_in_background exposed by default', async () => {
|
||||
const ctx = await setup()
|
||||
const names = ctx.tools.schemas().map(schema => schema.name)
|
||||
expect(names).toEqual(['bash', 'bash_output', 'bash_kill'])
|
||||
const bashSchema = ctx.tools.schemas()[0]!
|
||||
const schemas = ctx.tools.schemas()
|
||||
expect(schemas.map(schema => schema.name)).toEqual(['bash'])
|
||||
const bashSchema = schemas[0]!
|
||||
expect(bashSchema.parameters).toMatchObject({
|
||||
type: 'object',
|
||||
required: ['command', 'description'],
|
||||
})
|
||||
expect(Object.keys(bashSchema.parameters.properties as Record<string, unknown>))
|
||||
.toContain('run_in_background')
|
||||
expect(bashSchema.description).toContain('task_output')
|
||||
})
|
||||
|
||||
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
|
||||
@@ -275,7 +228,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
@@ -292,348 +245,274 @@ describe('bash tool', () => {
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background tools', () => {
|
||||
it('bash with run_in_background returns a task id immediately', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 0.2; echo bg-done', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toMatch(/^started background task bash-\d+$/)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bash_output polls incrementally and reports status', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'echo first; sleep 1; echo second', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
const first = await callUntilText(ctx, 'bash_output', { task_id: id }, 'first')
|
||||
expect(text(first)).toContain('first')
|
||||
expect(text(first)).toContain('[status: running]')
|
||||
|
||||
await ctx.bash.get(id)!.done
|
||||
const second = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(second)).toContain('second')
|
||||
expect(text(second)).not.toContain('first')
|
||||
expect(text(second)).toContain('[status: completed, exit code: 0]')
|
||||
|
||||
const third = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(third)).toContain('(no new output)')
|
||||
})
|
||||
|
||||
it('bash_output flags lossy reads with spill paths', async () => {
|
||||
it('applies the built-in background default when apply() receives a bare config', async () => {
|
||||
// Bypasses the schemastery defaults on purpose: apply() must stand on its
|
||||
// own `?? true` fallback when embedded programmatically without the schema.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[some output was dropped from memory; full output: ')
|
||||
})
|
||||
|
||||
it('bash_output reports unavailable when a lossy read has no safe spill path', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LossyReadBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const read = await call(ctx, 'bash_output', { task_id: 'bash-lossy' })
|
||||
expect(text(read)).toBe('tail\n[some output was dropped from memory; full output: (unavailable)]\n[status: running]')
|
||||
})
|
||||
|
||||
it('bash_kill stops a running task; repeat reports already-finished', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
const killed = await call(ctx, 'bash_kill', { task_id: id })
|
||||
expect(text(killed)).toBe(`killed background task ${id}`)
|
||||
await ctx.bash.get(id)!.done
|
||||
|
||||
const again = await call(ctx, 'bash_kill', { task_id: id })
|
||||
expect(text(again)).toBe(`task ${id} had already finished`)
|
||||
|
||||
const status = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(status)).toContain('[status: killed by SIGTERM]')
|
||||
})
|
||||
|
||||
it('unknown task ids are isError for both tools', async () => {
|
||||
const ctx = await setup()
|
||||
const read = await call(ctx, 'bash_output', { task_id: 'bash-999' })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(text(read)).toMatch(/unknown bash task/)
|
||||
const kill = await call(ctx, 'bash_kill', { task_id: 'bash-999' })
|
||||
expect(kill.isError).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bash_output', {}, /missing required property "task_id"/],
|
||||
['bash_output', { task_id: 9 }, /"task_id" must be a string/],
|
||||
['bash_kill', { task_id: '' }, /invalid task_id/],
|
||||
])('%s rejects invalid task_id %j', async (tool, args, pattern) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, tool, args)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
|
||||
const ctx = await setup()
|
||||
const inject = vi.fn()
|
||||
// The notice path looks the agent up in ctx.agents by its session token, so
|
||||
// the agent must be REGISTERED (not merely passed to execute). Mount a
|
||||
// registry and register a fake whose session.header.id IS the owner token.
|
||||
const agent = registerFakeAgent(ctx, 'bg', inject)
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
const [content, options] = inject.mock.calls[0] as [
|
||||
{ type: string; text: string }[],
|
||||
{ source: { kind: string; plugin: string } },
|
||||
]
|
||||
expect(content[0]!.text).toContain(`background bash task ${id} finished`)
|
||||
expect(content[0]!.text).toContain('bash_output')
|
||||
expect(options.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
|
||||
})
|
||||
|
||||
it('swallows ONLY the disposed-agent inject error', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('agent "x" is disposed') })
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg2'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
|
||||
const ctx = await setup()
|
||||
// A real bug in inject (not the benign disposed race) must surface — the
|
||||
// base-class notifier contains it (logs, does not reject task.done), but
|
||||
// the listener itself must have thrown rather than silently eaten it.
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('unexpected inject bug') })
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg3'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
// notifyTaskDone caught and logged the rethrown error.
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
|
||||
expect(logged).toBe(true)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => {
|
||||
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its
|
||||
// per-session agent — e.g. the ACP session disconnects and its AgentHandle
|
||||
// disposes while the background task is still running. The owner token is
|
||||
// still on the task, but no live agent carries it anymore, so the registry
|
||||
// lookup finds nothing and the notice is dropped (no throw).
|
||||
const ctx = await setup()
|
||||
const inject = vi.fn()
|
||||
const agent = registerFakeAgent(ctx, 'bg', inject)
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg4'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Unregister the agent BEFORE the task completes (simulate disconnect).
|
||||
unregisterFakeAgents(ctx)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not notify when no agent owned the task', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
ToolBash.apply(ctx, {})
|
||||
const schema = ctx.tools.schemas()[0]!
|
||||
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
||||
.toContain('run_in_background')
|
||||
})
|
||||
})
|
||||
|
||||
describe('background task ownership (cross-session isolation)', () => {
|
||||
/** Run a tool on behalf of a specific agent (sets exec.agent). */
|
||||
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
// Ownership is by TOKEN (session.header.id), NOT agent object identity — so
|
||||
// each agent needs a DISTINCT session id, else every fake yields the same
|
||||
// token and the isolation tests pass for the wrong reason (all tasks owned by
|
||||
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
|
||||
// it.
|
||||
const fakeAgent = (sessionId: string) =>
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
describe('background execution through the task runtime', () => {
|
||||
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
|
||||
const ctx = await setupWithTasks()
|
||||
const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
|
||||
expect(started.isError).toBe(false)
|
||||
expect(text(started)).toBe('started background task bash-1')
|
||||
|
||||
it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => {
|
||||
const ctx = await setup()
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
// Agent A starts a long-running background task.
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
// Agent B (a different session token) cannot read or kill A's task.
|
||||
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
||||
expect(readByB.isError).toBe(true)
|
||||
expect(text(readByB)).toMatch(/belongs to another session/)
|
||||
const killByB = await callAs(ctx, b, 'bash_kill', { task_id: id })
|
||||
expect(killByB.isError).toBe(true)
|
||||
expect(text(killByB)).toMatch(/belongs to another session/)
|
||||
|
||||
// The task is still running (B's kill did nothing) — A can still kill it.
|
||||
const killByA = await callAs(ctx, a, 'bash_kill', { task_id: id })
|
||||
expect(killByA.isError).toBe(false)
|
||||
expect(text(killByA)).toBe(`killed background task ${id}`)
|
||||
const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
|
||||
expect(text(read)).toContain('bg-ok')
|
||||
// A later read reports the terminal outcome in the generic status line.
|
||||
const final = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, '[status: completed, exit code: 0]')
|
||||
expect(final.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('a DIFFERENT Agent object with the SAME session token may access the task (ownership is by token, not object identity)', async () => {
|
||||
// Ownership fences by session.header.id, NOT Agent object identity. Two
|
||||
// distinct Agent objects sharing one session token (e.g. an agent re-created
|
||||
// on the same session) are the SAME owner.
|
||||
const ctx = await setup()
|
||||
const a1 = fakeAgent('sess-shared')
|
||||
const a2 = fakeAgent('sess-shared') // distinct object, same token
|
||||
const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id })
|
||||
expect(readByA2.isError).toBe(false)
|
||||
await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup
|
||||
it('a running background task is killable through the REAL task_kill tool', async () => {
|
||||
const ctx = await setupWithTasks()
|
||||
await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
|
||||
expect(text(killed)).toBe('requested cancellation of task bash-1')
|
||||
// The cancel reached the process handle; the task settles as killed with
|
||||
// the signal detail mapped by processOutcome.
|
||||
const final = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true })
|
||||
expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
|
||||
})
|
||||
|
||||
it('the no-agent (non-loop) caller cannot access an owned task', async () => {
|
||||
const ctx = await setup()
|
||||
const a = fakeAgent('sess-a')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// A call with no exec.agent has no token → cannot prove ownership of an owned task.
|
||||
const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(text(read)).toMatch(/belongs to another session/)
|
||||
await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
|
||||
})
|
||||
it('a background task started by an agent is registered with that agent as owner', async () => {
|
||||
// The fence SEMANTICS are pinned in dsh-tasks; this only pins that
|
||||
// tool-bash forwards exec.agent as the registration's owner.
|
||||
const ctx = await setupWithTasks()
|
||||
const agent = registerFakeAgent(ctx, 'sess-owner')
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }, agent)
|
||||
expect(text(started)).toBe('started background task bash-1')
|
||||
|
||||
it('an UNOWNED task (started with no agent) is accessible to anyone', async () => {
|
||||
const ctx = await setup()
|
||||
// Started by a non-loop caller (no exec.agent) → no owner token recorded.
|
||||
const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Any agent (and the no-agent caller) may read/kill it.
|
||||
const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id })
|
||||
expect(read.isError).toBe(false)
|
||||
const killed = await callAs(ctx, undefined, 'bash_kill', { task_id: id })
|
||||
const anon = await call(ctx, 'task_output', { task_id: 'bash-1' })
|
||||
expect(anon.isError).toBe(true)
|
||||
expect(text(anon)).toMatch(/belongs to another session/)
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' }, agent)
|
||||
expect(killed.isError).toBe(false)
|
||||
await call(ctx, 'task_output', { task_id: 'bash-1', wait: true }, agent) // await settlement — no orphan
|
||||
})
|
||||
|
||||
it('the owner can still access its task AFTER it completes (owner token persists on the task)', async () => {
|
||||
const ctx = await setup()
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
// Completion does NOT clear ownership: B is still rejected, A still allowed.
|
||||
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
||||
expect(readByB.isError).toBe(true)
|
||||
expect(text(readByB)).toMatch(/belongs to another session/)
|
||||
const readByA = await callAs(ctx, a, 'bash_output', { task_id: id })
|
||||
expect(readByA.isError).toBe(false)
|
||||
it('fails loud when the task runtime is not loaded', async () => {
|
||||
const ctx = await setup() // no TaskService / ToolTasks
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
})
|
||||
|
||||
it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => {
|
||||
// The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT
|
||||
// in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor +
|
||||
// task survive) preserves ownership. This is the regression guard: a
|
||||
// plugin-local map would make B accessible after reload, and this test would
|
||||
// catch it.
|
||||
it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
|
||||
class CountingStartExecutor extends BashExecutor {
|
||||
starts = 0
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
|
||||
}
|
||||
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
this.starts += 1
|
||||
return {
|
||||
command: spec.command,
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Before reload: B is rejected (A owns it).
|
||||
expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
|
||||
|
||||
// Reload ONLY tool-bash; the executor and its running task (with its owner
|
||||
// token) survive.
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.bash.get(id)?.status).toBe('running')
|
||||
expect(ctx.bash.ownerOf(id)).toBe('sess-a')
|
||||
|
||||
// After reload, ownership is INTACT → B is STILL rejected.
|
||||
expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
|
||||
await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('call-pre-aborted'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'sleep 60', description: 'test command', run_in_background: true },
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('command aborted')
|
||||
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
|
||||
})
|
||||
|
||||
it('a failed registration kills the just-started process (no orphan without an id)', async () => {
|
||||
class LeakProbeExecutor extends BashExecutor {
|
||||
kills = 0
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
|
||||
}
|
||||
|
||||
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
let close!: () => void
|
||||
const done = new Promise<void>((res) => { close = res })
|
||||
const proc: BashProcess = {
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done,
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => {
|
||||
this.kills += 1
|
||||
proc.status = 'killed'
|
||||
close()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
}
|
||||
// TaskService WITHOUT any control surface: register() throws AFTER the
|
||||
// process already started — the producer must kill and await it.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LeakProbeExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// The call resolved only after the kill landed (the catch awaits done).
|
||||
expect((ctx.bash as LeakProbeExecutor).kills).toBe(1)
|
||||
})
|
||||
|
||||
it('enableRunInBackground: false removes the parameter and flips the description', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'bash')!
|
||||
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
||||
.toEqual(['command', 'description', 'timeoutMs', 'workdir'])
|
||||
expect(schema.description).toContain('Background execution is not available')
|
||||
expect(schema.description).not.toContain('run_in_background')
|
||||
// The registry-held definition agrees (schema and capability never disagree).
|
||||
const parameters = ctx.tools.get('bash')!.parameters as { properties: Record<string, unknown> }
|
||||
expect('run_in_background' in parameters.properties).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderProcessRead', () => {
|
||||
const base: BashProcessRead = { delta: 'out\n', lossy: false }
|
||||
|
||||
it('returns the delta verbatim for a lossless read', () => {
|
||||
expect(renderProcessRead(base)).toBe('out\n')
|
||||
expect(renderProcessRead({ delta: '', lossy: false })).toBe('')
|
||||
})
|
||||
|
||||
it('appends the loss notice with the available spill paths', () => {
|
||||
expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log' }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: /spill/out.log]')
|
||||
expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log', stderrSpillPath: '/spill/err.log' }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: /spill/out.log, /spill/err.log]')
|
||||
})
|
||||
|
||||
it('reports (unavailable) when a lossy read has no safe spill path', () => {
|
||||
expect(renderProcessRead({ ...base, lossy: true }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('an empty lossy delta is the notice alone', () => {
|
||||
expect(renderProcessRead({ delta: '', lossy: true, stderrSpillPath: '/spill/err.log' }))
|
||||
.toBe('[some output was dropped from memory; full output: /spill/err.log]')
|
||||
})
|
||||
|
||||
it('inserts the separating newline only when the delta lacks one', () => {
|
||||
expect(renderProcessRead({ delta: 'tail', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
expect(renderProcessRead({ delta: 'tail\n', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOutcome', () => {
|
||||
function settled(over: Partial<BashProcess>): BashProcess {
|
||||
return {
|
||||
command: 'x',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
it('maps a signal-killed process to killed with the signal detail', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
|
||||
.toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
})
|
||||
|
||||
it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', exitCode: null })))
|
||||
.toEqual({ status: 'killed', detail: 'killed before exit' })
|
||||
})
|
||||
|
||||
it('maps a completed process to its exit code', () => {
|
||||
expect(processOutcome(settled({ exitCode: 3 })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 3' })
|
||||
})
|
||||
|
||||
it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
|
||||
expect(processOutcome(settled({ exitCode: null })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 0' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-cwd routing (per-session workdir)', () => {
|
||||
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`cwd-${++callCounter}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
// An agent whose session header carries a cwd (what session/new records).
|
||||
const agentInCwd = (cwd: string) =>
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as Agent
|
||||
|
||||
it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
|
||||
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
|
||||
expect(text(result).trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
|
||||
it('an explicit absolute workdir overrides the session cwd', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callAs(ctx, agentInCwd('/'), { command: 'pwd', description: 'pwd', workdir: '/tmp' })
|
||||
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: '/tmp' }, agentInCwd('/'))
|
||||
expect(text(result).trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
|
||||
it('a relative workdir is resolved against the session cwd', async () => {
|
||||
const ctx = await setup()
|
||||
// session cwd /usr + relative 'bin' → /usr/bin
|
||||
const result = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd', workdir: 'bin' })
|
||||
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: 'bin' }, agentInCwd('/usr'))
|
||||
expect(text(result).trim()).toMatch(/\/usr\/bin$/)
|
||||
})
|
||||
|
||||
it('two sessions with different cwds each run bash in their own dir', async () => {
|
||||
const ctx = await setup()
|
||||
const inUsr = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd' })
|
||||
const inTmp = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
|
||||
const inUsr = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/usr'))
|
||||
const inTmp = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
|
||||
expect(text(inUsr).trim()).toMatch(/\/usr$/)
|
||||
expect(text(inTmp).trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
@@ -697,35 +576,6 @@ describe('renderResult', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('status lines', () => {
|
||||
it('reports kills without a recorded signal (executor raced process exit)', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const task = ctx.bash.get(id)!
|
||||
|
||||
await call(ctx, 'bash_kill', { task_id: id })
|
||||
await task.done
|
||||
// Simulate the variant where the close event carried no signal.
|
||||
task.signal = null
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[status: killed]')
|
||||
})
|
||||
|
||||
it('reports completed tasks with a null exit code as exit 0', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const task = ctx.bash.get(id)!
|
||||
await task.done
|
||||
// Defensive: completed tasks always carry an exit code in practice; the
|
||||
// ?? 0 fallback covers task shapes from other executor implementations.
|
||||
task.exitCode = null
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -851,14 +701,6 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ card: 'generic', title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ card: 'generic', title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
})
|
||||
|
||||
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
|
||||
const ctx = await setup()
|
||||
// defineTool wraps presentCall to soft-validate against the schema and fall
|
||||
@@ -879,8 +721,8 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
* future refactor that blindly forwards `...args` — which would silently thread
|
||||
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
|
||||
* (the credential scrub in dsh-bash-local is the security control; see the
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
|
||||
* unused here.
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
|
||||
* hands back an already-settled fake handle so the task registration completes.
|
||||
*/
|
||||
class RecordingBashExecutor extends BashExecutor {
|
||||
readonly requests: BashExecRequest[] = []
|
||||
@@ -893,7 +735,6 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
run(): Promise<BashRunResult> {
|
||||
@@ -902,12 +743,17 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
start(): BashTask { throw new Error('unused') }
|
||||
get(): BashTask | undefined { return undefined }
|
||||
ownerOf(): OwnerToken | undefined { return undefined }
|
||||
list(): BashTask[] { return [] }
|
||||
readOutput(): BashTaskRead { throw new Error('unused') }
|
||||
kill(): boolean { return false }
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
return {
|
||||
command: spec.command,
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupRecording() {
|
||||
@@ -915,6 +761,8 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
@@ -947,9 +795,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
|
||||
it('a background bash call likewise carries no env/stdin', async () => {
|
||||
const { ctx, bash } = await setupRecording()
|
||||
// start() throws in this recorder, but resolve() runs first and records the
|
||||
// request — which is all this no-forward assertion needs.
|
||||
await ctx.tools.execute({
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('no-forward-2'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
@@ -960,13 +806,14 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
stdin: 'x',
|
||||
},
|
||||
})
|
||||
// The call really went down the background path (the recorder sees the real
|
||||
// request the consumer built, so the absent env/stdin below is a real
|
||||
// negative, not a recorder that drops everything).
|
||||
expect(text(result)).toBe('started background task bash-1')
|
||||
expect(bash.requests).toHaveLength(1)
|
||||
const request = bash.requests[0]!
|
||||
expect(request.command).toBe('sleep 1')
|
||||
expect('env' in request).toBe(false)
|
||||
expect('stdin' in request).toBe(false)
|
||||
// The owner token IS set on a background call (the isolation fence) — proving
|
||||
// the recorder sees the real request the consumer built, so the absent
|
||||
// env/stdin above is a real negative, not a recorder that drops everything.
|
||||
expect('owner' in request).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
@@ -25,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
|
||||
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash schema (background runs register with ctx.tasks)
|
||||
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
@@ -41,7 +43,9 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
|
||||
* service, the session store, system-prompt assembly, the tool registry, the
|
||||
* agent registry, the dev-mode invariants, the model-facing `bash` tool
|
||||
* agent registry, the background task registry + its `task_*` control tools,
|
||||
* the dev-mode invariants, the model-facing `bash` tool
|
||||
* schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
|
||||
* list as its OWN config (default `[]`), so each app supplies its own
|
||||
* pre-created agents.
|
||||
@@ -50,8 +51,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
export const name = 'agent-core'
|
||||
@@ -103,7 +106,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
ctx.plugin(ToolRegistry)
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(TaskService)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolTasks)
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
// The rest-slot is lexicographic: the bundle's own task control tools
|
||||
// (tool-tasks needs no executor, unlike the pending bash tool) follow alpha.
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/tool-bash"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tool-tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + drain the `ctx.agents.onCleanup` registrations + unregister + remove session).
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
|
||||
|
||||
@@ -274,11 +274,15 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* chain — the runtime awaits each disposer's returned promise before the next:
|
||||
*
|
||||
* yield session-detach (disposed LAST — detach onAppend + remove entry)
|
||||
* yield register (disposed 2nd — unregister)
|
||||
* yield register (disposed 3rd — unregister)
|
||||
* yield cleanup-drain (disposed 2nd — await ctx.agents.drainCleanups)
|
||||
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
|
||||
*
|
||||
* So on teardown: the loop is stopped and AWAITED to exit (its final
|
||||
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
|
||||
* THEN the awaited per-agent cleanups drain (background tasks cancel and
|
||||
* reach quiescence while the agent is STILL registered — a settling task's
|
||||
* completion notice can still find it, and `agent/disposed` has not fired),
|
||||
* THEN the agent is unregistered, THEN the session is detached — capturing the
|
||||
* closing events before detach, whether the trigger is the handle's `dispose()`
|
||||
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
|
||||
@@ -304,6 +308,11 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
yield this.ctx.sessions.enter(session)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// Disposed 2nd (after stop-and-drain below, before unregister above):
|
||||
// drain the awaited per-agent cleanups — the AgentFactory dispose
|
||||
// contract that lets other plugins (ctx.tasks) tie resources to this
|
||||
// agent's quiescence. drainCleanups contains rejections itself.
|
||||
yield async () => { await this.ctx.agents.drainCleanups(agent.id) }
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
|
||||
57
packages/core/agent-loop/tests/cleanup-drain.spec.ts
Normal file
57
packages/core/agent-loop/tests/cleanup-drain.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter } from './mock-adapter.ts'
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('agent disposal drains onCleanup registrations', () => {
|
||||
it('awaits the cleanup after loop drain and before unregistration', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
|
||||
|
||||
const order: string[] = []
|
||||
ctx.on('agent/disposed', () => void order.push('agent/disposed'))
|
||||
let cleanupSettled = false
|
||||
ctx.agents.onCleanup(handle.agent.id, async () => {
|
||||
// The agent must STILL be registered while cleanups drain (a settling
|
||||
// task's completion notice can still find it by session id).
|
||||
order.push(`cleanup:registered=${ctx.agents.get(handle.agent.id) !== undefined}`)
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
cleanupSettled = true
|
||||
order.push('cleanup:done')
|
||||
})
|
||||
|
||||
await handle.dispose()
|
||||
// dispose() resolves only after the cleanup settled (awaited, not fired).
|
||||
expect(cleanupSettled).toBe(true)
|
||||
expect(order).toEqual(['cleanup:registered=true', 'cleanup:done', 'agent/disposed'])
|
||||
})
|
||||
|
||||
it('a rejecting cleanup never breaks the disposal chain', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
|
||||
ctx.agents.onCleanup(handle.agent.id, () => Promise.reject(new Error('drain boom')))
|
||||
|
||||
await expect(handle.dispose()).resolves.toBeUndefined()
|
||||
expect(ctx.agents.get(handle.agent.id)).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('drain boom'))
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,10 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas
|
||||
- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
|
||||
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), drains the registered per-agent cleanups, unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached.
|
||||
|
||||
- `ctx.agents.onCleanup(agentId, cleanup: () => Promise<void>): () => void` — register an AWAITED per-agent cleanup: the agent's disposal chain runs it (after loop drain, before unregistration) and `AgentHandle.dispose()` resolves only after it settles. The seam for resources that must not outlive their owner (`ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot promise that, because emit listeners are not awaited. Throws for an unregistered agent id; effect-scoped.
|
||||
- `ctx.agents.drainCleanups(agentId): Promise<void>` — LIFECYCLE OWNERS ONLY: run and detach every registered cleanup (registration order, per-cleanup containment, loops so a cleanup registered mid-drain still runs). Part of the `AgentFactory` dispose contract — a replacement loop must call it in its disposal chain. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
|
||||
|
||||
### Events
|
||||
|
||||
|
||||
@@ -88,6 +88,13 @@ export interface AgentHandle {
|
||||
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
|
||||
* consumers (e.g. the ACP bridge) program against `ctx.agents` without
|
||||
* depending on the concrete `dsh-agent-loop` package.
|
||||
*
|
||||
* Dispose contract: the handle's `dispose()` must, after draining the loop and
|
||||
* BEFORE unregistering the agent, await
|
||||
* {@link AgentRegistry.drainCleanups | ctx.agents.drainCleanups(agent.id)} —
|
||||
* that is what makes {@link AgentRegistry.onCleanup} registrations an awaited
|
||||
* quiescence guarantee for every plugin, whichever loop implementation is
|
||||
* installed.
|
||||
*/
|
||||
export interface AgentFactory {
|
||||
/**
|
||||
@@ -117,6 +124,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<AgentId, Agent>()
|
||||
private factory: AgentFactory | undefined
|
||||
private cleanups = new Map<AgentId, Set<() => Promise<void>>>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
@@ -217,6 +225,66 @@ export class AgentRegistry extends Service {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an AWAITED per-agent cleanup: the agent's disposal chain runs it
|
||||
* (via {@link drainCleanups}) after the loop has drained and before the agent
|
||||
* unregisters, and `AgentHandle.dispose()` resolves only after it settles.
|
||||
* This is the seam for resources that must not outlive their owning agent
|
||||
* (e.g. `ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot
|
||||
* promise that, because emit listeners are not awaited. Throws for an agent
|
||||
* id not currently registered: a cleanup attached to a dead agent would
|
||||
* silently never run. Effect-scoped: disposed with the calling fiber.
|
||||
* @param agentId - the LIVE agent whose disposal must await this cleanup.
|
||||
* @param cleanup - awaited during disposal; a rejection is logged, never propagated.
|
||||
* @returns the disposer that detaches the cleanup without running it.
|
||||
*/
|
||||
onCleanup(agentId: AgentId, cleanup: () => Promise<void>): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (!this.store.has(agentId)) {
|
||||
throw new Error(`agent "${agentId}" is not registered (cleanup would never run)`)
|
||||
}
|
||||
let set = this.cleanups.get(agentId)
|
||||
if (set === undefined) {
|
||||
set = new Set()
|
||||
this.cleanups.set(agentId, set)
|
||||
}
|
||||
set.add(cleanup)
|
||||
return () => {
|
||||
set.delete(cleanup)
|
||||
// Guard the map removal with an identity check: after drainCleanups
|
||||
// detached this set, the same id may map to a FRESH set (a cleanup
|
||||
// registered mid-drain) that this stale disposer must not remove.
|
||||
if (set.size === 0 && this.cleanups.get(agentId) === set) this.cleanups.delete(agentId)
|
||||
}
|
||||
}, 'agents.onCleanup()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run and detach every cleanup registered for an agent (registration order,
|
||||
* awaited sequentially, per-cleanup containment — a rejecting cleanup is
|
||||
* logged and never starves the ones after it or the caller's disposal chain).
|
||||
* For LIFECYCLE OWNERS ONLY: the agent factory's disposal chain calls this
|
||||
* between loop drain and unregistration (part of the {@link AgentFactory}
|
||||
* dispose contract); other plugins register via {@link onCleanup}, never
|
||||
* drain. Loops until no cleanups remain, so one registered DURING the drain
|
||||
* (from a settling task) still runs instead of leaking.
|
||||
* @param agentId - the agent being disposed.
|
||||
* @returns resolves when every registered cleanup has settled.
|
||||
*/
|
||||
async drainCleanups(agentId: AgentId): Promise<void> {
|
||||
for (let set = this.cleanups.get(agentId); set !== undefined; set = this.cleanups.get(agentId)) {
|
||||
this.cleanups.delete(agentId)
|
||||
for (const cleanup of set) {
|
||||
try {
|
||||
await cleanup()
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agentId}": disposal cleanup threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All live agents, in registration order.
|
||||
* @returns a fresh array; mutating it does not affect the registry.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -76,6 +76,129 @@ describe('AgentRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry.onCleanup / drainCleanups', () => {
|
||||
it('drains cleanups in registration order, awaiting each', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const ran: string[] = []
|
||||
ctx.agents.onCleanup(agent.id, async () => {
|
||||
ran.push('first:start')
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
ran.push('first:end')
|
||||
})
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ran.push('second')
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
// Sequential await: the second cleanup starts only after the first settled.
|
||||
expect(ran).toEqual(['first:start', 'first:end', 'second'])
|
||||
// Drained cleanups are detached: a second drain is a no-op.
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ran).toEqual(['first:start', 'first:end', 'second'])
|
||||
})
|
||||
|
||||
it('contains a rejecting cleanup: logged, later cleanups still run', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
let ranAfter = false
|
||||
ctx.agents.onCleanup(agent.id, () => Promise.reject(new Error('cleanup boom')))
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ranAfter = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await expect(ctx.agents.drainCleanups(agent.id)).resolves.toBeUndefined()
|
||||
expect(ranAfter).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup boom'))
|
||||
})
|
||||
|
||||
it('rejects a cleanup for an agent that is not registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
expect(() => ctx.agents.onCleanup(AgentId('ghost'), () => Promise.resolve()))
|
||||
.toThrow('agent "ghost" is not registered')
|
||||
})
|
||||
|
||||
it('detaches without running on disposer call and on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
let ranA = false
|
||||
let ranB = false
|
||||
const detach = ctx.agents.onCleanup(agent.id, () => {
|
||||
ranA = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
detach()
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.agents.onCleanup(agent.id, () => {
|
||||
ranB = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ranA).toBe(false)
|
||||
expect(ranB).toBe(false)
|
||||
})
|
||||
|
||||
it('runs a cleanup registered during the drain instead of leaking it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const ran: string[] = []
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ran.push('outer')
|
||||
// A settling task registering follow-up cleanup mid-drain: the drain
|
||||
// loop must pick up the fresh set rather than strand it.
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ran.push('mid-drain')
|
||||
return Promise.resolve()
|
||||
})
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ran).toEqual(['outer', 'mid-drain'])
|
||||
})
|
||||
|
||||
it('a stale disposer from a drained set does not remove a fresh registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const detachOld = ctx.agents.onCleanup(agent.id, () => Promise.resolve())
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
|
||||
let ranFresh = false
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ranFresh = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
// The old registration's disposer fires after its set was drained; the
|
||||
// identity guard must keep it away from the fresh set under the same id.
|
||||
detachOld()
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ranFresh).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
/** A stub AgentFactory that records calls and returns a stub agent. */
|
||||
function stubFactory() {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
expect(names).toEqual(['bash', 'edit', 'read', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -25,7 +25,6 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
},
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
|
||||
@@ -38,6 +38,6 @@ The service also announces provider lifecycle: `subagent/provider-added` (the li
|
||||
|
||||
## Scope (first cut)
|
||||
|
||||
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
The consumer collects **synchronously by default**: it starts a run and awaits `result`. Background delegation does not change this seam — the consumer registers the run with the generic `ctx.tasks` runtime and the run is collected through the shared task tools ([the background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md)). Steering (`sendMessage`) is part of the contract but intentionally unused. See the seam RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
|
||||
See `src/types.ts` for the full contracts.
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Scope (first cut): the consumer collects synchronously — it starts a run and
|
||||
* awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage})
|
||||
* is part of the contract but intentionally unused; background / poll / spill
|
||||
* semantics are deferred to a future redesign that unifies long-running-tool
|
||||
* handling across subagents and bash.
|
||||
* Scope: the seam stays collection-agnostic — a run is started and its
|
||||
* `result` awaited, whether the consumer blocks on it (foreground) or
|
||||
* registers it as a `ctx.tasks` background task (the generic runtime owns
|
||||
* ids/polling/stop; this seam gains nothing task-shaped). Steering
|
||||
* ({@link SubagentRun.sendMessage}) is part of the contract but intentionally
|
||||
* unused.
|
||||
*
|
||||
* The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY
|
||||
* payload; `subagent/end` additionally carries the child's `lastAssistantMessage`
|
||||
@@ -26,8 +27,8 @@
|
||||
* FIXME(subagent-continuation): a control-flow `subagent/end` (an awaited
|
||||
* waterfall returning a stop/continue decision, like the other interception
|
||||
* seams) would require reshaping this emit into a waterfall, awaiting listeners
|
||||
* before settling, and a `resume` capability on the in-process provider — part
|
||||
* of the deferred background/steering redesign, NOT this observe-only cut.
|
||||
* before settling, and a `resume` capability on the in-process provider — a
|
||||
* deliberate deferral, NOT part of this observe-only cut.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen
|
||||
|
||||
## Provider selection is config, not model-facing
|
||||
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt, run_in_background? }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
|
||||
|
||||
## The description states the provider's context contract
|
||||
|
||||
@@ -14,10 +14,13 @@ The tool description and the `prompt` parameter description are DERIVED from the
|
||||
|---|---|
|
||||
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
|
||||
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
|
||||
| `enableRunInBackground` | Expose `run_in_background` in this instance's schema (default `true`). Disabled, the parameter is absent entirely — delegation through this instance stays strictly synchronous. |
|
||||
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) |
|
||||
|
||||
## Lifecycle (synchronous collect)
|
||||
## Foreground lifecycle (synchronous collect)
|
||||
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
## Background delegation (a generic task)
|
||||
|
||||
`run_in_background: true` refuses an already-aborted `exec.signal`, starts the run, registers `{ kind: 'subagent', label: description, owner: parent, cancel, done }` with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and returns `started background subagent task <id>` — the parent keeps working and collects/stops the child through the generic `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`). The tool-call signal is deliberately NOT wired to the run after the id is returned; cancellation belongs to `task_kill` (its logged `reason` is forwarded to `run.cancel`) and the runtime's owner-disposal cleanup. The task is final-output-only (no incremental transcript — the child session remains the detailed trace), and its `done` settles only after `run.dispose()` (child quiescence), so owner disposal cannot resolve before the child is actually gone. Mapping (exported for tests): `runOutcome` — `completed` carries the final text as the task output; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as status-line detail — and `settleRun`, which disposes on both result paths and contains an infrastructure rejection as `failed`. A missing `ctx.tasks` fails the call loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`). See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
@@ -32,13 +33,15 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,9 +5,13 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import * as mock from '@deepseek-ai/dsh-subagent-mock'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { runOutcome, settleRun } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
|
||||
@@ -60,12 +64,21 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(text(result)).toBe('child says hi')
|
||||
})
|
||||
|
||||
it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
|
||||
it('exposes description + prompt + run_in_background to the model (no provider/type parameter)', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
|
||||
expect(schema).toBeDefined()
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background'])
|
||||
expect(schema!.description).toContain('task_output')
|
||||
})
|
||||
|
||||
it('omits run_in_background entirely when the instance disables it (schema and capability never disagree)', async () => {
|
||||
const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
|
||||
expect(schema!.description).not.toContain('task_output')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -452,3 +465,180 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-tool-subagent background mode', () => {
|
||||
/** A parent agent carrying a real session token, registered in ctx.agents (owner-cleanup wiring requires a live registry entry). */
|
||||
function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
const agent = { id: AgentId(`agent-${sessionId}`), inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
async function backgroundSetup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
|
||||
const ctx = await setup(toolConfig, mockConfig)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('returns a task id immediately and the answer is collected through task_output', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
|
||||
const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
|
||||
expect(start.isError).toBe(false)
|
||||
expect(text(start)).toBe('started background subagent task subagent-1')
|
||||
|
||||
const collected = await ctx.tools.execute({
|
||||
callId: CallId('collect-1'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(collected)).toBe('background answer\n[status: completed]')
|
||||
|
||||
// Final-output reads are idempotent (not consumed).
|
||||
const again = await ctx.tools.execute({
|
||||
callId: CallId('collect-2'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1' },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(again)).toBe('background answer\n[status: completed]')
|
||||
})
|
||||
|
||||
it('fails loud when the tasks runtime is not loaded', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('refuses to start when the tool signal is already aborted', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('subagent delegation aborted')
|
||||
})
|
||||
|
||||
it('forwards task_kill reasons to run.cancel (and defaults one when absent)', async () => {
|
||||
// A provider whose runs settle only on cancel — the mock settles on a
|
||||
// microtask, too fast to observe a LIVE kill through the real tools.
|
||||
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const cancels: (string | undefined)[] = []
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'hanging',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
|
||||
return {
|
||||
id: AgentId(`hang-${cancels.length}`),
|
||||
result: new Promise((res) => { settle = res }),
|
||||
cancel(reason?: string) { cancels.push(reason); settle({ output: [], stopReason: 'aborted' }) },
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
})
|
||||
// Direct apply (schema bypass): schemastery would default agentOptions to
|
||||
// an (truthy) empty object — the raw config exercises the omitted branch
|
||||
// on the background start request.
|
||||
tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' })
|
||||
|
||||
const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
expect(text(startOne)).toBe('started background subagent task subagent-1')
|
||||
expect(text(startTwo)).toBe('started background subagent task subagent-2')
|
||||
|
||||
const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
|
||||
const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
|
||||
expect(text(withReason)).toBe('requested cancellation of task subagent-1')
|
||||
expect(text(withoutReason)).toBe('requested cancellation of task subagent-2')
|
||||
expect(cancels).toEqual(['superseded', 'background subagent task killed'])
|
||||
|
||||
// The aborted children settle as killed tasks.
|
||||
const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
|
||||
expect(text(killed)).toBe('(no new output)\n[status: killed]')
|
||||
})
|
||||
|
||||
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
|
||||
const output = [{ type: 'text' as const, text: 'partial' }]
|
||||
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
|
||||
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
|
||||
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
|
||||
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
|
||||
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
|
||||
// Merge-extensible: an unknown reason is failed-with-detail, never success.
|
||||
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
|
||||
})
|
||||
|
||||
it('settleRun disposes the run before reporting, on both result paths', async () => {
|
||||
const order: string[] = []
|
||||
const completed = await settleRun({
|
||||
id: AgentId('child-1'),
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
order.push('reported')
|
||||
expect(completed).toEqual({ status: 'completed', output: 'ok' })
|
||||
expect(order).toEqual(['dispose', 'reported'])
|
||||
|
||||
// An infrastructure rejection still disposes and reports failed.
|
||||
let disposed = false
|
||||
const failed = await settleRun({
|
||||
id: AgentId('child-2'),
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
cancel() {},
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background registration failure (no orphaned child)', () => {
|
||||
it('cancels and disposes the just-started run when register() throws', async () => {
|
||||
// TaskService is loaded but NO control surface is attached, so
|
||||
// ctx.tasks.register throws AFTER the provider run already started.
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(parent)
|
||||
|
||||
const events: string[] = []
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'probe',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let settle!: (value: { output: never[]; stopReason: 'aborted' }) => void
|
||||
return {
|
||||
id: AgentId('probe-child'),
|
||||
result: new Promise((res) => { settle = res }),
|
||||
cancel(reason?: string) { events.push(`cancel:${reason}`); settle({ output: [], stopReason: 'aborted' }) },
|
||||
dispose() { events.push('dispose'); return Promise.resolve() },
|
||||
}
|
||||
},
|
||||
})
|
||||
tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('probe-1'),
|
||||
name: 'subagent_probe',
|
||||
arguments: { description: 'd', prompt: 'p', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// The child was cancelled AND disposed before the call settled — the
|
||||
// model never got an id, so nothing else could ever collect or kill it.
|
||||
expect(events).toEqual(['cancel:background task registration failed', 'dispose'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
packages/tasks/README.md
Normal file
10
packages/tasks/README.md
Normal 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.
|
||||
25
packages/tasks/tasks/README.md
Normal file
25
packages/tasks/tasks/README.md
Normal 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.
|
||||
35
packages/tasks/tasks/package.json
Normal file
35
packages/tasks/tasks/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
457
packages/tasks/tasks/src/index.ts
Normal file
457
packages/tasks/tasks/src/index.ts
Normal 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
|
||||
155
packages/tasks/tasks/src/types.ts
Normal file
155
packages/tasks/tasks/src/types.ts
Normal 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
|
||||
465
packages/tasks/tasks/tests/tasks.spec.ts
Normal file
465
packages/tasks/tasks/tests/tasks.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
24
packages/tasks/tasks/tsconfig.json
Normal file
24
packages/tasks/tasks/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
24
packages/tasks/tool-tasks/README.md
Normal file
24
packages/tasks/tool-tasks/README.md
Normal 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.
|
||||
43
packages/tasks/tool-tasks/package.json
Normal file
43
packages/tasks/tool-tasks/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
183
packages/tasks/tool-tasks/src/index.ts
Normal file
183
packages/tasks/tool-tasks/src/index.ts
Normal 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),
|
||||
}))
|
||||
}
|
||||
306
packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
Normal file
306
packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
33
packages/tasks/tool-tasks/tsconfig.json
Normal file
33
packages/tasks/tool-tasks/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -70,7 +70,9 @@ describe('dsh-acp-agent composition', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
// The rest-slot is lexicographic: the bundle's own task control tools
|
||||
// (tool-tasks needs no executor, unlike the pending bash tool) follow alpha.
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
|
||||
|
||||
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
|
||||
Background-task isolation rides on the `ctx.tasks` runtime (`dsh-tasks`): task ids are global and predictable, so every read/kill/wait/list is fenced to the owning agent's session (`session.header.id`), and one session's agent can't read or kill another's task through `task_output`/`task_kill`. Ownership is by session token, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the registration lives in the tasks service it survives a producer-plugin HMR reload.
|
||||
|
||||
## Per-session cwd
|
||||
|
||||
|
||||
@@ -92,7 +92,9 @@ describe('dsh-stdio-agent app', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
// The rest-slot is lexicographic: the bundle's own task control tools
|
||||
// (tool-tasks needs no executor, unlike the pending bash tool) follow alpha.
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ Zero-dependency primitives shared across the other groups. A package lands here
|
||||
|---|---|
|
||||
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
|
||||
|
||||
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
|
||||
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
|
||||
|
||||
@@ -21,6 +21,6 @@ Construction goes through the per-id factory in the OWNING package (a plain cast
|
||||
|
||||
## Policy: brand ids that cross package boundaries
|
||||
|
||||
A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.**
|
||||
A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `TaskId` in `dsh-tasks`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.**
|
||||
|
||||
This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`.
|
||||
This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-tasks`, for example, brands `TaskId` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`.
|
||||
|
||||
Reference in New Issue
Block a user