Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

# Conflicts:
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/integration.spec.ts
#	packages/ui/acp/README.md
This commit is contained in:
Tianyi Cui
2026-07-15 23:34:17 +08:00
136 changed files with 5478 additions and 3116 deletions

View File

@@ -4,7 +4,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass
## Hierarchy
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
Packages live at `packages/<group>/<pkg>/`; groups are containers, while names remain `@deepseek-ai/dsh-<pkg>`. **Each group README is the canonical package/ctx-key map.**
| Group | Role | Release expectation |
|---|---|---|
@@ -18,6 +18,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`context/`](context/README.md) | Opt-in request-context enrichment | 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) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | 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 |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
@@ -32,7 +33,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).
Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table.
## Dependencies

View File

@@ -7,6 +7,6 @@ The canonical three-package capability seam (see [capability seams](../../docs/r
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` |
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [the acp-agent example's default composition](../../examples/acp-agent/)).

View File

@@ -24,12 +24,12 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **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 environment** — ambient credential-shaped variables are removed before noninteractive terminal defaults and explicit caller entries are applied. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. Trusted plugins use `env` and `stdin`, but the model-facing tool does not 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.
- **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 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.
## Model Experience
Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-task deltas and state, spill-file path, exact `Error: unknown bash task "<taskId>"` and `Error: aborted before spawn: <reason>` failures, and retains each resulting tool message until compaction.
Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures.
## Known Limitations and Deferred Work
@@ -38,6 +38,5 @@ Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdou
- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them.
- **Finished background tasks are never evicted** — they stay in the task map, retaining their in-memory output tails, until executor disposal.
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.

View File

@@ -1,15 +1,14 @@
/**
* Local-subprocess implementation of the bash seam. Each call runs in its own
* process group, background tasks are tracked, and disposal kills and awaits
* them. Execution policy belongs in `tools/pre-execute` or a sandboxing
* executor, not this local process layer.
* Local-subprocess implementation of the bash executor seam. Each command runs
* as `bash -c` in its own process group; disposal kills and joins live groups.
* Execution policy belongs in `tools/pre-execute` or a sandboxing executor.
* @module @deepseek-ai/dsh-bash-local
*/
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 { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
@@ -37,20 +36,9 @@ 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
* in-memory output with full-stream spill files (pi, OpenCode),
* process-group SIGTERM→SIGKILL kills with a 3s grace (OpenCode).
* Local bash executor with bounded output, spill files, and process-group
* `SIGTERM` to `SIGKILL` escalation.
*/
export class LocalBashExecutor extends BashExecutor {
static Config: z<Config> = z.object({
@@ -61,8 +49,8 @@ export class LocalBashExecutor extends BashExecutor {
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
private tasks = new Map<BashTaskId, TrackedTask>()
private nextTaskId = 1
/** Live processes retained only so disposal can kill and join them. */
private live = new Map<BashProcess, RunningBash>()
/** Test seam: spill knobs forwarded to runBash. */
internals: RunInternals = {}
@@ -71,26 +59,21 @@ export class LocalBashExecutor extends BashExecutor {
constructor(ctx: Context, config: Config) {
super(ctx)
// schemastery (static Config) has already filled the defaulted fields;
// the cast records that runtime fact for exactOptionalPropertyTypes.
// Schemastery fills these fields before construction; the type does not encode that step.
this.config = config as ResolvedConfig
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
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.
// Await closure so even a TERM-trapping child cannot outlive the fiber.
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')
}
@@ -114,24 +97,16 @@ export class LocalBashExecutor extends BashExecutor {
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
...request.signal ? { signal: request.signal } : {},
// Carry stdin/env through verbatim — optional, no config default (absent
// means none). env merges AFTER the scrub in run.ts.
// Explicit environment values are merged after credential scrubbing 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,
// Carry a sandbox-mode override through verbatim: this executor never
// confines, so the field is inert here (the seam contract) — a
// sandboxing subclass overrides resolve() to stamp its default instead.
// Local execution carries this override for sandboxing subclasses.
sandboxMode: request.sandboxMode,
}
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
// One fused deadline drives both the timeout and upstream cancellation;
// runBash listens on d.signal and runs the SIGTERM→grace→SIGKILL kill.
// `using` clears the timer across the awaited process lifetime.
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const outcome = await runBash({
command: spec.command,
@@ -142,18 +117,14 @@ export class LocalBashExecutor extends BashExecutor {
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our timeout cut the
// command short; any other abort — an upstream cancel, or a foreign (outer) deadline's
// timeout under nesting — is aborted.
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, 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).
start(spec: BashExecSpec): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = runBash({
command: spec.command,
cwd: spec.workdir,
@@ -164,93 +135,66 @@ 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 = {
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. Background runs
// forward only the upstream signal (no timeout), so its aborted state
// is the authoritative "was this cancelled" signal.
if (task.status === 'running') task.status = spec.signal?.aborted === true ? 'killed' : 'completed'
task.exitCode = outcome.exitCode
task.signal = outcome.signal
this.notifyTaskDone(task)
// Any signal termination is killed, including a command signaling itself.
if (proc.status === 'running') {
proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, running.stderr.readFrom(0).text)
this.live.delete(proc)
}, (error: unknown) => {
// Spawn-level failure (bad workdir, …): the task never ran. String()
// suffices — runBash only rejects with Error instances.
task.status = 'killed'
task.running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
this.notifyTaskDone(task)
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
this.onProcessDone(proc, running.stderr.readFrom(0).text)
this.live.delete(proc)
}),
}
this.tasks.set(id, task)
return task
}
readOutput: (): BashProcessRead => {
const out = running.stdout.readFrom(stdoutOffset)
const err = running.stderr.readFrom(stderrOffset)
stdoutOffset = out.nextOffset
stderrOffset = err.nextOffset
get(id: BashTaskId): BashTask | undefined {
return this.tasks.get(id)
// 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.live.set(proc, running)
return proc
}
/**
* Full collected stderr of a tracked task from stream start (bounded by the
* in-memory cap; bytes only in the spill file are not re-read). A protected
* seam for subclasses that classify a settled task's outcome — reading here
* does NOT advance the consumer's {@link readOutput} cursor. An unknown id
* (a task already dropped by disposal) reads as empty.
* Settlement hook for subclasses that attach execution facts to a process.
* Called after exit facts or spawn-failure output are stamped and before
* {@link BashProcess.done} resolves. The base implementation is intentionally
* empty.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
*/
protected collectedStderr(id: BashTaskId): string {
const task = this.tasks.get(id)
return task === undefined ? '' : task.running.stderr.readFrom(0).text
}
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
}
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
}
export default LocalBashExecutor

View File

@@ -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,36 +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
let delta = ''
while (Date.now() < deadline) {
last = bash.readOutput(id)
delta += last.delta
if (delta.includes(expected)) return { ...last, delta }
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; output was ${JSON.stringify(delta)}, 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', () => {
@@ -90,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; echo ready; while :; do sleep 60 & wait $!; done' }))
await readUntil(bash, task.id, 'ready\n')
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 }))
@@ -154,229 +128,183 @@ 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.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 self-signal exit settles the handle as killed, not completed', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({ command: 'kill -TERM $$' }))
await proc.done
expect(proc.status).toBe('killed')
expect(proc.exitCode).toBeNull()
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('executor cancellation, callback, and disposal contracts', () => {
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')
})
})

View File

@@ -2,21 +2,23 @@
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
| Mode | File effects |
|---|---|
| `read-only` (default) | No writes anywhere (of `/dev`, only the `/dev/null` node is writable, so `>/dev/null` keeps working) |
| `workspace-write` | Writes only under `workspaceRoot` + `/tmp` (ephemeral under bwrap, the host `/tmp` under Landlock, `/private/tmp` plus the per-user temp dir under Seatbelt) |
| `danger-full-access` | No confinement; the provider is never consulted. Execution is `dsh-bash-local`'s verbatim — foreground results still carry `sandbox: { mode, denied: false }` (no `enforcement`: nothing was confined), background tasks carry no sandbox facts |
| `danger-full-access` | No confinement; the provider is never consulted. Foreground results carry `sandbox: { mode, denied: false }`; background process handles carry no sandbox facts. |
Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker.
- **Config default, per-call override.** `resolve()` stamps the configured sandbox mode onto each spec unless an approved request supplies a wider mode. That override affects only its call or background task. `ctx.bash.sandboxMode` reports the default so the tool advertises escalation only when supported; results report the effective mode. The model learns standing mode only from tool/result facts, not a system-prompt announcement.
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
@@ -56,5 +58,5 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **A background runner failure has no immediate error channel** — it is recorded on the settled task and surfaces when the caller polls with `bash_output`.
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.

View File

@@ -0,0 +1,49 @@
/**
* Internal shell-quoting and sandbox-result classification helpers.
*
* @module @deepseek-ai/dsh-bash-sandbox/helpers
*/
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
/**
* Quote one string as a single-quoted POSIX shell word.
* @param text - raw argv element to preserve through the outer shell parse.
* @returns the quoted shell word.
*/
export function shellQuote(text: string): string {
return `'${text.replaceAll("'", String.raw`'\''`)}'`
}
/**
* Classify a failed run against the selected backend's denial dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive denial substrings from the active wrap.
* @returns whether the failed run matches that denial dialect.
*/
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
}
/**
* Classify a failed run against the selected backend's runner-failure dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
* @returns whether the failed run matches that runner-failure dialect.
*/
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
}
/**
* Match a non-zero exit against case-insensitive stderr signatures.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text.
* @param signatures - substrings identifying the selected backend's dialect.
* @returns whether this is a non-zero exit whose stderr matches a signature.
*/
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
if (exitCode === null || exitCode === 0) return false
const lowered = stderr.toLowerCase()
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
}

View File

@@ -3,24 +3,25 @@
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Runner failure means the command never
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
* tasks carry `runnerFailed`. The tool owns approval and passes per-call modes.
* processes carry `runnerFailed`. The tool owns approval and passes per-call modes.
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { resolve } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
/**
* Plugin config: the local executor's knobs plus the sandbox policy. All
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
* fail-safe default; an example that wants a workspace-writable agent opts in
* explicitly). The runner choice is NOT configured here: which platform
* explicitly). The runner choice is not configured here: which platform
* backend confines the command is the `ctx.sandbox` provider's config.
*/
export interface Config extends LocalConfig {
@@ -33,54 +34,6 @@ export interface Config extends LocalConfig {
workspaceRoot?: string
}
/**
* Quote one string as a single-quoted POSIX shell word (embedded single
* quotes become `'\''`), so a wrapped argv element survives the outer
* `bash -c` re-parse byte-for-byte.
* @param text - the raw argv element to quote.
* @returns the single-quoted shell word.
*/
export function shellQuote(text: string): string {
return `'${text.replaceAll("'", String.raw`'\''`)}'`
}
/**
* Conservatively classify a nonzero, non-signal run using only the selected
* backend's denial signatures. Text inference may miss a denial or match
* unrelated stderr in that dialect; it never uses another backend's terms.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's denial dialect, case-insensitive stderr substrings.
* @returns whether the run's failure reads as a sandbox denial.
*/
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
}
/**
* Classify a nonzero run using the selected backend's runner-failure
* signatures. Callers check this before denial because runner diagnostics may
* contain denial words; the command did not run.
* @param result - the settled foreground run to classify.
* @param signatures - the active wrap's runner-failure signatures,
* case-insensitive stderr substrings.
* @returns whether the run's failure reads as the runner itself failing.
*/
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
}
/**
* The classifier core shared by foreground results and settled background
* tasks: failed AND signature present. Lowercases BOTH sides — the seam
* declares its signatures case-insensitive, and producers compose them from
* runtime data of any case (an `argv0` path, `No such file or directory`).
*/
function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
if (exitCode === null || exitCode === 0) return false
const lowered = stderr.toLowerCase()
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
}
/**
* Registers as `ctx.bash` in place of the local executor and requires a
* `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is
@@ -104,11 +57,12 @@ export class SandboxBashExecutor extends LocalBashExecutor {
private readonly mode: SandboxMode
private readonly workspaceRoot: string
/**
* Per-task mode and wrap facts retained until settlement. Overlapping tasks
* may use different modes or provider facts, so one latest-wrap field would
* misclassify earlier completions.
* Per-process confinement facts retained until settlement. Providers may
* vary enforcement and diagnostic dialect between overlapping calls, so a
* shared latest-wrap value would classify a process against the wrong facts.
* Unconfined processes have no entry.
*/
private readonly taskFacts = new Map<BashTaskId, {
private readonly processFacts = new Map<BashProcess, {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
@@ -117,10 +71,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
constructor(ctx: Context, config: Config) {
super(ctx, config)
// schemastery (static Config) already filled the defaulted fields — the
// cast records that runtime fact (mirrors LocalBashExecutor's config
// cast). `workspaceRoot` and `cwd` have NO schema default, so their
// fallback chain is real branching.
// Schemastery fills mode before construction; workspaceRoot and cwd retain runtime fallbacks.
this.mode = config.mode as SandboxMode
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
}
@@ -158,39 +109,36 @@ export class SandboxBashExecutor extends LocalBashExecutor {
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
override start(spec: BashExecSpec): BashTask {
override start(spec: BashExecSpec): BashProcess {
// Same stamped-by-resolve invariant as run().
const mode = spec.sandboxMode as SandboxMode
if (mode === 'danger-full-access') return super.start(spec)
// Classification needs settled stderr. Store facts synchronously after
// spawn, before the earliest process completion can be observed.
// Install facts synchronously; promise settlement cannot run before start() returns.
const confined = this.confine(spec.command, mode)
const task = super.start({ ...spec, command: confined.command })
const proc = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
this.taskFacts.set(task.id, { mode, enforcement, denialSignatures, runnerFailureSignatures })
return task
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
return proc
}
/**
* Stamp per-task sandbox facts before completion listeners and `done` settle.
* Full-access tasks have no facts; signal deaths are not denials.
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override notifyTaskDone(task: BashTask): void {
const facts = this.taskFacts.get(task.id)
protected override onProcessDone(proc: BashProcess, stderr: string): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.taskFacts.delete(task.id)
const stderr = this.collectedStderr(task.id)
// Runner failure outranks denial. Background settlement has no throw
// channel, so this fact is its counterpart to the foreground exception.
const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
task.sandbox = {
this.processFacts.delete(proc)
// Runner failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(task.exitCode, stderr, facts.denialSignatures),
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
enforcement: facts.enforcement,
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.notifyTaskDone(task)
super.onProcessDone(proc, stderr)
}
/**

View File

@@ -5,7 +5,8 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**

View File

@@ -13,7 +13,8 @@ import { Context } from 'cordis'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
@@ -132,7 +133,7 @@ describe('danger-full-access', () => {
const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
await task.done
expect(task.sandbox).toBeUndefined()
expect(bash.readOutput(task.id).delta).toContain('free-bg')
expect(task.readOutput().delta).toContain('free-bg')
expect(calls).toHaveLength(0)
})
})
@@ -184,7 +185,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
await task.done
expect(task.sandbox).toBeUndefined()
expect(bash.readOutput(task.id).delta).toContain('bg-free')
expect(task.readOutput().delta).toContain('bg-free')
expect(calls).toHaveLength(0)
})
})
@@ -243,6 +244,20 @@ describe('result facts', () => {
})
describe('background sandbox facts', () => {
it('stamps facts and releases accounting when background spawn fails', async () => {
const { bash } = await setup()
const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing')
const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
@@ -273,15 +288,6 @@ describe('background sandbox facts', () => {
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
})
it('completion listeners already see the stamped facts (stamp precedes notify)', async () => {
const { ctx, bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const seen: unknown[] = []
ctx.bash.onTaskDone((task) => { seen.push(task.sandbox) })
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
await task.done
expect(seen).toEqual([{ mode: 'read-only', denied: true, enforcement: 'partial' }])
})
it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
// Facts belong to each wrap and may vary between calls. The slow task settles after the
// quick task starts; a shared latest-wrap field would classify and stamp it with the wrong
@@ -308,8 +314,8 @@ describe('background sandbox facts', () => {
const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
// Let the stderr land before the kill so the classifier sees the
// signature and must still refuse it on the null exit code alone.
await vi.waitFor(() => { expect(bash.readOutput(task.id).delta).toContain('Permission denied') })
bash.kill(task.id)
await vi.waitFor(() => { expect(task.readOutput().delta).toContain('Permission denied') })
task.kill()
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})

View File

@@ -5,7 +5,8 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**

View File

@@ -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 and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
@@ -18,23 +18,20 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
| Member | Semantics |
|---|---|
| `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. |
| `start(spec)` | Background execution. Returns a task-free `BashProcess` handle immediately; **no timeout applies**. The caller may adapt it into `ctx.tasks`. |
| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
| `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. |
| `BashProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. |
| `BashProcess.kill()` | Kill the process group. Returns `false` when it already finished. |
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.
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit — see the HMR-safety tests.
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, sandboxMode) before execution. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
The seam owns per-session sandbox overrides through the log-only `bash/sandbox-mode` event, `effectiveSandboxMode`, and `setSandboxMode`; writers preserve turn enclosure, and replay restores the last override. `BashTaskId` and `OwnerToken` are distinct brands. Foreground `run` returns exit, timeout, cancellation, output, and optional sandbox facts; background `start` and `readOutput` use task records. A sandboxing executor reports the executed mode, conservative denial classification, and enforcement completeness. See [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md) for full shapes.
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.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 (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 value means "none". See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Model Experience

View File

@@ -22,13 +22,11 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -1,24 +1,23 @@
/**
* The bash executor seam (`ctx.bash`): an abstract service defining what a bash backend does —
* run commands, manage background tasks — without saying how.
* The `ctx.bash` executor seam for foreground commands and background process
* handles. Task ids, ownership, polling, and notices belong to
* `@deepseek-ai/dsh-tasks`, keeping executors independent of sessions.
* @module @deepseek-ai/dsh-bash
*/
import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
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 { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
export type {
BashExecRequest,
BashExecSpec,
BashProcess,
BashProcessRead,
BashProcessStatus,
BashRunResult,
BashSandboxInfo,
BashTask,
BashTaskListener,
BashTaskRead,
BashTaskStatus,
CollectedOutput,
} from './types.ts'
@@ -29,34 +28,30 @@ declare module 'cordis' {
}
/**
* Registers one `ctx.bash` implementation. Runtime command failures resolve as
* {@link BashRunResult}; only infrastructure failures reject. Background starts
* return immediately without a timeout, report completion exactly once while
* live, and remain cancellable by signal or {@link kill}. Output reads are
* incremental and flag lost buffered data; disposal kills and awaits all tasks.
* Abstract bash execution service. Subclass, implement the abstract methods,
* and load the subclass as a plugin — it registers as `ctx.bash` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Implementations must honor these semantics:
* - {@link run} rejects only for infrastructure failures. Nonzero exits,
* timeout kills, and abort kills resolve with a {@link BashRunResult}.
* - {@link start} returns immediately; no timeout applies to background
* processes. `done` settles at process close and never rejects; spawn
* failures settle as `killed` with the error on stderr.
* - {@link BashProcess.readOutput} is incremental: consecutive reads never
* repeat output. Lossy reads report truncation and available spill files.
* - Disposal kills all running background processes and awaits their exit.
*/
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')
}
/**
* The sandbox mode this executor confines commands under BY DEFAULT, or `undefined` when it
* does not sandbox at all — the capability fact the tool and ACP layers read to advertise
* sandbox controls honestly.
* A session or call may override this default, so widening is evaluated per
* execution rather than encoded in this getter.
* @returns the configured default mode of a sandboxing executor;
* `undefined` for an executor that never confines.
* The sandbox mode this executor applies by default, or `undefined` when it
* does not sandbox commands.
* @returns the configured default sandbox mode, when supported.
*/
get sandboxMode(): SandboxMode | undefined {
return undefined
@@ -79,79 +74,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 the token without interpreting policy; keeping it here
* lets ownership survive a consumer-plugin 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

View File

@@ -1,79 +1,24 @@
/**
* Execution vocabulary for the bash executor seam. Types only — the abstract
* service lives in `./index.ts`, implementations in sibling packages
* (`@deepseek-ai/dsh-bash-local` first).
*
* Execution types for the bash executor seam. Background task semantics belong
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles.
* @module dsh-bash/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** 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
}
/**
* Sandbox facts for one foreground run — present on {@link BashRunResult} iff
* a sandboxing executor ran the command (an unsandboxed executor reports no
* `sandbox` field at all). Reported independently of `exitCode`/`signal`
* (orthogonal outcomes), so a caller can tell "the command failed on its own"
* from "the sandbox blocked a file operation". The mode/enforcement
* vocabulary lives on the `@deepseek-ai/dsh-sandbox` seam; this shape is the
* bash seam's result-fact carrier for it.
* Sandbox facts for one run, present iff a sandboxing executor handled it.
* Facts are reported independently of process exit status so callers can
* distinguish command failures from policy denials and runner failures.
*/
export interface BashSandboxInfo {
/** The mode the command actually ran under. */
mode: SandboxMode
/**
* True when the executor classifies this run's failure as the sandbox
* denying a file operation. The classification is CONSERVATIVE (a failed
* exit whose stderr carries a filesystem-permission signature) and reads
* the COLLECTED stderr — the bounded in-memory tail per
* {@link CollectedOutput} semantics, so a signature that survives only in a
* spill file is missed toward `denied: false`. A plain command failure
* keeps `denied: false` even under a sandboxed mode.
*/
/** Whether the sandbox denied a file operation. */
denied: boolean
/**
* How completely the runner enforced `mode`'s file effects — see
* {@link SandboxEnforcement}. Absent exactly when `mode` is
* `danger-full-access`: nothing is confined, so there is no enforcement to
* report.
*/
/** How completely the selected runner enforced the requested mode. */
enforcement?: SandboxEnforcement
/**
* The sandbox runner failed before executing the command. Set only on settled
* background tasks; foreground runs throw `SANDBOX_UNAVAILABLE` instead.
*/
/** Whether the sandbox runner failed before the command could run. */
runnerFailed?: boolean
}
@@ -109,30 +54,14 @@ 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 shared `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
/**
* Explicit per-call sandbox policy. The tool stamps a session override or a
* one-shot approved escalation, with the grant taking precedence. Sandboxing
* executors honor it for this call; non-sandboxing executors do not confine.
*/
/** Explicit per-call sandbox mode override. */
sandboxMode?: SandboxMode | undefined
}
/**
* A fully-resolved execution SPEC — exactly what {@link BashExecutor.run} /
* {@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
* timeout) — the field is still required because the type is shared.
* A resolved execution spec. {@link BashExecutor.resolve} fills and caps the
* required fields; {@link BashExecutor.start} ignores `timeoutMs` because
* background processes have no executor timeout.
*/
export interface BashExecSpec {
command: string
@@ -140,40 +69,14 @@ export interface BashExecSpec {
timeoutMs: number
/** Abort signal — implementations kill the command when it fires. */
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).
*/
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
/**
* Extra environment entries, carried through verbatim from
* {@link BashExecRequest.env} and merged by the implementation AFTER its
* credential scrub (an explicit entry wins even when its name matches the
* scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no
* config default, absent means "no extra env".
* Extra environment entries, merged after credential scrubbing so explicit
* values win; absent means no extra entries.
*/
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
/**
* The sandbox mode this call executes under, REQUIRED-but-nullable for the
* same visibility reason as `owner`. A sandboxing executor's `resolve()`
* stamps the effective mode (the request's explicit override, else its
* configured default) so `run()`/`start()` read the spec, never the config;
* a non-sandboxing executor carries the request value through verbatim and
* ignores it (`undefined` under such an executor means what its README says:
* unconfined execution).
*/
/** Resolved sandbox mode; ignored by executors that do not confine. */
sandboxMode: SandboxMode | undefined
}
@@ -201,42 +104,15 @@ export interface BashRunResult {
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
/**
* Sandbox facts, present iff a sandboxing executor ran the command — an
* unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See
* {@link BashSandboxInfo} for the `denied` classification semantics.
*/
/** Sandbox execution facts, absent for an unsandboxed executor. */
sandbox?: BashSandboxInfo
}
/** 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
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>
/**
* Sandbox facts for this task's execution, stamped by a sandboxing executor
* once the task settles and BEFORE completion listeners are notified — an
* `onTaskDone` consumer and a `done` awaiter both see it. Denial
* classification runs against the settled task's collected stderr, so the
* field cannot exist earlier: absent while the task is running and under an
* executor that does not sandbox. See {@link BashSandboxInfo} for the
* `denied` semantics.
*/
sandbox?: BashSandboxInfo
}
/** 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. */
@@ -247,5 +123,31 @@ export interface BashTaskRead {
stderrSpillPath?: string
}
/** Completion callback for background tasks. */
export type BashTaskListener = (task: BashTask) => void
/**
* A background process handle returned by {@link BashExecutor.start}. It is the
* only access path; buffered output remains readable after exit. Executor
* disposal kills running processes and awaits {@link done}.
*/
export interface BashProcess {
/** 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>
/** Sandbox facts, stamped once a confined process settles. */
sandbox?: BashSandboxInfo
/**
* 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
}

View File

@@ -1,150 +1,83 @@
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,
sandboxMode: request.sandboxMode,
}
}
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(): BashProcess {
const proc: BashProcess = {
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('reports no default sandbox mode (composition truth: the base never confines)', async () => {
const { bash } = await setup()
expect(bash.sandboxMode).toBeUndefined()
})
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, sandboxMode: undefined })
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.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('reports no default sandbox mode from the task-free base seam', async () => {
const ctx = new Context()
await ctx.plugin(StubExecutor)
expect(ctx.bash.sandboxMode).toBeUndefined()
})
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/)
})
})

View File

@@ -14,9 +14,6 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../sandbox/sandbox"
},

View File

@@ -1,12 +1,12 @@
# @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`). This package owns schema and text shaping while process concerns stay behind the seam. Executor facts can change rendered results, and a sandboxing executor activates the escalation fields, without moving those presentation rules into the backend.
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
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 package root exposes only the Cordis plugin contract (`name`, `inject`, `apply`); result rendering remains an implementation detail covered by same-package tests.
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests.
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. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching).
The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
## Tools
@@ -24,41 +24,27 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
`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 — `[sandbox: file access denied under <mode> mode]` when a sandboxing executor classified the failure as a policy denial (reported first so `[exit code: N]` stays the last line; the static description tells the model a denial is policy, not a command bug, and forbids retrying around it), `[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.
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
### `bash_output`
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). A settled task classified as a sandbox denial carries the same `[sandbox: file access denied under <mode> mode]` marker on every read that sees it (denials are only classifiable once the whole stderr has been collected). 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 shared registry/session id (`agent.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 shared 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.)
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
## 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 while the tool keeps model-facing result text 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 listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for that shared agent/session id (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`.
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
## 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 trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control.
## Permissions and escalation
Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md).
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../ui/user-approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the session's effective mode), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
## Per-session mode switching
Under a sandboxing executor this plugin makes the session's standing mode override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); the `bash/sandbox-mode` fold owned by [`dsh-bash`](../bash/README.md)) real at EXECUTION: every call is stamped `escalation grant > session override > undefined` onto `BashExecRequest.sandboxMode`; without either, the executor's `resolve()` applies its configured default. Nothing is stamped under a non-sandboxing executor (nothing would honor it) or for an agent-less caller (no session to fold). The prompt deliberately does NOT state the mode and a switch is not narrated: a standing declaration teaches the model to refuse preemptively, while the denial marker already names the mode the command ran under exactly when the boundary is hit — behavior, not belief, carries the state.
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
@@ -76,7 +62,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor
### Tool schemas
**What the model sees**: The model sees the generated [`bash`, `bash_output`, and `bash_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `sandbox_permissions` and `justification` augment `bash` only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definitions for that agent.
**What the model sees**: The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent.
**Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph.
@@ -88,19 +74,18 @@ Check the [exit code: N] marker on every bash result; investigate failures befor
### Background task context and results
**What the model sees**: Start returns exactly `started background task <taskId>`. Completion injects exactly `background bash task <taskId> finished <status>. Read its output with bash_output.` Reads return only the data-dependent delta or `(no new output)`, optionally `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, then exactly one of `[status: running]`, `[status: killed]`, `[status: killed by <signal>]`, or `[status: completed, exit code: <exitCode>]`. Kill returns `killed background task <taskId>` or `task <taskId> had already finished`.
**What the model sees**: Start returns exactly `started background task <taskId>`. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`, sandbox facts, and terminal detail such as `exit code: <exitCode>` or `signal: <signal>` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response.
**Token effect**: Start and status text is small; deltas are data-dependent. The completion notice and every tool result are retained until compaction, but polling does not repeat already-delivered output.
**Token effect**: The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output.
### Tool errors
**What the model sees**: Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `invalid task_id: expected a string, got <value>`, `task <taskId> belongs to another session`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
**What the model sees**: Validation and policy failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
**Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run.
## Known Limitations and Deferred Work
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
- **The bash tools opt out of `timeout-policy` budgets** — `bash` keeps the executor-owned `BASH_TIMEOUT` path and `bash_output`/`bash_kill` declare no budget, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Completion notices do not wake an idle agent** — they become durable context for the next request; a caller needing progress now must poll `bash_output` or send another message.
- **Tasks started outside an agent have no ownership fence** — their predictable ids are readable and killable by any caller; only agent-started tasks carry a session owner token.
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-bash",
"description": "Model-facing bash tools (bash, bash_output, bash_kill) over the DeepSeek Harness bash executor seam",
"description": "Model-facing bash tool with optional generic background-task and sandbox-escalation support",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -28,21 +28,25 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^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.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "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.7"
}

View File

@@ -0,0 +1,27 @@
/**
* Generic-task adaptation for background bash process handles.
*
* @module @deepseek-ai/dsh-tool-bash/background
*/
import type { BashProcess } from '@deepseek-ai/dsh-bash'
/**
* 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 command exit is
* reported, not failed, exactly like the foreground rendering.
* @param proc - the settled process handle.
* @returns the outcome for the `ctx.tasks` registration.
*/
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
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}` }
}

View File

@@ -1,89 +1,52 @@
/**
* 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.
*
* 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`.
*
* Task ownership: a background task's OWNER is an opaque token — the owning
* agent's shared `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.)
*
* Commands run with the executor's full authority unless a sandboxing
* executor (`@deepseek-ai/dsh-bash-sandbox`) confines them; per-call
* allow/deny/ask policy is the `tools/pre-execute` waterfall — see
* docs/architecture.md § Extension And Composition. Under a sandboxing
* executor this plugin also advertises the ESCALATION surface
* (`sandbox_permissions`/`justification` — the sandbox RFC § Escalation,
* docs/rfc/implemented/feature/2026-07-06-sandbox.md): a command the
* sandbox denied may be retried once under a strictly wider mode, resolved
* through `ctx.approval` BEFORE anything executes and failing closed on every
* unanswerable path. The fields exist only when the mounted executor reports
* a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised
* that the composition cannot honor.
*
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
* call is stamped `escalation grant > session override > executor default`.
* The prompt deliberately does NOT state the mode and no switch is narrated:
* the model learns the boundary from the denial marker (which names the mode
* it ran under) exactly when it matters, instead of preemptively refusing
* work a standing declaration would discourage.
* Model-facing `bash` tool over the `ctx.bash` executor seam. Background calls
* register process handles with `ctx.tasks`; their work uses task cancellation
* rather than the tool-call signal after an id is returned.
*
* TODO(permissions): deployment policy belongs in `tools/pre-execute` and
* sandboxing executors; see docs/architecture.md § Extending The Harness.
* @module @deepseek-ai/dsh-tool-bash
*/
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, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
// Side-effect type import: declaration-merges `ctx.approval`, consumed
// opportunistically by the escalation gate (`ctx.get('approval')` — the seam
// stays optional at runtime, same pattern as dsh-tools' ask routing).
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashTask } from '@deepseek-ai/dsh-bash'
import { parseExitStatus, renderResult } from './render.ts'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
/**
* Validate the constraints the SchemaSpec can't express. `defineTool` now
* validates parsed args against the SchemaSpec before `execute` runs (the
* arg-validation RFC), so type/required/enum checks are already done and `args`
* is the validated `InferArgs` shape here. What remains are value constraints
* the DSL has no vocabulary for: non-empty strings, a positive finite timeout,
* and the escalation pairing (`sandbox_permissions` and `justification` travel
* together — an approval prompt without a reason, or a reason driving nothing,
* is a malformed ask).
*/
/** Configures whether the model may background commands. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
}
export const Config: z<Config> = z.object({
enableRunInBackground: z.boolean().default(true),
})
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
interface BashToolArgs {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
sandbox_permissions?: string
justification?: string
}
function validateBashArgs(args: BashToolArgs): void {
if (args.command.trim().length === 0) {
throw new Error('invalid command: expected a non-empty string')
@@ -105,73 +68,23 @@ function validateBashArgs(args: BashToolArgs): void {
}
}
/**
* 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)
}
/**
* The bash tool's validated argument shape — the base parameters plus the two
* escalation fields, which are ADVERTISED only when the mounted executor
* reports a confining default mode (absent from the schema otherwise, so the
* SchemaSpec validator rejects them before `execute` ever sees one).
*/
interface BashToolArgs {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
sandbox_permissions?: string
justification?: string
}
/**
* The strictly-wider table: what a call whose effective mode is the key may
* escalate TO. Checked at EXECUTION, never baked into the schema — the
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
* registry-global while the effective mode is per-call truth.
*/
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}
/**
* The closed escalation-target vocabulary — every mode a call could ever
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
* whenever the mounted executor confines: cutting the enum down to the modes
* wider than the executor's DEFAULT would strand a session whose effective
* mode sits below it (a `danger-full-access` default would advertise nothing
* while a narrower-switched session stays confined with no lever).
*/
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
/**
* The bash tool's static description. The base text is byte-stable regardless
* of composition (it is part of the pinned snapshot header); the escalation
* teaching rides only when the mounted executor actually honors the fields —
* it names the ONE sanctioned exception to the base text's "do not retry
* another way" rule. Its deference clause ("If the session states approval
* prompts are disabled…") points at the approval plugin's never-policy prompt
* sentence by meaning, not by parsed wording — a rendezvous kept working by
* that sentence continuing to open with the approvals-disabled claim.
*/
function bashDescription(escalationModes: readonly SandboxMode[]): string {
function bashDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = 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.'
const base = '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]`. '
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). '
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
+ '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`.'
+ background
if (escalationModes.length === 0) return base
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
@@ -186,35 +99,14 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string {
+ 'it — but it does not forbid attempting or escalating other commands later.'
}
// Pure tool-owned presentation used for both live events and replay.
/**
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
* = !is_terminal_tool`), so the command must BE the title to be seen. This
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
* use the bare command as an execute tool's title. The model-written
* `description` (a readable summary) rides as a `content` text block shown ABOVE
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
* shows only the card; surfacing it as a content block is a deliberate
* divergence here — we keep the human summary visible alongside the card.)
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
*
* `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
* 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
* cwd (this PURE presenter, args only, can't see it).
* Present foreground calls as terminals and background starts as generic cards.
* The command remains the title on both paths; foreground cwd is passed through
* for the bridge to resolve, while background descriptions remain card content.
*/
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView {
// A background start is not an interactive terminal — a generic execute card
// with the command as rawInput and the description as a content block.
if (args.run_in_background === true) {
return {
card: 'generic',
@@ -224,8 +116,6 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
content: [{ type: 'text', text: args.description }],
}
}
// A foreground run IS a terminal: the command titles the card, the description
// renders above it, and the cwd (when the model gave a workdir) heads it.
return {
card: 'terminal',
title: args.command,
@@ -235,57 +125,24 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
}
/**
* Completed-state presentation for a `bash` call. Two parallel renderings of the
* same output: `terminal.output` for a UI that shows a terminal card (the run's
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
* newlines preserved, since a terminal renderer relies on exact bytes), and a
* fenced ```console `content` block as the fallback for a UI without terminal
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
*
* Terminal output/exit is suppressed for results that are NOT a finished
* foreground run: a `run_in_background` start (`isBackground` — the text is a
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
* abort — there is no real process exit to pill, and the body is an error
* message, not `renderResult` output, so parsing it would be meaningless). Those
* return a `generic` result whose content is the fenced ```console block. A
* finished foreground run returns a `terminal` result carrying the RAW output
* and the parsed exit status; the BRIDGE derives the fenced fallback from
* `output` for a UI without terminal support, so the tool does not double-encode
* it. A non-text result (unexpected for bash) falls through to `undefined`.
* Present completed foreground output as a terminal; background acknowledgements
* and execution errors use generic fenced output without an exit-status pill.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
const raw = block.text
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
// A background ack or an errored run is not a real terminal exit: render the
// fenced ```console fallback as generic content (no exit pill).
// Background acknowledgements and errors have no terminal exit status.
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
// A finished foreground run: RAW output + parsed exit for the terminal card.
// The bridge derives the no-capability fenced fallback from `output`.
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
}
/** 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
* (`session.header.cwd`) so each ACP session's commands run in ITS workspace,
* not the server's launch dir. A RELATIVE model `workdir` is resolved against
* the session cwd (the tool tells the model to pass `workdir` instead of `cd`,
* so a relative one should be relative to the session's root, not `process.cwd()`).
* Returns `undefined` when neither is available (no agent / headerless session /
* no session cwd) — the executor then applies its own config/`process.cwd()`
* default, preserving today's non-ACP behavior.
* Resolve an explicit workdir first, making a relative one session-cwd-relative;
* otherwise use the session cwd and leave executor defaulting as the fallback.
*/
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
const sessionCwd = exec.agent?.session.header.cwd
@@ -296,124 +153,18 @@ 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): 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
// if the model actually checks it every time.
ctx.systemPrompt.section({
name: 'tool:bash',
order: 105,
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 shared registry/session id,
* or `undefined` for a non-agent caller. Agent and Session deliberately have
* one live identity; workdir remains separate session metadata.
*/
const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
exec.agent ? OwnerToken(exec.agent.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 shared registry/session token, 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.
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.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 (Agent.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
}
})
// The escalation surface exists whenever the mounted executor confines.
// Its enum is the closed target vocabulary, deliberately NOT cut down by
// the configured default: a session may switch to a narrower effective mode
// while sharing this globally registered schema. Strict widening therefore
// belongs to the per-call check below. An executor swap restarts this fiber
// (static inject) and re-registers the schema.
export function apply(ctx: Context, config: Config): void {
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
/**
* The session's standing mode override for an ordinary (non-escalating)
* call: the `bash/sandbox-mode` fold of the calling agent's log, stamped
* onto the request so execution follows the fold without stating it in the
* prompt. Weakest precedence — an escalation grant (freshly approved for
* exactly this call) outranks it, and without either the executor's
* `resolve()` applies its configured default. Undefined for a non-sandboxing
* executor (nothing honors it) and for agent-less callers (no session to
* fold).
*/
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* anything executes. Returns the granted mode to stamp onto the bash
* request; throws the distinct fail-closed text for every other path (no
* service composed, an agent-less execution, a rejection, a cancellation,
* an unanswerable ask) — the registry turns the throw into this call's
* isError result, and nothing has run. The seam is consumed
* opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a
* deployment without it degrades per call, never at registration.
*/
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
// Schema validation only checks ADVERTISED keys, so an unadvertised
// `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a
// human is never prompted to "escalate" a sandbox that is not there. When
// the fields ARE advertised, the registry's SchemaSpec enum has already
// pinned `mode` to this ladder for every caller.
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
// Strict widening is an EXECUTION check against the call's effective
// mode — session override ?? executor default, the same fold ordinary
// calls are stamped with — deliberately not a schema constraint (the
// enum is the closed target vocabulary; the effective mode is per-call
// truth). A non-widening request fails closed here and never prompts a
// human.
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
@@ -429,14 +180,10 @@ export function apply(ctx: Context): void {
agent: exec.agent,
toolName: 'bash',
callId: exec.callId,
// Self-contained for the audit trail: approval/asked stores this
// reason, and the target mode is part of the grant's identity.
reason: `escalate sandbox to ${mode}: ${justification}`,
...exec.signal ? { signal: exec.signal } : {},
})
switch (outcome) {
// The SchemaSpec enum already pinned `mode` to the closed target
// vocabulary; the per-call check above proved it is strictly wider.
case 'allowed-once': return mode as SandboxMode
case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`)
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
@@ -445,9 +192,16 @@ export function apply(ctx: Context): void {
}
}
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
ctx.systemPrompt.section({
name: 'tool:bash',
order: 105,
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
})
ctx.tools.register(defineTool({
name: 'bash',
description: bashDescription(escalationModes),
description: bashDescription(backgroundEnabled, escalationModes),
parameters: {
command: { type: 'string', required: true, description: 'The bash command to execute.' },
description: {
@@ -459,117 +213,69 @@ 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.' },
} : {},
...escalationModes.length > 0 ? {
sandbox_permissions: {
type: 'string' as const,
enum: [...escalationModes],
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry '
+ 'of a command the sandbox just denied; requires justification and user approval.',
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
},
justification: {
type: 'string' as const,
description: 'Required with sandbox_permissions: one sentence for the user explaining '
+ 'why this exact command needs the wider access.',
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
},
} : {},
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// `description` is display/logging metadata only (surfaced to UIs via
// the tool/call session event); it is intentionally NOT forwarded to
// ctx.bash and has no effect on execution.
// An escalating call resolves approval BEFORE anything executes; every
// non-grant outcome throws its distinct error text and runs nothing.
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
// An ordinary call carries the session's standing override instead —
// grant > session override > executor default (see sessionOverride).
// Description is display metadata; workdir defaults to the caller's session.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
// Default the workdir to the calling agent's session cwd so each ACP
// session runs in its own workspace (see resolveWorkdir); an explicit
// model workdir still wins.
const workdir = resolveWorkdir(args.workdir, exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
...exec.signal ? { signal: exec.signal } : {},
...sandboxMode !== undefined ? { sandboxMode } : {},
}
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}` }]
// Undeclared keys are allowed, so schema omission also needs enforcement.
if (!backgroundEnabled) {
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)')
}
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')
}
// Reject pre-start cancellation; returned tasks use their own lifecycle.
if (exec.signal?.aborted) throw new Error('command aborted')
// Task preflight finishes before the starter can spawn a process.
const id = tasks.start({
kind: 'bash',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
run: () => {
const proc = ctx.bash.start(ctx.bash.resolve(request))
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})
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, escalationModes) }]
},
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)}`
if (read.task.sandbox?.runnerFailed) {
// The sandbox RUNNER itself failed — the command never ran. The
// foreground path surfaces this as the structured SANDBOX_UNAVAILABLE
// error; a settled task's read carries the marker instead.
text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`
} else if (read.task.sandbox?.denied) {
// Mirrors the foreground result marker (and its same-turn escalation
// hint). Background denials are only classifiable once the task
// settles (the classifier needs the whole stderr), so the marker
// rides every read that sees the settled task.
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
if (escalationModes.length > 0) {
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
}
}
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),
}))
}

View File

@@ -4,7 +4,7 @@
* @module @deepseek-ai/dsh-tool-bash/render
*/
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
@@ -15,7 +15,7 @@ function streamText(output: CollectedOutput): string {
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
* stderr section, then exit-status markers. Non-zero exits are reported, not
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
* @param result - the completed foreground run from the executor.
@@ -40,23 +40,15 @@ export function renderResult(
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// The sandbox marker precedes the exit-status markers so `[exit code: N]`
// stays the LAST line (exitStatus() anchors its parse there). Denial is a
// reported fact like timeout: the model decides how to react.
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
// The same-turn nudge lives at the decision point: only when this
// composition advertises the fields (a lever is never hinted that the
// schema does not offer), and inside the sandbox marker family so the
// exit-code marker stays the last line.
// Hint only when the composition exposes escalation, before the final exit marker.
if (escalationModes.length > 0) {
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
}
}
// Timeout is reported independently of how the process actually ended: a
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
// signal:null — the model must still see that the command was cut short.
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
@@ -69,6 +61,38 @@ export function renderResult(
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 generic control surface's job.
* @param read - one incremental read from the process handle.
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the delta text with any loss or sandbox notice appended.
*/
export function renderProcessRead(
read: BashProcessRead,
sandbox?: BashSandboxInfo,
escalationModes: readonly SandboxMode[] = [],
): string {
const notices: string[] = []
if (read.lossy) {
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
}
if (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`)
if (escalationModes.length > 0) {
notices.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
}
}
if (notices.length === 0) return read.delta
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
}
/**
* Recover the structured exit status from a rendered {@link renderResult}
* string — the inverse of the status markers it appends. A killed marker

View File

@@ -8,15 +8,17 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop 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()
@@ -26,6 +28,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)
@@ -68,6 +72,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([
@@ -117,46 +131,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 }),
// Each harness owns a fresh BashLocal service, whose first task id is
// deterministically bash-1. Keep the scripted call faithful to what the
// model sent; tool arguments are immutable once execution policy begins.
toolCallResponse('call-2', 'bash_output', { task_id: 'bash-1' }, undefined),
textResponse('Started it in the background.'),
toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }),
textResponse('Background task finished.'),
])
let taskId = ''
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-bg'), { model: 'mock' })
// Capture the generated id so the deterministic fixture is checked against
// the real executor instead of silently assuming 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]!
}
})
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
await waitForIdle(ctx, agent)
expect(taskId).toBe('bash-1')
const firstResult = findEvent(events(agent), 'tool/result')
expect(firstResult.data.isError).toBe(false)
expect(resultText(firstResult)).toBe('started background task bash-1')
// 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 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]')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
@@ -26,6 +29,9 @@
{
"path": "../../bash/bash"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../core/system-prompt"
},

View File

@@ -86,17 +86,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'bash',
summary: 'Registers one `ctx.bash` implementation.',
summary: 'Abstract bash execution service.',
methods: [
'abstract resolve(request: BashExecRequest): BashExecSpec',
'abstract run(spec: BashExecSpec): Promise<BashRunResult>',
'abstract start(spec: BashExecSpec): BashTask',
'abstract get(id: BashTaskId): BashTask | undefined',
'abstract ownerOf(id: BashTaskId): OwnerToken | undefined',
'abstract list(): BashTask[]',
'abstract readOutput(id: BashTaskId): BashTaskRead',
'abstract kill(id: BashTaskId): boolean',
'onTaskDone(listener: BashTaskListener): () => void',
'abstract start(spec: BashExecSpec): BashProcess',
],
},
{
@@ -216,6 +210,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
],
},
{
key: 'tasks',
summary: 'The `tasks` service: the runtime-global background task registry.',
methods: [
'start(spec: TaskStart): TaskId',
'list(caller?: Agent): TaskSnapshot[]',
'get(id: TaskId, caller?: Agent): TaskSnapshot',
'read(id: TaskId, caller?: Agent): TaskRead',
'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>',
'onTaskDone(listener: TaskDoneListener): () => void',
'attachSurface(name: string): () => void',
],
},
{
key: 'tools',
summary: 'Tool registry and execution pipeline.',
@@ -563,11 +571,23 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'BashExecRequest',
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner?: OwnerToken | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
},
{
name: 'BashExecSpec',
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner: OwnerToken | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
},
{
name: 'BashProcess',
declaration: 'export interface BashProcess {\n status: BashProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n sandbox?: BashSandboxInfo;\n readOutput(): BashProcessRead;\n kill(): boolean;\n}',
},
{
name: 'BashProcessRead',
declaration: 'export interface BashProcessRead {\n delta: string;\n lossy: boolean;\n stdoutSpillPath?: string;\n stderrSpillPath?: string;\n}',
},
{
name: 'BashProcessStatus',
declaration: 'export type BashProcessStatus = \'running\' | \'completed\' | \'killed\';',
},
{
name: 'BashRunResult',
@@ -577,26 +597,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'BashSandboxInfo',
declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}',
},
{
name: 'BashTask',
declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n sandbox?: BashSandboxInfo;\n}',
},
{
name: 'BashTaskId',
declaration: 'export type BashTaskId = Branded<\'BashTaskId\'>;',
},
{
name: 'BashTaskListener',
declaration: 'export type BashTaskListener = (task: BashTask) => void;',
},
{
name: 'BashTaskRead',
declaration: 'export interface BashTaskRead {\n task: BashTask;\n delta: string;\n lossy: boolean;\n stdoutSpillPath?: string;\n stderrSpillPath?: string;\n}',
},
{
name: 'BashTaskStatus',
declaration: 'export type BashTaskStatus = \'running\' | \'completed\' | \'killed\';',
},
{
name: 'Branded',
declaration: 'export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n};',
@@ -749,10 +749,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
},
{
name: 'OwnerToken',
declaration: 'export type OwnerToken = Branded<\'OwnerToken\'>;',
},
{
name: 'PresetOption',
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
@@ -929,6 +925,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SurfaceOp',
declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};',
},
{
name: 'TaskDoneListener',
declaration: 'export type TaskDoneListener = (snapshot: TaskSnapshot, owner: Agent | undefined) => void | PromiseLike<void>;',
},
{
name: 'TaskHooks',
declaration: 'export interface TaskHooks {\n cancel(reason?: string): void;\n done: Promise<TaskOutcome>;\n readOutput?(): string;\n}',
},
{
name: 'TaskId',
declaration: 'export type TaskId = Branded<\'TaskId\'>;',
},
{
name: 'TaskKind',
declaration: 'export type TaskKind = TaskKindMap[keyof TaskKindMap];',
},
{
name: 'TaskKindMap',
declaration: 'export interface TaskKindMap {\n bash: \'bash\';\n subagent: \'subagent\';\n}',
},
{
name: 'TaskOutcome',
declaration: 'export interface TaskOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}',
},
{
name: 'TaskRead',
declaration: 'export interface TaskRead {\n text: string;\n snapshot: TaskSnapshot;\n}',
},
{
name: 'TaskSnapshot',
declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}',
},
{
name: 'TaskStart',
declaration: 'export interface TaskStart {\n kind: TaskKind;\n label: string;\n owner?: Agent;\n run(): TaskHooks;\n}',
},
{
name: 'TaskStatus',
declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
},
{
name: 'TerminalCallView',
declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}',

View File

@@ -103,7 +103,7 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute``tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: `agent/pre-step`
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
- Persistence: `session/event` + `session/flush`
- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`)

View File

@@ -23,7 +23,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(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -30,6 +30,8 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.

View File

@@ -41,6 +41,10 @@ export interface Config {
persistenceRoot?: string
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
}
// Each front door owns a complete, directly readable config schema; extracting
@@ -58,6 +62,8 @@ export const Config: z<Config> = z.object({
// apply() fallback through one named constant while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
})
/* jscpd:ignore-end */
@@ -74,6 +80,8 @@ export function apply(ctx: Context, config: Config): void {
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
})
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })

View File

@@ -19,8 +19,9 @@ import * as acpAgent from '../src/index.ts'
* ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`;
* this spec asserts the composition and the persistenceRoot default branch.
*/
async function mount(config: acpAgent.Config): Promise<Context> {
async function mount(config: acpAgent.Config, withBash = false): Promise<Context> {
const ctx = new Context()
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
await ctx.plugin(acpAgent, config)
// The bundle mounts its children inside apply() (not awaited there); let their
// fibers settle so the spine services are ready.
@@ -112,6 +113,19 @@ describe('dsh-acp-demo composition', () => {
await ctx.fiber.dispose()
})
it('forwards bundled tool config into agent-core', async () => {
const ctx = await mount({
model: 'mock',
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
skills: await isolatedSkillsConfig(),
}, true)
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
.not.toContain('run_in_background')
await ctx.fiber.dispose()
})
it('exposes its plugin shape', () => {
expect(acpAgent.name).toBe('acp-demo')
expect(acpAgent.Config).toBeDefined()
@@ -134,7 +148,7 @@ describe('dsh-acp-demo composition', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output'])
await ctx.fiber.dispose()
})

View File

@@ -17,9 +17,11 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-skill skill provider registry
@deepseek-ai/dsh-skill-local local filesystem skill provider
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
@deepseek-ai/dsh-invariants runtime event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash schema
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
@deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
(dsh-system-prompt gets the forwarded `persona`)
```
@@ -39,11 +41,12 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas,
// { agents?, persona?, toolOrder?, tools?, skills?, toolBash?, toolTasks? }
// The schema intersects the owner schemas,
// so validation and defaulting can never drift from the owners.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates one under the `main` config label; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates one under the `main` config label; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
## Why a code bundle, not a shared YAML include

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-spine-demo",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + invariants + tool-bash + tool-skill + agent-loop)",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + tool-skill + tool-tasks + agent-loop)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -31,8 +31,10 @@
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^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-skill": "^0.0.1",
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -46,8 +48,10 @@
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
},

View File

@@ -1,7 +1,8 @@
/**
* Default executor-less, UI-less agent spine. It bundles the common services,
* concrete loop, local skill provider, and model-facing bash/skill consumers;
* deployments still choose the LLM adapter, bash executor, and presentation.
* background-task registry and controls, concrete loop, local skill provider,
* and model-facing bash/skill consumers; deployments still choose the LLM
* adapter, bash executor, and presentation.
* The plugin intentionally exposes named exports only because Loader default
* unwrapping would discard its `Config` schema (see docs/postmortem/0001).
* @module @deepseek-ai/dsh-agent-spine-demo
@@ -17,9 +18,11 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
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 toolSkill from '@deepseek-ai/dsh-tool-skill'
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-spine-demo'
@@ -35,13 +38,19 @@ export interface SkillConfig {
}
/**
* Bundle config: each field forwarded verbatim to the child that owns it — `agents` to the
* agent loop (an app that pre-creates no agents, like the ACP bridge, omits it),
* `persona` and `toolOrder` to the system-prompt plugin (the deployment's persona section and
* the explicit model-facing tool order), the `tools` object to the tool registry (its
* presentation `mode`), and `skills` to the skill registry/local provider/tool consumer.
* The schema intersects the owners' schemas, which supply defaults for every
* optional input and keep validation from drifting.
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle
* owns. Producer opt-in stays producer-local: `toolBash` configures bash only;
* future background-capable tools remain independently composed plugins.
* Every field is optional INPUT here because each owner's schema
* supplies the default (`[]` / `''` / absent — lexicographic / `native`); the
* schema is the INTERSECTION of the owners' own schemas (the registry's
* nested under its `tools` key), so validation and defaulting can never
* drift from them.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -54,6 +63,10 @@ export interface Config {
tools?: ToolsConfig
/** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig
/** Model-facing bash tool config, including this producer's background opt-in. */
toolBash?: toolBash.Config
/** Generic background-task control-tool wait bounds. */
toolTasks?: toolTasks.Config
}
/** The skill config schema exported for app packages that forward `skills`. */
@@ -63,11 +76,22 @@ export const SkillConfigSchema: z<SkillConfig> = z.object({
tool: toolSkill.Config,
})
/** The bash-tool config schema exported for app packages that forward `toolBash`. */
export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
/** Intersect the owners' schemas so validation + defaulting stay identical. */
export const Config = z.intersect([
AgentLoop.Config,
SystemPrompt.Config,
z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema }),
z.object({
tools: ToolRegistry.Config,
skills: SkillConfigSchema,
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
}),
]) as unknown as z<Config>
/**
@@ -92,8 +116,10 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, config.skills?.local ?? {})
ctx.plugin(AgentRegistry)
ctx.plugin(TaskService)
ctx.plugin(invariants)
ctx.plugin(toolBash)
ctx.plugin(toolBash, config.toolBash ?? {})
ctx.plugin(toolSkill, config.skills?.tool ?? {})
ctx.plugin(toolTasks, config.toolTasks ?? {})
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -7,10 +7,15 @@ import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
probe: 'probe'
}
}
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const agent = { session: { header: { cwd } } } as unknown as Agent
const empty: Message[] = []
@@ -30,12 +35,13 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
* Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
* bin smokes; here we assert the composition + config forwarding.
*/
async function mount(config?: agentCore.Config): Promise<Context> {
async function mount(config?: agentCore.Config, withBash = false): Promise<Context> {
const oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-'))
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-agents-'))
const ctx = new Context()
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
try {
await ctx.plugin(agentCore, config)
// The bundle mounts its children inside apply() (not awaited there); let their
@@ -88,6 +94,7 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(ctx.get('tools')).toBeDefined()
expect(ctx.get('skills')).toBeDefined()
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('tasks')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
await ctx.fiber.dispose()
})
@@ -157,6 +164,33 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('forwards its bundled tool configs to tool-bash and tool-tasks', async () => {
const ctx = await mount({
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
}, true)
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
expect(bash).toBeDefined()
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
.not.toContain('run_in_background')
const id = ctx.tasks.start({
kind: 'probe',
label: 'config forwarding probe',
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
})
const wait = vi.spyOn(ctx.tasks, 'wait')
await ctx.tools.execute({
callId: CallId('task-config-forwarding'),
name: 'task_output',
arguments: { task_id: id, wait: true },
})
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
await ctx.fiber.dispose()
})
it('uses the default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
@@ -181,7 +215,7 @@ describe('dsh-agent-spine-demo bundle', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output'])
await ctx.fiber.dispose()
})

View File

@@ -11,9 +11,6 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/timer"
},
@@ -52,6 +49,12 @@
},
{
"path": "../../bash/tool-bash"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../tasks/tool-tasks"
}
]
}

View File

@@ -30,6 +30,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |

View File

@@ -49,6 +49,10 @@ export interface Config {
welcome?: string
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-core. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/**
* If set, the pre-created agent RESUMES this persisted session id instead of
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
@@ -70,6 +74,8 @@ export const Config: z<Config> = z.object({
persistenceRoot: z.string().default('./.sessions'),
welcome: z.string().default('ready.'),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
resumeSessionId: z.string(),
})
@@ -102,6 +108,8 @@ export function apply(ctx: Context, config: Config): void {
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
}],
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
})
ctx.plugin(toolAskUser)
}

View File

@@ -16,8 +16,9 @@ import * as stdioAgent from '../src/index.ts'
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
* survive namespace collapse while silently losing its schema.
*/
async function mount(config: stdioAgent.Config): Promise<Context> {
async function mount(config: stdioAgent.Config, withBash = false): Promise<Context> {
const ctx = new Context()
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
await ctx.plugin(stdioAgent, config)
// The app mounts its children inside apply() (not awaited there); let their
// fibers settle so the spine services + the pre-created agent are ready.
@@ -144,6 +145,19 @@ describe('dsh-stdio-demo app', () => {
await ctx.fiber.dispose()
})
it('forwards bundled tool config into agent-core', async () => {
const ctx = await mount({
model: 'mock',
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
skills: await isolatedSkillsConfig(),
}, true)
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
.not.toContain('run_in_background')
await ctx.fiber.dispose()
})
it('exposes its name and Config schema', () => {
expect(stdioAgent.name).toBe('stdio-demo')
expect(stdioAgent.Config).toBeDefined()
@@ -166,7 +180,7 @@ describe('dsh-stdio-demo app', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill'])
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output'])
await ctx.fiber.dispose()
})

View File

@@ -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,
sandboxMode: request.sandboxMode,
}
},

View File

@@ -2,6 +2,8 @@
Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly.
The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal.
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.

View File

@@ -7,14 +7,13 @@
*/
import { spawnSync } from 'node:child_process'
import { realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { grantArgs as landlockGrantArgs, LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
import { Context } from 'cordis'
import z from 'schemastery'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
/** Plugin config. All optional — `static Config` supplies the defaults. */
export interface Config {
@@ -38,77 +37,6 @@ export interface Config {
probeTimeoutMs?: number
}
/**
* Build a bwrap profile: the host is read-only with fresh `/dev` and `/proc`;
* workspace-write overlays writable temp and workspace mounts. PID and network
* isolation are intentionally outside the file-effect policy.
*
* @param policy - the file-effect policy to express as bwrap arguments.
* @returns the bwrap profile arguments (before the trailing `--` + argv).
*/
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
if (policy.mode === 'workspace-write') {
args.push('--tmpfs', '/tmp')
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
}
return args
}
/**
* Build Landlock grants for the same file policy without synthetic mounts.
* Read-only grants only `/dev/null` for writes; workspace-write also grants the
* host temp root and workspace.
*
* @param policy - the file-effect policy to express as launcher grants.
* @returns the launcher grant arguments (before `--` + argv).
*/
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
const readWrite = ['/dev/null']
if (policy.mode === 'workspace-write') {
readWrite.push('/tmp', policy.workspaceRoot)
}
return landlockGrantArgs({ readOnly: ['/'], readWrite })
}
/**
* Resolve a granted root to the path the kernel actually sees. Seatbelt path
* filters match the CANONICAL path (symlinks resolved), and the roots this
* profile grants are symlinked on every macOS: `/tmp` is `/private/tmp` and
* the user temp dir lives under `/var` → `/private/var` — an as-spelled
* grant would match nothing.
*/
function canonicalPath(path: string): string {
try {
return realpathSync(path)
} catch {
// An unresolved grant matches nothing until the named path exists; keep its spelling.
return path
}
}
/** Quote one path as an SBPL string literal (backslashes and double quotes escaped). */
function sbplString(path: string): string {
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
}
/**
* Build a Seatbelt profile that denies file writes then allows `/dev/null` and,
* for workspace-write, the canonical workspace, host temp, and per-user macOS
* temp roots. Network and process visibility remain unrestricted.
*
* @param policy - the file-effect policy to express as an SBPL profile.
* @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + argv).
*/
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
if (policy.mode === 'workspace-write') {
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
}
return ['-p', forms.join(' ')]
}
/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */
function defaultProbeBwrap(timeoutMs: number): boolean {
const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {

View File

@@ -0,0 +1,67 @@
/**
* Internal platform-profile builders for the local sandbox provider.
*
* @module @deepseek-ai/dsh-sandbox-local/profiles
*/
import { realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
/**
* Build the bwrap profile arguments for one file-effect policy.
* @param policy - file-effect policy to express as bwrap mounts.
* @returns profile arguments before the trailing separator and command argv.
*/
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
if (policy.mode === 'workspace-write') {
args.push('--tmpfs', '/tmp')
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
}
return args
}
/**
* Build the Landlock launcher grants for one file-effect policy.
* @param policy - file-effect policy to express as Landlock allow-list grants.
* @returns launcher grant arguments before the trailing separator and command argv.
*/
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
const readWrite = ['/dev/null']
if (policy.mode === 'workspace-write') {
readWrite.push('/tmp', policy.workspaceRoot)
}
return landlockGrantArgs({ readOnly: ['/'], readWrite })
}
/** Resolve a granted root to the canonical path the Seatbelt kernel sees. */
function canonicalPath(path: string): string {
try {
return realpathSync(path)
} catch {
// Missing or unreadable roots stay as spelled; an unresolved root grants
// nothing until it exists, which is the conservative outcome.
return path
}
}
/** Quote one path as an SBPL string literal. */
function sbplString(path: string): string {
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
}
/**
* Build the sandbox-exec arguments and SBPL profile for one policy.
* @param policy - file-effect policy to express as an SBPL profile.
* @returns sandbox-exec arguments before the trailing separator and command argv.
*/
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
if (policy.mode === 'workspace-write') {
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
}
return ['-p', forms.join(' ')]
}

View File

@@ -6,7 +6,8 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { bwrapProfileArgs } from '../src/profiles.ts'
/**
* Keyless backend integration through `confine()` and a real bwrap process. With no rung forced,

View File

@@ -15,12 +15,10 @@ import { Context } from 'cordis'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import {
bwrapProfileArgs,
landlockProfileArgs,
LocalSandboxProvider,
seatbeltProfileArgs,
} from '@deepseek-ai/dsh-sandbox-local'
import type { Config } from '@deepseek-ai/dsh-sandbox-local'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../src/profiles.ts'
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }

View File

@@ -6,7 +6,8 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { seatbeltProfileArgs } from '../src/profiles.ts'
/**
* Keyless backend integration through `confine()` and a real macOS Seatbelt process, with Linux

View File

@@ -60,13 +60,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
## Collection model
The current model-facing tool collects synchronously: it awaits the child result and disposes the run before returning. Background collection and polling remain outside this seam. See the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md) and `src/types.ts` for the complete contracts.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; the consumer registers startup and the eventual run with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md), the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
## Model Experience
Indirectly, through `dsh-tool-subagent`, which retains only a provider's data-dependent final output or exact `Error: no subagent provider registered for "<name>"`, `Error: subagent provider "<name>" does not support the "<capability>" capability`, and `Error: <message>` start failures in the parent while child working tokens remain child-only.
Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only.
## Known Limitations and Deferred Work
- **The current consumer collects synchronously** — the model-facing tool starts a run and awaits `result`; steering (`sendMessage`) is part of the seam but intentionally unused, and background/poll/spill semantics are deferred to a future long-running-runtime design.
- **The lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface is deferred until a consumer needs one.
- **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool.
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.

View File

@@ -4,6 +4,22 @@
* child before returning its run, so fulfillment is the single publication and
* ownership-transfer boundary.
*
* Unlike the bash seam (one executor per context, second load throws), MULTIPLE
* providers coexist here: each registers under a unique name and a caller picks
* one by name. The shape mirrors the LLM adapter registry
* (`LlmService.registerAdapter`), not the single-service bash executor.
*
* This package is the INTERFACE third of the capability seam. Implementations
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
*
* 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.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
* serialization and hostile-input validation belong at real process, worker,

View File

@@ -1,66 +1,51 @@
# @deepseek-ai/dsh-tool-subagent
The `subagent` tool lets the model delegate one self-contained task and collect the child's final output. It is a thin consumer of `ctx.subagents`; changing the configured provider changes the transport without changing the model-facing execution contract.
The model-facing delegation tool over one configured `ctx.subagents` provider. Changing the provider changes transport without changing the execution contract.
## Provider selection
## Provider selection and lifecycle
Each plugin instance binds to exactly one provider. The model sees `{ description, prompt }`, not a provider selector. To expose multiple transports, load the plugin multiple times with distinct `toolName` values.
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
The description is derived from `provider.inheritsParentContext`: spawn and ACP tell the model to provide a standalone prompt, while fork says the child already sees completed conversation turns. The plugin follows `subagent/provider-added` and `subagent/provider-removed`, so concurrent Cordis plugin loading does not create a registration-order dependency.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
## Lifecycle
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).
`execute` passes the tool execution's abort signal when present, otherwise supplies an inert signal to satisfy the required `SubagentStartRequest.signal`. It awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The selected signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort.
A non-`completed` stop reason becomes an `isError` tool result; partial child output is never reported as success. The current tool blocks the parent turn until collection finishes; background and polling modes are deferred.
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
## Config
| Key | Meaning |
|---|---|
| `provider` | Required `ctx.subagents` provider name. |
| `toolName` | Model-facing tool name (default `subagent`). Must be unique per plugin instance. |
| `agentOptions` | Default child agent options, currently including `model`. |
| `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). |
| `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. |
| `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. |
| `agentOptions` | Default child options, currently including `model`. |
| `persona` | Per-child persona; requires provider `persona` capability. |
| `toolFilter` | Per-child global-tool restriction; requires provider `toolFilter` capability. |
| `maxDepth` | Absolute delegation-depth cap; requires provider `depthLimit` capability. |
`toolFilter` changes the child's visible global tool layer; it is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
| `maxDepth` | Absolute delegation-depth cap; requires `depthLimit` capability. |
## Model Experience
### Standalone-provider schema
### Tool schema
**What the model sees**: While a fresh-context provider exists, the configured tool uses the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent); the catalog also records how `toolName` changes the visible name.
**What the model sees**: The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`.
**Token effect**: Fixed schema cost per parent request while mounted. Removing the provider removes the whole schema.
**Token effect**: Fixed schema cost per parent request; each provider instance adds one schema.
### Inherited-context-provider schema
### Foreground result
**What the model sees**: Relative to the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent), a provider that seeds completed turns replaces only the tool and `prompt` parameter descriptions with the text below; the shape and `description` parameter stay unchanged.
**What the model sees**: The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: <message>`. Intermediate child steps stay out of the parent.
**Token effect**: Fixed schema cost per parent request while mounted. Exposing multiple providers adds one independently named schema per load.
**Token effect**: The prompt and result remain in parent history until compaction; child working context remains in the child.
#### Inherited-context-provider tool description
### Background task result
```markdown
Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.
```
**What the model sees**: Start returns exactly `started background subagent task <id>`. The generic task surface provides later status, final output, cancellation responses, and notices.
#### Inherited-context-provider prompt description
```markdown
The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new.
```
### Tool-call history and result
**What the model sees**: The task description and full prompt remain in the parent assistant tool call. Success contains only the child's data-dependent final text. Other stop reasons become exactly `Error: subagent run was cancelled`, `Error: subagent run failed`, `Error: subagent run hit its token limit before finishing`, `Error: subagent declined the task`, or `Error: subagent run ended abnormally (<reason>)`; a call without an owning agent becomes `Error: subagent tool requires a calling agent (exec.agent was undefined)`. Intermediate child steps never enter the parent.
**Token effect**: Prompt and final output are data-dependent retained tokens. All child working context is paid in the child and omitted from the parent.
**Token effect**: The acknowledgement is retained; final output enters parent history only when collected or injected.
## Known Limitations and Deferred Work
- **Delegation blocks the parent turn** — synchronous collect only; background start and poll collection are deferred to the long-running-runtime redesign.
- **Duplicate `toolName` across waiting loads is detected late** (`TODO(subagent-dup-toolname)`) — two loads waiting on providers collide only when a provider arrives, and the throw rolls back the provider's fiber rather than the misconfigured tool's; config-time detection needs a cross-fiber registry of intended names.
- **Child policy is fixed per tool registration** — `model`, persona, tool filter, and depth cap come from this plugin load's config, not model-call arguments; exposing another policy requires another distinctly named tool.
- **Background runs expose final output only** — intermediate child steps stay in the child session.
- **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names.
- **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool.

View File

@@ -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.7"
},
@@ -32,13 +33,15 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@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.5",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,21 +1,20 @@
/**
* Model-facing delegation tool bound by configuration to one provider; transport selection is not
* exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and
* re-derives conversation-history wording after reload, so load order is irrelevant.
*
* Execution synchronously awaits the child result and always disposes the run. Non-completed stop
* reasons become error results, while transport details remain behind `ctx.subagents`. Load this
* plugin more than once to expose multiple configured providers.
* Model-facing delegation through one configured `ctx.subagents` provider.
* Provider lifecycle controls tool registration and context-sensitive schema
* wording. Foreground calls always dispose the run after collection; background
* calls use an independent cancellation signal and settle a final-output task
* only after child disposal.
* @module @deepseek-ai/dsh-tool-subagent
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
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']
@@ -25,34 +24,29 @@ export interface Config {
/** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
provider: string
/**
* The model-facing tool name to register (default `subagent`). To expose more
* than one transport, load this plugin once per provider — each load MUST set
* a distinct `toolName` (the tool registry rejects a duplicate name), e.g.
* `{ provider: 'spawn', toolName: 'subagent' }` and
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
* Model-facing tool name (default `subagent`). Each loaded instance must use
* a distinct name.
*/
toolName?: string
/**
* Default per-child agent options (model) applied to every spawned child.
* Omitted fields fall back to the child loop's own defaults.
* Expose `run_in_background` (default true). Disabled instances omit the
* parameter and reject forced background calls.
*/
enableRunInBackground?: boolean
/**
* Agent options applied to every child; omitted fields use child-loop defaults.
*/
agentOptions?: AgentOptions
/**
* Per-child persona applied to every child this tool spawns: a scoped
* `deployment:persona` section shadowing the deployment's persona for the
* child alone. Requires the bound provider's `persona` capability
* (in-process backends support it; a request against one that doesn't is
* rejected at start). Omitted ⇒ the child renders the deployment persona.
* Per-child persona that shadows `deployment:persona`. Requires the
* provider's `persona` capability; omission preserves the deployment persona.
*/
persona?: string
/**
* Tool scoping applied to every child this tool spawns (see
* `SubagentStartRequest.toolFilter`): the named global tools vanish from
* the child's prompt AND refuse to execute. Requires the provider's
* `toolFilter` capability. Unknown names fail the spawn loudly. Note the
* child otherwise sees every global tool — including this delegation tool
* itself; `deny`-listing it (or setting `maxDepth`) is how a deployment
* bounds recursion.
* Tool filter applied to every child. Filtered tools disappear from its
* prompt and reject execution. Requires the provider's `toolFilter`
* capability; unknown names fail startup. Children otherwise see this tool,
* so deny it or set `maxDepth` to bound recursion.
*/
toolFilter?: {
/** Global tool names the child keeps; everything else is removed. */
@@ -61,12 +55,8 @@ export interface Config {
deny?: string[]
}
/**
* Recursion cap applied to every child this tool spawns (see
* `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
* than this in the delegation tree is rejected. Requires the provider's
* `depthLimit` capability. Must be a non-negative safe integer and is
* validated when the plugin loads. Omitted ⇒ unbounded (bound it in
* deployments that expose this tool to children).
* Maximum child depth. Requires the provider's `depthLimit` capability and a
* non-negative safe integer. Omission is unbounded.
*/
maxDepth?: number
}
@@ -74,16 +64,13 @@ export interface Config {
export const Config: z<Config> = z.object({
provider: z.string().required(),
toolName: z.string().default('subagent'),
// Omitted-object discipline (see the toolFilter note below): without the
// forced default an omitted `agentOptions` materializes `{}`, which reads as
// present — the request would carry `agentOptions: {}` and the presence
// check in execute() could never be false through config.
enableRunInBackground: z.boolean().default(true),
// Prevent Schemastery from materializing omitted agentOptions as `{}`.
agentOptions: z.object({
model: z.string(),
}).default(undefined as unknown as { model: string }),
persona: z.string(),
// Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which
// silently means deny all. Preserve omission while retaining an explicit empty allow-list.
// Preserve omission; Schemastery's `{ allow: [] }` default would deny every tool.
toolFilter: z.object({
allow: z.array(z.string()).default(undefined as unknown as string[]),
deny: z.array(z.string()).default(undefined as unknown as string[]),
@@ -93,9 +80,8 @@ export const Config: z<Config> = z.object({
/**
* Flatten a child's final output blocks to text for the tool result. The child
* may return non-text blocks; this cut surfaces the text content (the common
* case) and drops the rest, which is acceptable for a synchronous summary —
* the structured path (`outputSchema`) is the channel for non-text results.
* may return non-text blocks; this path returns only text. Structured results
* use `outputSchema`.
*/
function outputText(blocks: ContentBlock[]): string {
return blocks
@@ -124,6 +110,50 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
/**
* Map a child result to the task outcome: completed carries final text,
* aborted is killed, and every other reason is failed without partial output.
* @param result - child terminal result.
* @returns 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 reasons remain failures with their raw detail.
default:
return { status: 'failed', detail: String(result.stopReason) }
}
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
* @param run - live run to settle and release.
* @returns outcome after child resources are released.
*/
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
let outcome: TaskOutcome
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: String(error) }
}
try {
await run.dispose()
} catch (error: unknown) {
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
}
return outcome
}
/**
* Model-facing wording from the provider's conversation-history descriptor
* ({@link SubagentProvider.inheritsParentContext}).
@@ -140,7 +170,7 @@ export function providerWording(inheritsConversation: boolean): { description: s
if (inheritsConversation) {
return {
description:
'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all '
'Delegate a task to a subagent that inherits this conversation: a child agent seeded with all '
+ 'completed turns so far (it does not see the current in-flight turn), returning only its final '
+ 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, '
+ 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
@@ -163,30 +193,47 @@ export function providerWording(inheritsConversation: boolean): { description: s
}
}
function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
return {
prompt: [{ type: 'text', text: prompt }],
parent,
signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
}
}
/** Settle pending startup without rejecting the task producer contract. */
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
try {
return await settleRun(await start)
} catch (error: unknown) {
return signal.aborted
? { status: 'killed' }
: { status: 'failed', detail: String(error) }
}
}
export function apply(ctx: Context, config: Config): void {
// Keep misconfiguration at plugin load even when a caller invokes apply()
// directly and bypasses Schemastery's natural/max metadata.
// Direct apply() bypasses Schemastery's numeric constraints.
assertSubagentMaxDepth(config.maxDepth)
// Misconfiguration fails loud AT LOAD (the check is self-contained): an
// explicit `toolFilter: {}` would otherwise pass the capability gate and
// kill every delegation later, in the child-setup `restrict({})` throw.
// Reject an empty explicit filter at load instead of failing every delegation.
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
}
// The tool MIRRORS its provider's lifecycle instead of assuming load order:
// the cordis Loader starts sibling entries concurrently, so "backend listed
// first in cordis.yml" does not guarantee "provider registered first", and
// an HMR reload of the backend replaces the provider while this fiber stays
// loaded. Register the tool when the bound provider is (or becomes)
// available — deriving the wording from THAT provider — and unregister it
// when the provider goes away, so the description can never outlive or
// predate the provider it describes.
// Mirror provider lifecycle because sibling load order and HMR replacement
// can change provider availability while this fiber remains active.
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 return a task id; collect with `task_output` and stop with `task_kill`.'
: ''),
parameters: {
description: {
type: 'string',
@@ -198,55 +245,85 @@ export function apply(ctx: Context, config: Config): void {
required: true,
description: wording.promptDescription,
},
...backgroundEnabled ? {
run_in_background: {
type: 'boolean' as const,
description: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
},
} : {},
},
async execute(args, exec): Promise<ContentBlock[]> {
const parent = exec.agent
if (!parent) {
// The loop sets `exec.agent` for every model-driven call; its absence
// means a non-agent caller invoked the tool directly, which has no
// parent to attribute the child to. Fail loud rather than guess.
// Non-agent callers provide no parent for delegation ownership.
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
}
const request: SubagentStartRequest = {
prompt: [{ type: 'text', text: args.prompt }],
parent,
signal: exec.signal ?? new AbortController().signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
if (args.run_in_background === true) {
// The validator permits undeclared keys, so schema omission also needs
// execution-time enforcement.
if (!backgroundEnabled) {
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
}
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')
}
// Reject cancellation before spawning; after return, the task-owned
// signal covers both pending startup and the ready child.
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
// Task preflight finishes before the starter can spawn a child.
const id = tasks.start({
kind: 'subagent',
label: args.description,
owner: parent,
run: () => {
const controller = new AbortController()
const start = ctx.subagents.start(
config.provider,
startRequest(config, args.prompt, parent, controller.signal),
)
return {
cancel: (reason?: string) => {
controller.abort(reason ?? 'background subagent task killed')
},
done: settleStart(start, controller.signal),
// No readOutput: the child session owns intermediate detail.
}
},
})
return [{ type: 'text', text: `started background subagent task ${id}` }]
}
const request = startRequest(
config,
args.prompt,
parent,
exec.signal ?? new AbortController().signal,
)
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
try {
const result = await run.result
const error = stopReasonError(result)
if (error !== undefined) {
// Map a non-clean finish to an isError result (the registry turns a
// throw into an isError). Report the reason, not partial output.
// The registry converts this throw to isError; partial output is not success.
throw new Error(error)
}
return [{ type: 'text', text: outputText(result.output) }]
} finally {
// Always reach child quiescence — never leak a live idle child/session.
// Dispose before returning so no child session outlives the call.
await run.dispose()
}
},
}))
}
// Listeners first, then the presence check: both run synchronously, so no
// registration can slip between them; the `disposeTool === undefined` guard
// makes a same-tick added-event after a successful mount a no-op.
// Register listeners before checking presence so no synchronous change is missed.
// TODO(subagent-dup-toolname): two WAITING fibers configured with the same
// toolName collide only when their provider finally arrives — the duplicate
// tool-name throw then propagates through `subagent/provider-added` and
// rolls back the PROVIDER registration, so an invalid config blasts the
// backend's fiber instead of the misconfigured tool's. Config-time detection
// would need a cross-fiber registry of intended tool names; revisit if a
// real deployment ever hits it.
// toolName collide when their provider appears, and the duplicate-name throw
// rolls back the provider registration. Add an intent registry if this occurs.
ctx.on('subagent/provider-added', (provider) => {
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
})
@@ -259,9 +336,7 @@ export function apply(ctx: Context, config: Config): void {
if (present !== undefined) {
mount(present)
} else {
// Not an error: the backend's fiber may activate after this one.
// The tool appears the moment the provider registers; a typo'd provider
// name shows up as this note plus a tool that never materializes.
// A backend fiber may activate later; a misspelled provider remains visible in this log.
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
}
}

View File

@@ -5,10 +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 { 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'
import { SessionId } from '@deepseek-ai/dsh-session'
/**
@@ -62,12 +65,36 @@ 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('refuses a forced run_in_background at execution time when the instance disables it', async () => {
// Schema omission is advertising, not enforcement: the arg validator
// allows undeclared keys, so the opt-out must also hold in execute().
const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
const parent = { id: SessionId('sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent
const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent })
expect(forced.isError).toBe(true)
expect(text(forced)).toContain('run_in_background is disabled for this tool instance')
// The provider was never asked to start a child.
expect(ctx.subagents.getProvider('mock')).toBeDefined()
const foreground = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: parent })
expect(foreground.isError).toBe(false)
})
it.each([
@@ -227,7 +254,7 @@ describe('dsh-tool-subagent', () => {
// Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived
// from the fresh provider, not served stale from the first mount.
await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true })
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation')
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('inherits this conversation')
})
it('the tool PLUGIN fiber owns its lifecycle listeners: disposal unmounts, and a disposed fiber never zombie-mounts', async () => {
@@ -277,10 +304,10 @@ describe('dsh-tool-subagent', () => {
expect(props['prompt']!.description).toContain('include everything it needs')
})
it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => {
it('derives inherited-context wording from a seeded-conversation provider', async () => {
const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('INHERITS this conversation')
expect(schema.description).toContain('inherits this conversation')
expect(schema.description).not.toContain('does not see this conversation')
const props = (schema.parameters as { properties: Record<string, { description: string }> }).properties
expect(props['prompt']!.description).toContain('completed turns')
@@ -557,3 +584,270 @@ describe('dsh-tool-subagent', () => {
await expect(fiber).rejects.toThrow(/names neither `allow` nor `deny`/)
})
})
describe('dsh-tool-subagent background mode', () => {
/** A live parent with a dedicated scope fiber for structural task cleanup. */
function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
const scopeFiber = ctx.plugin(() => {})
const id = SessionId(sessionId)
const agent = {
id,
ctx: scopeFiber.ctx,
inject,
session: { id, header: { version: 0, id, 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('settles an asynchronous provider-start failure as a failed task', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
ctx.subagents.registerProvider({
name: 'broken-start',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => { throw new Error('setup failed') },
})
tool.apply(ctx, { provider: 'broken-start', toolName: 'subagent_broken' })
const started = await ctx.tools.execute({
callId: CallId('broken-start'),
name: 'subagent_broken',
arguments: { description: 'broken', prompt: 'p', run_in_background: true },
agent: parent,
})
expect(text(started)).toBe('started background subagent task subagent-1')
const output = await ctx.tools.execute({
callId: CallId('broken-output'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
agent: parent,
})
expect(text(output)).toContain('[status: failed, Error: setup failed]')
})
it('kills a subagent task while provider readiness is still pending', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
ctx.subagents.registerProvider({
name: 'pending-start',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: request => new Promise((_resolve, reject) => {
request.signal.addEventListener('abort', () => { reject(new Error('startup aborted')) }, { once: true })
}),
})
tool.apply(ctx, { provider: 'pending-start', toolName: 'subagent_pending' })
await ctx.tools.execute({
callId: CallId('pending-start'),
name: 'subagent_pending',
arguments: { description: 'pending', prompt: 'p', run_in_background: true },
agent: parent,
})
await ctx.tools.execute({
callId: CallId('pending-kill'),
name: 'task_kill',
arguments: { task_id: 'subagent-1', reason: 'no longer needed' },
agent: parent,
})
const output = await ctx.tools.execute({
callId: CallId('pending-output'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
agent: parent,
})
expect(text(output)).toBe('(no new output)\n[status: killed]')
})
it('forwards task_kill reasons through the run signal (and defaults one when absent)', async () => {
// Use a provider that remains live until its signal is aborted.
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
const parent = ownerAgent(ctx, 'sess-parent')
const cancels: (string | undefined)[] = []
let starts = 0
ctx.subagents.registerProvider({
name: 'hanging',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async (request) => {
let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
const id = SessionId(`hang-${++starts}`)
const result = new Promise<{ output: { type: 'text'; text: string }[]; stopReason: 'aborted' }>((res) => { settle = res })
request.signal.addEventListener('abort', () => {
cancels.push(typeof request.signal.reason === 'string' ? request.signal.reason : undefined)
settle({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id,
result,
dispose: () => Promise.resolve(),
}
},
})
// Direct apply preserves omitted agentOptions instead of applying schema defaults.
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: SessionId('child-1'),
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
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: SessionId('child-2'),
result: Promise.reject(new Error('transport gone')),
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
const disposeFailed = await settleRun({
id: SessionId('child-3'),
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const bothFailed = await settleRun({
id: SessionId('child-4'),
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(bothFailed).toEqual({
status: 'failed',
detail: 'Error: result failed; dispose failed: Error: reap failed',
})
})
})
describe('background preflight failure (no orphaned child, by construction)', () => {
it('never starts the child when tasks.start preflight throws', async () => {
// With no control surface, task preflight fails before the provider can spawn.
const ctx = await setup({ provider: 'mock' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
const scopeFiber = ctx.plugin(() => {})
const id = SessionId('sess-p')
const parent = {
id,
ctx: scopeFiber.ctx,
inject: () => {},
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(parent)
let starts = 0
ctx.subagents.registerProvider({
name: 'probe',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => {
starts += 1
return {
id: SessionId('probe-child'),
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
dispose: () => 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')
// Declare-then-execute: the failed preflight means no child ever existed.
expect(starts).toBe(0)
})
})

View File

@@ -28,6 +28,9 @@
},
{
"path": "../subagent"
},
{
"path": "../../tasks/tasks"
}
]
}

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

@@ -0,0 +1,10 @@
# tasks/ — background task capability family
The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See 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 registry owns state across producer or surface reloads; the tool package owns presentation. Producers register execution hooks through `ctx.tasks.start` and own whether their config exposes `run_in_background`.

View File

@@ -0,0 +1,35 @@
# @deepseek-ai/dsh-tasks
The process-local background task registry (`ctx.tasks`). It gives long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup. Producer plugins extend `TaskKindMap` with their opaque id namespace.
## Service API
- `start(spec): TaskId` validates the control surface, spec, and exact live owner before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
- `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks.
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
- `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter.
- `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited.
- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached.
Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal.
## Lifecycle
Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
See the [task type catalog](../../../docs/core-data-structures/tasks.md) and [runtime RFC](../../../docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
## Model Experience
Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices.
## Known Limitations and Deferred Work
- **Tasks are process-local** — durable or cross-restart execution needs a separate lifecycle.
- **The service and implementation are not split** — a second backend must define the lifecycle that shapes that boundary.
- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API.
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely.

View File

@@ -0,0 +1,38 @@
{
"name": "@deepseek-ai/dsh-tasks",
"description": "Background task registry (ctx.tasks) for the DeepSeek Harness \u2014 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",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^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:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,441 @@
/**
* The in-process background task registry (`ctx.tasks`). It owns task ids,
* session-scoped access, lifecycle state, completion listeners, and owner
* cleanup while producers retain their execution resources.
*
* Registrations outlive producer and control-surface fibers. Agent or service
* disposal cancels live work and awaits compliant producers; a throwing
* teardown cancel force-fails only the record and reports a possible orphan.
* @module @deepseek-ai/dsh-tasks
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { TaskId } from './types.ts'
import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from './types.ts'
export { TaskId } from './types.ts'
export type {
TaskDoneListener,
TaskHooks,
TaskKind,
TaskKindMap,
TaskOutcome,
TaskRead,
TaskSnapshot,
TaskStart,
TaskStatus,
} from './types.ts'
declare module 'cordis' {
interface Context {
tasks: TaskService
}
}
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
interface TrackedTask {
id: TaskId
kind: TaskKind
label: string
/** Exact lifecycle owner; session-id authorization is derived from it. */
owner: Agent | 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 by the first effective settlement. */
markSettled: () => void
/** Live waits; settlement with a waiter marks the task reported. */
waiters: number
/** Removable resolvers for live waits; timeout/abort unregister before the task settles. */
waitResolvers: Set<() => void>
}
/** 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.
*/
// TODO(task-service-backend): Separate the service contract from this
// process-local implementation when a second backend defines its lifecycle.
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 with attached scope cleanup, mapped to the exact disposer. */
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
/** Service context used by detached settlement continuations and teardown. */
private readonly selfCtx: Context
constructor(ctx: Context) {
super(ctx, 'tasks')
this.selfCtx = ctx
ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
}
/**
* Preflight access, validation, and owner cleanup before starting and
* atomically registering work. A throwing starter leaves nothing registered;
* after it returns, registration cannot fail. Settlement records the outcome,
* notifies listeners, and releases waiters.
* @param spec - task identity, owner, and synchronous starter.
* @returns the registry-issued `<kind>-N` id.
*/
start(spec: TaskStart): TaskId {
if (this.surfaces.size === 0) {
throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
}
if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
const hooks = spec.run()
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = TaskId(`${spec.kind}-${count}`)
let markSettled!: () => void
const settled = new Promise<void>((resolve) => { markSettled = resolve })
const task: TrackedTask = {
id,
kind: spec.kind,
label: spec.label,
owner: spec.owner,
cancel: hooks.cancel.bind(hooks),
readOutput: hooks.readOutput?.bind(hooks),
status: 'running',
detail: undefined,
output: undefined,
startedAt: Date.now(),
finishedAt: undefined,
reported: false,
settled,
markSettled,
waiters: 0,
waitResolvers: new Set(),
}
this.store.set(id, task)
void hooks.done.then(
(outcome) => { this.settle(task, outcome) },
(error: unknown) => {
// Contain a producer contract violation so cleanup and waiters cannot 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
}
/**
* List caller-owned and unowned tasks in registration order without exposing
* another session's labels.
* @param caller - reading agent; a non-agent caller sees only unowned tasks.
* @returns fresh snapshots.
*/
list(caller?: Agent): TaskSnapshot[] {
const session = caller?.id
return [...this.store.values()]
.filter(task => task.owner === undefined || task.owner.id === session)
.map(task => this.snapshot(task))
}
/**
* Return a non-consuming snapshot without changing its read cursor or notice
* state. Throws for an unknown or foreign task.
* @param id - task to look up.
* @param caller - reading agent checked against the 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 the next stream delta, or the idempotent final output after settlement.
* A terminal read marks the task reported. Throws for an unknown or foreign
* task.
* @param id - task to read.
* @param caller - reading agent checked against the owner.
* @returns output text and 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, then mark the task stopping and reported. A producer
* throw propagates without changing task state. Throws for an unknown or
* foreign task.
* @param id - task to cancel.
* @param caller - killing agent checked against the owner.
* @param reason - logged reason forwarded to the producer.
* @returns `requested` for live work, otherwise `already-finished`.
*/
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' {
const task = this.expect(id)
this.assertAccess(task, caller)
if (isTerminal(task.status)) {
task.reported = true
return 'already-finished'
}
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
task.cancel(reason)
task.status = 'stopping'
task.reported = true
return 'requested'
}
/**
* Wait for settlement or timeout without cancelling the task. Caller abort
* rejects only while the task is live; after settlement it returns the
* terminal snapshot so a notice suppressed for this waiter is still delivered.
* Timed-out and aborted waits detach their resolvers. Throws for invalid,
* unknown, or foreign input.
* @param id - task to wait for.
* @param timeoutMs - positive finite wait bound in milliseconds.
* @param caller - waiting agent checked against the owner.
* @param signal - optional cancellation of the wait itself.
* @returns snapshot at settlement or timeout.
*/
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')
// Abort removes the waiter synchronously so same-tick settlement cannot
// suppress a notice for a wait that will reject.
task.waiters += 1
let counted = true
const uncount = (): void => {
if (!counted) return
counted = false
task.waiters -= 1
}
try {
// The scoped deadline distinguishes a successful wait timeout from
// caller cancellation and clears its timer on every exit.
using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
await new Promise<void>((resolve, reject) => {
const onSettled = (): void => {
task.waitResolvers.delete(onSettled)
d.signal.removeEventListener('abort', onAbort)
resolve()
}
const onAbort = (): void => {
task.waitResolvers.delete(onSettled)
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
resolve()
} else if (isTerminal(task.status)) {
// Settlement suppressed the notice for this waiter; deliver it.
resolve()
} else {
uncount()
reject(new Error('wait aborted'))
}
}
task.waitResolvers.add(onSettled)
d.signal.addEventListener('abort', onAbort, { once: true })
})
} finally {
uncount()
}
}
if (isTerminal(task.status)) task.reported = true
return this.snapshot(task)
}
/**
* Register an effect-scoped completion listener. Each listener is contained;
* returned promises are observed but not awaited. No listener runs after
* service disposal.
* @param listener - receives each terminal snapshot and its exact owner.
* @returns 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()
}
/**
* Attach an effect-scoped surface that can read and stop tasks. {@link start}
* refuses work while none is attached.
* @param name - diagnostic label; duplicate names remain independent.
* @returns disposer that detaches this surface.
*/
attachSurface(name: string): () => void {
// One token per call keeps duplicate labels independently disposable.
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.owner !== undefined && task.owner.id !== caller?.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 {
const ownerSession = task.owner?.id
return {
id: task.id,
kind: task.kind,
label: task.label,
...ownerSession !== undefined ? { ownerSession } : {},
status: task.status,
...task.detail !== undefined ? { detail: task.detail } : {},
startedAt: task.startedAt,
...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {},
reported: task.reported,
}
}
/**
* Record the first terminal outcome, notify contained listeners, and release
* waiters. First-wins preserves a teardown force-failure against late producer
* settlement. Pending waits mark the task reported before listeners run.
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
if (isTerminal(task.status)) return
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 {
const returned = listener(snapshot, task.owner)
void Promise.resolve(returned).catch((error: unknown) => {
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
})
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
const waitResolvers = [...task.waitResolvers]
task.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
task.markSettled()
}
/**
* Attach one awaited cleanup through the exact owner's scope. This survives
* producer reloads and joins agent quiescence; the retained disposer lets
* service teardown detach the cross-fiber effect. Fails when the registry is
* absent or the owner is not its currently registered instance.
*/
private ensureOwnerCleanup(owner: Agent): void {
const ownerId = owner.id
const agents = this.selfCtx.get('agents')
if (agents === undefined) {
throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
}
if (agents.get(ownerId) !== owner) {
throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`)
}
if (this.ownerCleanups.has(owner)) return
// Record only after attach succeeds; a disposing scope rejects new effects.
const detach = owner.ctx.effect(() => async () => {
this.ownerCleanups.delete(owner)
await this.disposeOwned(owner)
}, 'tasks.ownerCleanup()')
this.ownerCleanups.set(owner, detach)
}
/** Cancel, await terminal records, and drop every task owned by one exact agent lifecycle. */
private async disposeOwned(owner: Agent): Promise<void> {
const owned = [...this.store.values()].filter(task => task.owner === owner)
this.cancelForTeardown(owned, 'owner disposed')
await Promise.all(owned.map(task => task.settled))
for (const task of owned) this.store.delete(task.id)
}
/**
* Close listeners, cancel live tasks, await settlement, and detach owner
* effects. Throwing cancels are force-failed to avoid teardown deadlock.
*/
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()
// Detach cross-fiber owner effects after the shared store is quiescent.
const ownerCleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup())))
}
/**
* Cancel tasks during teardown with per-task containment. A throwing cancel
* force-fails the record and reports a possible orphan; a cancel that returns
* without settling remains indistinguishable from a slow stop and may stall.
*/
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
for (const task of tasks) {
if (isTerminal(task.status)) continue
try {
task.cancel(reason)
task.status = 'stopping'
} catch (error: unknown) {
const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`)
this.settle(task, { status: 'failed', detail })
}
}
}
}
export default TaskService

View File

@@ -0,0 +1,152 @@
/**
* Types shared by task producers, the registry, and control surfaces. The
* service implementation 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'
import type { SessionId } from '@deepseek-ai/dsh-session'
/**
* Identifies a background task. The registry generates `<kind>-N`; predictable
* ids rely on owner authorization rather than secrecy.
*/
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`, optionally `stopping`, then exactly one terminal
* status. Producer-specific facts belong in {@link TaskSnapshot.detail}.
*/
export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/**
* Producer-defined task kinds. Plugins extend this map by declaration merging;
* the registry treats every value as an opaque id namespace.
*/
export interface TaskKindMap {
bash: 'bash'
subagent: 'subagent'
}
/** The merge-extensible union of registered producer kind names. */
export type TaskKind = TaskKindMap[keyof TaskKindMap]
/** Terminal result supplied by a producer through {@link TaskHooks.done}. */
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 tasks without `readOutput`; stream tasks leave it unset. */
output?: string
}
/**
* Producer declaration passed to {@link TaskService.start}. The runtime
* preflights access and cleanup before invoking {@link run}; the producer owns
* execution resources while the runtime owns identity and lifecycle state.
*/
export interface TaskStart {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
kind: TaskKind
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the task. The instance must be the one currently
* registered under its agent id. Omitting the owner creates an unowned task,
* open to any caller until service disposal.
*/
owner?: Agent
/**
* Start the work after preflight and synchronously return its hooks. Called
* once; a throw leaves nothing registered, and the producer must clean up any
* partially started resources.
*/
run(): TaskHooks
}
/** Hooks through which the runtime controls and observes producer work. */
export interface TaskHooks {
/**
* Request termination. Must be synchronous, idempotent, and eventually settle
* {@link done}; throws propagate. The optional reason is forwarded verbatim.
*/
cancel(reason?: string): void
/**
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
* registry record without claiming that the work stopped.
*/
done: Promise<TaskOutcome>
/**
* Consume output produced since the previous call. The producer formats
* truncation and spill notices. Absence marks a final-output-only task; each
* task has one consuming cursor.
*/
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: TaskKind
/** The producer-supplied one-line label. */
label: string
/**
* Owner session id used for authorization and correlation; absent for
* unowned tasks. Completion listeners receive the exact {@link Agent}
* separately through {@link TaskDoneListener}.
*/
ownerSession?: SessionId
/** 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 when a kill, read, or wait has reported or committed to report the
* terminal state. Completion surfaces suppress redundant notices when set.
*/
reported: boolean
}
/** Output and post-read state returned by {@link TaskService.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 with the exact owner supplied at start, or `undefined`
* for an unowned task. Returned promises are observed but not awaited.
*/
export type TaskDoneListener = (
snapshot: TaskSnapshot,
owner: Agent | undefined,
) => void | PromiseLike<void>

View File

@@ -0,0 +1,740 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
workflow: 'workflow'
}
}
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scopeFiber = ctx.plugin(() => {})
const agent = {
id,
options: {},
session: new Session(id),
status: 'idle' as const,
ctx: scopeFiber.ctx,
send() {},
steer() {},
inject() {},
cancel() {},
whenIdle() { return Promise.resolve() },
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
return agent
}
async function disposeAgentScope(agent: Agent): Promise<void> {
const dispose = agentScopeDisposers.get(agent)
if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`)
await dispose()
}
/** A controllable producer start-spec: settle its `done` on demand, record cancels. */
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
let settle!: (outcome: TaskOutcome) => void
let reject!: (error: unknown) => void
const cancels: (string | undefined)[] = []
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
const hooks: TaskHooks = {
cancel(reason) { cancels.push(reason) },
done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
...hookOverrides,
}
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
return { spec, 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))
/** Inspect the internal resolver registry to pin bounded retention while a task stays live. */
function waitResolverCount(ctx: Context, id: TaskId): number {
const service = ctx.tasks as unknown as { store: Map<TaskId, { waitResolvers: Set<() => void> }> }
const task = service.store.get(id)
if (task === undefined) throw new Error(`missing test task ${id}`)
return task.waitResolvers.size
}
describe('TaskService.start', () => {
it('preserves the SessionId brand on public owner snapshots', () => {
expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
})
it('refuses to register while no control surface is attached', async () => {
const ctx = new Context()
await ctx.plugin(TaskService)
expect(() => ctx.tasks.start(producer().spec))
.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.start(producer({ kind: '' as TaskKind }).spec)).toThrow('invalid task kind')
expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
})
it('issues kind-prefixed ids from per-kind counters', async () => {
const ctx = await harness()
expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
expect(ctx.tasks.start(producer().spec)).toBe('bash-2')
expect(ctx.tasks.start(producer({ kind: 'subagent' }).spec)).toBe('subagent-1')
expect(ctx.tasks.start(producer({ kind: 'workflow' }).spec)).toBe('workflow-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.start(p.spec)
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.start(p.spec)
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.start(p.spec)
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.start(p.spec)
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 onTaskDone listener without starving later listeners', async () => {
const ctx = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: TaskId[] = []
ctx.tasks.onTaskDone(async () => { throw new Error('async listener boom') })
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
const p = producer()
const id = ctx.tasks.start(p.spec)
p.settle({ status: 'completed' })
await tick()
expect(seen).toEqual([id])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTaskDone listener rejected'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async 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.start(p.spec)
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.start(p.spec)
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.start(p.spec)
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-finished task instead of failing', async () => {
const ctx = await harness()
const p = producer()
const id = ctx.tasks.start(p.spec)
p.settle({ status: 'completed' })
await tick()
expect(ctx.tasks.kill(id)).toBe('already-finished')
})
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.start({
kind: 'bash',
label: 'flaky cancel',
run: () => ({
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-finished')
})
})
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.start(p.spec)
const wait = ctx.tasks.wait(id, 5_000)
p.settle({ status: 'completed', detail: 'exit code: 0' })
expect(await wait).toMatchObject({ status: 'completed', reported: true })
// A waiting reader claims delivery before completion listeners inspect the snapshot.
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.start(producer().spec)
expect(await ctx.tasks.wait(id, 5)).toMatchObject({ status: 'running', reported: false })
})
it('unregisters timed-out and aborted wait resolvers while the task remains live', async () => {
const ctx = await harness()
const id = ctx.tasks.start(producer().spec)
for (let index = 0; index < 3; index += 1) {
const wait = ctx.tasks.wait(id, 5)
expect(waitResolverCount(ctx, id)).toBe(1)
await expect(wait).resolves.toMatchObject({ status: 'running' })
expect(waitResolverCount(ctx, id)).toBe(0)
}
const controller = new AbortController()
const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
expect(waitResolverCount(ctx, id)).toBe(1)
controller.abort()
await expect(wait).rejects.toThrow('wait aborted')
expect(waitResolverCount(ctx, id)).toBe(0)
expect(ctx.tasks.get(id).status).toBe('running')
})
it('returns immediately for an already-finished task', async () => {
const ctx = await harness()
const p = producer()
const id = ctx.tasks.start(p.spec)
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.start(producer().spec)
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.start(producer().spec)
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')
})
it('an abort racing settlement in the same tick does not swallow 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.start(p.spec)
const controller = new AbortController()
const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
// Settlement is queued first, so abort must remove the waiter synchronously;
// otherwise settlement suppresses the notice for a reader that receives nothing.
p.settle({ status: 'completed', detail: 'exit code: 0' })
controller.abort()
await expect(wait).rejects.toThrow('wait aborted')
expect(seen).toHaveLength(1)
expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
})
it('an abort landing after settlement still delivers the terminal snapshot it owes', async () => {
const ctx = await harness()
const controller = new AbortController()
const seen: TaskSnapshot[] = []
// The listener aborts after settlement has assigned delivery to this waiter
// but before its resolve microtask; the waiter must still receive the result.
ctx.tasks.onTaskDone((snapshot) => {
seen.push(snapshot)
controller.abort()
})
const p = producer()
const id = ctx.tasks.start(p.spec)
const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await expect(wait).resolves.toMatchObject({ status: 'completed', reported: true })
expect(seen[0]).toMatchObject({ id, reported: true }) // suppression stays honest: the wait delivered
})
})
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(ctx, 'owner')
ctx.agents.register(owner)
const other = stubAgent(ctx, 'other')
const owned = ctx.tasks.start(producer({ owner }).spec)
const open = ctx.tasks.start(producer().spec)
// 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(ctx, 'alice')
const bob = stubAgent(ctx, 'bob')
ctx.agents.register(alice)
ctx.agents.register(bob)
const aliceTask = ctx.tasks.start(producer({ owner: alice }).spec)
const bobTask = ctx.tasks.start(producer({ owner: bob }).spec)
const openTask = ctx.tasks.start(producer({ kind: 'subagent' }).spec)
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.start(producer({ owner: stubAgent(ctx, 'a') }).spec))
.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.start(producer().spec)).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(ctx, 'ghost') // never registered in ctx.agents
// Exact-instance validation precedes registry mutation and cleanup attachment.
expect(() => ctx.tasks.start(producer({ owner: ghost }).spec))
.toThrow('is not the registered agent instance')
expect(ctx.tasks.list(ghost)).toEqual([])
// A later valid registration must still attach cleanup for the same object.
ctx.agents.register(ghost)
const cancels: (string | undefined)[] = []
let settle!: (outcome: TaskOutcome) => void
const id = ctx.tasks.start({
kind: 'bash',
label: 'after retry',
owner: ghost,
run: () => ({
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 disposeAgentScope(ghost)
expect(cancels).toEqual(['owner disposed'])
expect(ctx.tasks.list(ghost)).toEqual([])
})
it('rejects a stale owner instance after another agent reuses its id', async () => {
const ctx = await harness()
const staleOwner = stubAgent(ctx, 'owner')
const unregisterStale = ctx.agents.register(staleOwner)
unregisterStale()
const currentOwner = stubAgent(ctx, 'owner')
ctx.agents.register(currentOwner)
const current = producer({ owner: currentOwner })
ctx.tasks.start(current.spec) // Attach the current owner's cleanup first.
const stale = producer({ owner: staleOwner })
const staleRun = vi.fn(() => stale.spec.run())
expect(() => ctx.tasks.start({ ...stale.spec, run: staleRun }))
.toThrow('is not the registered agent instance')
expect(staleRun).not.toHaveBeenCalled()
// Access is keyed by the unified session id, so a reconnect carrying the
// same identity can observe the current task even though stale ownership
// registration is rejected by exact-instance validation.
expect(ctx.tasks.list(staleOwner)).toHaveLength(1)
expect(ctx.tasks.list(currentOwner)).toHaveLength(1)
current.settle({ status: 'completed' })
await tick()
await disposeAgentScope(currentOwner)
})
})
describe('TaskService owner cleanup', () => {
it('drains the owner: cancels live tasks, awaits settlement, drops snapshots', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, '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.start({
kind: 'subagent',
label: 'long research',
owner,
run: () => ({
cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
}),
})
const terminal = producer({ owner })
ctx.tasks.start(terminal.spec)
terminal.settle({ status: 'completed' })
await tick()
await disposeAgentScope(owner)
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 drains all owned tasks with the scope', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const first = producer({ owner })
const second = producer({ owner })
ctx.tasks.start(first.spec)
ctx.tasks.start(second.spec)
first.settle({ status: 'completed' })
second.settle({ status: 'completed' })
await tick()
expect(owner.ctx.fiber.getEffects().filter(effect => effect.label === 'tasks.ownerCleanup()')).toHaveLength(1)
await disposeAgentScope(owner)
expect(ctx.tasks.list(owner)).toEqual([])
})
it('does not let an old scope cleanup cancel a same-id/session replacement task', async () => {
const ctx = await harness()
const oldOwner = stubAgent(ctx, 'owner')
const detachOld = ctx.agents.register(oldOwner)
const cancels: string[] = []
function start(owner: Agent, label: string): TaskId {
let settle!: (outcome: TaskOutcome) => void
return ctx.tasks.start({
kind: 'bash',
label,
owner,
run: () => ({
cancel() { cancels.push(label); settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
}),
})
}
start(oldOwner, 'old task')
detachOld()
const replacement = stubAgent(ctx, 'owner')
ctx.agents.register(replacement)
const replacementId = start(replacement, 'replacement task')
await disposeAgentScope(oldOwner)
expect(cancels).toEqual(['old task'])
expect(ctx.tasks.list(replacement).map(task => task.id)).toEqual([replacementId])
await disposeAgentScope(replacement)
expect(cancels).toEqual(['old task', 'replacement task'])
})
it('registers owner cleanup on the agent scope rather than the tasks fiber', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const tasksFiber = await ctx.plugin(TaskService)
ctx.tasks.attachSurface('test-surface')
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const ownerCleanupEffects = () => owner.ctx.fiber.getEffects()
.filter(effect => effect.label === 'tasks.ownerCleanup()')
const first = producer({ owner })
ctx.tasks.start(first.spec)
expect(ownerCleanupEffects()).toHaveLength(1)
first.settle({ status: 'completed' })
await tick()
expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks.ownerCleanup()')).toBe(false)
await disposeAgentScope(owner)
// Only the owner registration is released; the long-lived tasks service
// and its own teardown effect remain active.
expect(ownerCleanupEffects()).toHaveLength(0)
expect(ctx.get('tasks')).toBeDefined()
expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks teardown')).toBe(true)
})
it('force-fails a throwing teardown cancel without awaiting producer done, first outcome wins', async () => {
const ctx = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
let settle!: (outcome: TaskOutcome) => void
ctx.tasks.start({
kind: 'bash',
label: 'broken producer',
owner,
run: () => ({
cancel() { throw new Error('cancel boom') },
done: new Promise<TaskOutcome>((res) => { settle = res }),
}),
})
const drain = disposeAgentScope(owner)
let drained = false
void drain.then(() => { drained = true })
await tick()
const drainedWithoutProducerDone = drained
if (!drainedWithoutProducerDone) {
// Release the producer if the assertion fails so the test can finish.
settle({ status: 'completed' })
await drain
} else {
// A late producer completion must not replace the failure or notify twice.
settle({ status: 'completed' })
await tick()
}
expect(drainedWithoutProducerDone).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
expect(seen).toHaveLength(1)
expect(seen[0]?.status).toBe('failed')
expect(seen[0]?.detail).toContain('cancel threw during teardown')
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.start({
kind: 'bash',
label: 'sleep 600',
run: () => ({
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('force-fails a throwing cancel so service disposal does not await producer done', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(TaskService)
ctx.tasks.attachSurface('test-surface')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
let settle!: (outcome: TaskOutcome) => void
ctx.tasks.start({
kind: 'bash',
label: 'broken service task',
run: () => ({
cancel() { throw new Error('service cancel boom') },
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
}),
})
const disposal = fiber.dispose()
let disposed = false
void disposal.then(() => { disposed = true })
await tick()
const disposedWithoutProducerDone = disposed
if (!disposedWithoutProducerDone) {
// Release the producer if the assertion fails so the test can finish.
settle({ status: 'completed' })
await disposal
} else {
settle({ status: 'completed' })
await tick()
}
expect(disposedWithoutProducerDone).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
expect(seen).toEqual([])
})
it('detaches owner effects from still-live agent scopes when the service unloads', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const tasksFiber = await ctx.plugin(TaskService)
ctx.tasks.attachSurface('test-surface')
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
let settle!: (outcome: TaskOutcome) => void
ctx.tasks.start({
kind: 'bash',
label: 'owned work',
owner,
run: () => ({
cancel() { settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
}),
})
const ownerEffects = () => owner.ctx.fiber.getEffects()
.filter(effect => effect.label === 'tasks.ownerCleanup()')
expect(ownerEffects()).toHaveLength(1)
await tasksFiber.dispose()
expect(ownerEffects()).toHaveLength(0)
})
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.start(producer().spec)).not.toThrow() // a ×1 + b remain
detachA2()
expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains
await fiber.dispose() // detaches b with its fiber (HMR safety)
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
})
})

View File

@@ -0,0 +1,30 @@
{
"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"
},
{
"path": "../../core/session"
},
{
"path": "../../util/timeout"
}
]
}

View File

@@ -0,0 +1,56 @@
# @deepseek-ai/dsh-tool-tasks
The model-facing control surface for `ctx.tasks`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the surface required by `ctx.tasks.start()`.
## Tools
- `task_output(task_id, wait?, timeout_ms?)` reads without blocking by default. Stream tasks return only the next delta; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. `wait: true` waits up to the configured cap and leaves a still-running task alive on timeout.
- `task_list()` returns caller-visible tasks as `<id> [<kind>] <status> — <label>`.
- `task_kill(task_id, reason?)` requests cancellation immediately and forwards the logged reason. Terminal tasks return a non-consuming snapshot.
All three use generic ACP cards: `read` for output and list, `execute` for kill.
## Completion notices
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice; owner-disposal races are contained.
## Config
| key | default | meaning |
|---|---|---|
| `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
| `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
A default above the cap fails at load.
## Model Experience
### System prompt
**What the model sees**: Every request in this plugin's registration scope contains this guidance. Agent-scoped tool filtering may hide the tools without removing the independently registered prompt section.
**Token effect**: Small fixed input cost per request while active.
#### Background-task guidance
```markdown
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.
```
### Tool schemas
**What the model sees**: The generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this surface is visible.
**Token effect**: Fixed schema cost on each request where the tools are visible.
### Results and notices
**What the model sees**: Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background tasks)`. Kill returns `requested cancellation of task <id>` or the existing terminal status. Unreported owned completion uses the notice above.
**Token effect**: Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output.
## Known Limitations and Deferred Work
- **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`.
- **Stream reads are single-consumer** — independent observers need another runtime API.
- **Unowned tasks have no session fence** — external surfaces must supply caller policy or avoid them.

View File

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

View File

@@ -0,0 +1,148 @@
/**
* Model-facing `task_output`, `task_list`, and `task_kill` tools over
* `ctx.tasks`. Loading the plugin attaches the control surface required by
* producers. It also injects unreported completions as durable context for the
* owner's next request; notices do not wake idle agents.
* @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']
/** Configures bounded `task_output` waits. */
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 generic status with optional producer detail.
* @param snapshot - task state to render.
* @returns a bracketed status line.
*/
export function statusLine(snapshot: TaskSnapshot): string {
return snapshot.detail !== undefined
? `[status: ${snapshot.status}, ${snapshot.detail}]`
: `[status: ${snapshot.status}]`
}
/** Validate the non-empty constraint that SchemaSpec cannot express. */
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 presentation shared by the three generic task controls. */
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})`)
}
// Producers may start work only while a control surface is attached.
ctx.tasks.attachSurface('tool-tasks')
// Cross-call guidance follows the bash section and precedes 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.',
})
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
ctx.tasks.onTaskDone((snapshot, owner) => {
if (snapshot.reported || owner === undefined) return
try {
owner.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) {
// Disposal may win the race after settlement; other injection failures surface.
if (error instanceof Error && error.message.includes('is disposed')) return
throw error
}
})
ctx.tools.register(defineTool({
name: 'task_output',
description: 'Read a background task. Stream tasks return only output since the previous read; '
+ 'final-output tasks return their result after settlement. Every response ends with '
+ '`[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.',
// A timed-out wait returns task state rather than a TOOL_TIMEOUT error, so
// this tool owns its deadline instead of using ToolDefinition.timeoutMs.
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(_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-finished') {
// A snapshot describes terminal state without consuming pending output.
const snapshot = ctx.tasks.get(id, exec.agent)
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
}
return Promise.resolve([{ type: 'text', text: `requested cancellation of task ${id}` }])
},
presentCall: args => presentTaskCall(`Kill background task ${args.task_id}`, 'execute', args.task_id),
}))
}

View File

@@ -0,0 +1,336 @@
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 { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
const agentRegistryDisposers = new WeakMap<Agent, () => void>()
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 agent id is deliberately different so session authorization and exact
* lifecycle ownership cannot be confused in tests.
*/
function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
const scopeFiber = ctx.plugin(() => {})
const agent = {
id: `agent-${sessionId}`,
ctx: scopeFiber.ctx,
inject,
session: { header: { version: 0, id: sessionId, createdAt: 0 } },
} as unknown as Agent
agentRegistryDisposers.set(agent, ctx.agents.register(agent))
return agent
}
function detachAgent(agent: Agent): void {
const dispose = agentRegistryDisposers.get(agent)
if (dispose === undefined) throw new Error(`missing registry disposer for agent "${agent.id}"`)
dispose()
}
/** A controllable producer start-spec (settle `done` on demand, record cancels). */
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
let settle!: (outcome: TaskOutcome) => void
const cancels: (string | undefined)[] = []
const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
const hooks: TaskHooks = {
cancel(reason) { cancels.push(reason) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
...hookOverrides,
}
const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
return { spec, 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.start(producer().spec)).not.toThrow()
await toolsFiber.dispose()
expect(() => ctx.tasks.start(producer().spec)).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.start(producer().spec)).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.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
// 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.start(p.spec)
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.start(p.spec)
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.start(producer().spec)
// 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.start(producer({ owner: alice, label: 'pnpm test' }).spec)
ctx.tasks.start(producer({ kind: 'subagent', label: 'open research' }).spec)
const p = producer({ owner: alice, label: 'build' })
ctx.tasks.start(p.spec)
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.start(p.spec)
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-finished 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.start(p.spec)
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.start(p.spec)
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.start(p.spec)
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.start(p.spec)
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.start(unowned.spec)
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.start(p.spec)
p.settle({ status: 'completed' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
})
it('does not route an old owner completion notice to a same-session replacement', async () => {
const { ctx } = await setup()
const oldInject = vi.fn(() => { throw new Error('agent "agent-shared" is disposed') })
const oldOwner = fakeAgent(ctx, 'shared', oldInject)
const p = producer({ owner: oldOwner })
ctx.tasks.start(p.spec)
detachAgent(oldOwner)
const replacementInject = vi.fn()
fakeAgent(ctx, 'shared', replacementInject)
p.settle({ status: 'completed' })
await tick()
expect(oldInject).toHaveBeenCalledTimes(1)
expect(replacementInject).not.toHaveBeenCalled()
})
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.start(p.spec)
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('keeps using the exact owner after the agent registry is gone', async () => {
const { ctx, agentsFiber } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
// Settlement must not depend on a later registry lookup: the exact owner
// supplied at start remains the destination while its own scope is live.
const p1 = producer({ owner })
ctx.tasks.start(p1.spec)
const p2 = producer({ owner })
ctx.tasks.start(p2.spec)
await agentsFiber.dispose()
p1.settle({ status: 'completed' })
p2.settle({ status: 'failed' })
await tick()
expect(inject).toHaveBeenCalledTimes(2)
})
})

View File

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

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-acp
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own concrete `Agent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
`inject: ['agents', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
@@ -26,60 +26,47 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|---|---|---|
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message``user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events |
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" |
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
| `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
## Multi-session
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` keyed by the shared agent/session id. Agent-scoped events derive that id from `agent.session.id` and verify the record owns the exact agent object, so a foreign same-id object cannot claim the bridge's session. Every `session/event` 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. Permission prompts follow the same ownership and prompt only the matching session.
Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md).
## Session config options
When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options come from the deployment's preset table and its current value is `PermissionService.current(session.events)`, including the derived, switch-away-only `custom` state when the effective knobs match no preset. `session/set_config_option` accepts only advertised preset names, calls `PermissionService.set()` to write the preset through to the sandbox-mode and approval-policy events, and returns the complete refreshed state. A switch during an open turn appends immediately; an idle switch stays on the session record and anchors at the next turn's `agent/prompt-submit`, inside the turn and before request assembly. Until that anchor, responses overlay the pending value and a crash reverts to the durable fold. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); preset contract: [`dsh-permission`](../permission/README.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
When `ctx.permission` is composed, the bridge advertises one `permission` select in `session/new` and `session/load`. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-config-options).
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.
The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work.
## Per-session cwd
Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must be absolute and equal to it, so the editor and bash executor agree on the workspace before an agent is constructed. A load whose persisted session has no absolute cwd is REJECTED up front via a metadata-only `list()` check, BEFORE resume constructs an agent (else bash would silently fall back to the server's launch dir, and a post-resume reject would leak the registered agent). `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). The server may be launched outside every workspace: an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` is still rejected: widening the tool/filesystem scope beyond the single cwd is a separate sandbox concern.)
`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported.
## Tool-call presentation
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards:
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along).
- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card).
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview.
`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath``Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
## Terminal card (capability-gated)
A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`:
- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card.
- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call.
When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries a ` ```console ` text block the bridge DERIVES by fencing the terminal result's unfenced `output` — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
## Settle-exactly-once
A `session/prompt` resolves or rejects exactly once from the canonical `session/event` stream. The listener captures the prompt's owning turn from `turn/start` and settles in a `finally` block when the matching `turn/end` is appended, so a presentation/streaming failure cannot strand the RPC after the durable terminal event exists. Correlation by turn id prevents a late end from a cancelled prompt from settling its successor. A turn ending in `error` rejects the RPC with an internal error carrying the failure message because ACP has no error stop reason; every other reason resolves through the codec. An empty or whitespace-only prompt is rejected before enqueue because it would start no turn and otherwise leave the RPC pending.
A prompt captures its owning turn and settles exactly once from the matching durable `turn/end`, even if presentation failed. Turn correlation excludes stale endings. Error turns reject with an ACP internal error; empty prompts reject before enqueue.
## Permission prompts
The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning `SessionRecord` through `ownedRecord`, which looks up `agent.session.id` in the forward session map and requires the record to own that exact agent object. It then issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority.
For a bridge-owned call, the [approval seam](../user-approval/README.md) maps `ask` to an editor prompt with one-shot allow/reject options. Foreign or call-less requests delegate; unknown choices never grant, cancellation stays cancellation, and transport failure becomes fail-closed unavailability. Whether a tool asks remains policy outside the bridge.
## Disposal & disconnect
Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted``dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise).
Disposal and client disconnect share one memoized teardown. It cancels pending prompts and disposes all owned agent handles in parallel, waiting for loop exit and final flush before registry removal. Mid-turn teardown records `disposed`; `session/cancel` records `aborted`.
## stdout is the protocol

View File

@@ -133,7 +133,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). |
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
| Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. |
| Background-task ownership isolation | — | ✅ | Generic `task_output`/`task_kill` reject tasks whose branded owner `SessionId` belongs to another session. |
| stdout-is-the-protocol guarantee | S | ✅ | The bridge runs in an example with no stdout logger. |
## Gap summary

View File

@@ -7,6 +7,6 @@ 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) |
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
`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`.
`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).

View File

@@ -17,10 +17,10 @@ export function SessionId(id: string): SessionId {
}
```
Construction goes through the per-id factory in the OWNING package (a plain cast inside — zero runtime cost). Comparison, logging, JSON serialization, and the wire format all behave exactly as for an ordinary string; the brand is erased at compile time.
Construction goes through the per-id factory in the owning package. Comparison, logging, JSON serialization, and the wire format behave as for an ordinary string; the brand is erased at compile time.
## Policy: brand ids that cross package boundaries
A package brands the ids it OWNS`CallId` in `dsh-llm` (tool-call correlation), the shared agent/session `SessionId` in `dsh-session`, and `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`, the shared agent/session `SessionId` in `dsh-session`, and `TaskId` in `dsh-tasks`. Brand cross-package ids that could plausibly be confused; not every string needs one.
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. Keeping it dependency-free lets `dsh-tasks`, for example, brand `TaskId` without importing an unrelated capability package merely to reach `Branded`.

View File

@@ -10,13 +10,13 @@
* comparison, logging, and serialization all behave as ordinary strings.
*
* Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call
* correlation), and `SessionId` in dsh-session; `BashTaskId`/`OwnerToken` live
* in dsh-bash. Branding is for ids that cross package boundaries and could
* plausibly be confused; not every string needs a brand.
* correlation), the shared agent/session `SessionId` in dsh-session, and
* `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 — so the brand vocabulary stays dependency-free and a
* package can brand its ids without depending on an unrelated capability
* package (e.g. dsh-bash brands its ids without pulling in dsh-llm).
* package.
*
* @module @deepseek-ai/dsh-brand
*/

View File

@@ -20,8 +20,6 @@ export {
LocalFetchProvider,
} from './provider.ts'
export type { LocalFetchLimits } from './provider.ts'
export { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
export type { FetchableKind } from './policy.ts'
/** Default `User-Agent`: an explicit product agent, never a browser disguise. */
export const DEFAULT_USER_AGENT = 'deepseek-harness/0.0.1 (+https://github.com/deepseek-ai)'

View File

@@ -3,9 +3,10 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { AddressInfo } from 'node:net'
import { Context } from 'cordis'
import WebService from '@deepseek-ai/dsh-web'
import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID, classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from '@deepseek-ai/dsh-web-fetch-local'
import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-local'
import type { LocalFetchLimits } from '@deepseek-ai/dsh-web-fetch-local'
import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-local'
import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from '../src/policy.ts'
const limits: LocalFetchLimits = {
maxUrlLength: 2048,

View File

@@ -25,8 +25,6 @@ export {
DEEPSEEK_DEFAULT_MAX_USES,
DEEPSEEK_DEFAULT_MODEL,
DEEPSEEK_PROVIDER_ID,
citationSnippets,
mapAnthropicResponse,
} from './provider.ts'
export type { DeepSeekSearchProviderOptions } from './provider.ts'

View File

@@ -4,11 +4,10 @@ import Loader from '@cordisjs/plugin-loader'
import WebService from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
citationSnippets,
mapAnthropicResponse,
DEEPSEEK_PROVIDER_ID,
} from '@deepseek-ai/dsh-web-search-deepseek'
import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek'
import { citationSnippets, mapAnthropicResponse } from '../src/provider.ts'
import type { AnthropicResponse } from '@deepseek-ai/dsh-web-search-deepseek/src/types.ts'
const options = {

View File

@@ -24,8 +24,6 @@ export {
EXA_DEFAULT_SEARCH_TYPE,
EXA_PROVIDER_ID,
ExaSearchProvider,
mapExaResponse,
mapExaResult,
} from './provider.ts'
export type { ExaSearchProviderOptions } from './provider.ts'

View File

@@ -1,8 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import WebService from '@deepseek-ai/dsh-web'
import { ExaSearchProvider, mapExaResponse, mapExaResult, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa'
import { ExaSearchProvider, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa'
import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa'
import { mapExaResponse, mapExaResult } from '../src/provider.ts'
const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test', searchType: 'auto' as const, highlightsPerResult: 1 }

View File

@@ -18,8 +18,6 @@ export {
PERPLEXITY_DEFAULT_MODEL,
PERPLEXITY_PROVIDER_ID,
PerplexitySearchProvider,
mapPerplexityResponse,
mapPerplexityResult,
} from './provider.ts'
export type { PerplexityRecency, PerplexitySearchProviderOptions } from './provider.ts'

View File

@@ -3,10 +3,10 @@ import { Context } from 'cordis'
import WebService from '@deepseek-ai/dsh-web'
import {
PerplexitySearchProvider,
mapPerplexityResponse,
PERPLEXITY_PROVIDER_ID,
} from '@deepseek-ai/dsh-web-search-perplexity'
import * as perplexityPlugin from '@deepseek-ai/dsh-web-search-perplexity'
import { mapPerplexityResponse } from '../src/provider.ts'
const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar', maxTokens: 1024 }