mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced read/kill/wait/list, attachSurface misconfiguration fence, reported-flag notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/ task_kill, completion-notice injection, background prompt habit). Producers opt in via their own enableRunInBackground config: bash (stream kind; seam slimmed to resolve/run/start returning a BashProcess handle, bash_output/bash_kill deleted) and subagent (final-output kind; done settles after run.dispose()). Owner disposal drains tasks through the new awaited ctx.agents.onCleanup seam in the loop's disposal chain. Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
# @deepseek-ai/dsh-tool-bash
|
||||
|
||||
The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees.
|
||||
The model-facing `bash` tool, registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees. Background runs are generic tasks: the tool registers the started process with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and the model collects/stops them through the shared `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`) — this package registers no companion tools of its own.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). The `ctx.tasks` runtime is looked up at call time: a background call without it fails loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`).
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
|
||||
## Tools
|
||||
## Config
|
||||
|
||||
### `bash`
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `enableRunInBackground` | `true` | Expose `run_in_background` in the schema. Disabled, the parameter is absent entirely (schema and capability never disagree) and the description says background execution is unavailable. |
|
||||
|
||||
## The `bash` tool
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
@@ -16,35 +20,23 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
|
||||
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. Present only when `enableRunInBackground` allows. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
|
||||
Foreground result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
|
||||
|
||||
### `bash_output`
|
||||
## Background runs as tasks
|
||||
|
||||
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
|
||||
|
||||
### `bash_kill`
|
||||
|
||||
`task_id` → ask the executor to kill the background task. The concrete executor decides how to signal or stop the process; killing an already-finished task is a reported no-op, and unknown ids are errors.
|
||||
|
||||
### Task ownership (cross-session isolation)
|
||||
|
||||
The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task <id> belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
A `run_in_background` call refuses an already-aborted `exec.signal`, starts the process through the seam, registers `{ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput }` with `ctx.tasks`, and returns `started background task <id>`. The tool-call signal is deliberately NOT wired to the process after that — the parent step may end while the command runs; cancellation belongs to `task_kill` and the runtime's owner-disposal cleanup. The producer mapping is exported for tests: `processOutcome` (a killed process → `killed` with the signal as detail; everything else → `completed` with `exit code: N` — a nonzero exit is reported, not failed) and `renderProcessRead` (the incremental delta, plus a `[some output was dropped from memory; full output: …]` notice with spill paths on lossy reads). Ownership, isolation, listing, polling, waiting, kill semantics, and completion notices are all the task runtime's — see [`packages/tasks`](../../tasks/README.md).
|
||||
|
||||
## UI presentation
|
||||
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## Background completion notices
|
||||
|
||||
When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — its output is read via `task_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions
|
||||
|
||||
|
||||
@@ -26,9 +26,13 @@
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
@@ -37,6 +41,8 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
/**
|
||||
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
|
||||
* schema + text shaping — every process concern lives behind the `ctx.bash`
|
||||
* executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
|
||||
* executor implementations swap in without touching what the model sees.
|
||||
* The model-facing `bash` tool. Pure schema + text shaping — every process
|
||||
* concern lives behind the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`),
|
||||
* so sandbox/permission/remote executor implementations swap in without
|
||||
* touching what the model sees.
|
||||
*
|
||||
* Background notifications: when a background task completes, a short notice
|
||||
* is injected into the owning agent's session (`agent.inject()` — the
|
||||
* documented context seam). Injection is durable context for the NEXT model
|
||||
* request, not a wake-up: an idle agent stays idle until something sends a
|
||||
* message, which is why the tool descriptions tell the model to poll with
|
||||
* `bash_output`.
|
||||
* Background runs are TASKS, not bash-private state: `run_in_background`
|
||||
* starts a process through the seam and registers its handle with the generic
|
||||
* `ctx.tasks` runtime (`@deepseek-ai/dsh-tasks`), which owns the id, the
|
||||
* owner fence, the completion notice, and the model-facing collect/stop
|
||||
* tools (`task_output`/`task_list`/`task_kill` from
|
||||
* `@deepseek-ai/dsh-tool-tasks`). Whether the parameter is exposed at all is
|
||||
* THIS plugin's `enableRunInBackground` config (default on) — the registry
|
||||
* never rewrites a producer's schema.
|
||||
*
|
||||
* Task ownership: a background task's OWNER is an opaque token — the owning
|
||||
* agent's `session.header.id` — passed to the executor at spawn
|
||||
* (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
|
||||
* (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
|
||||
* `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
|
||||
* and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
|
||||
* !== caller`); an unowned task (no token — started by a non-agent caller) is
|
||||
* open to anyone. Task ids are global and predictable (`bash-1`, …); under
|
||||
* multi-session ACP (RFC 011) this token check is the fence that stops one
|
||||
* session's agent from reading or killing another session's background task.
|
||||
*
|
||||
* Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
|
||||
* fiber), rather than in this plugin, is what makes ownership survive a
|
||||
* `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
|
||||
* a task spawned before it. (The `onTaskDone` listener is still effect-scoped
|
||||
* to this plugin's `apply`, so a
|
||||
* completion landing during the reload gap still drops its one notice — the
|
||||
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
* The tool-call abort signal is deliberately NOT wired to a background
|
||||
* process: after the task id is returned the parent step may end while the
|
||||
* work continues; cancellation belongs to `task_kill` and the owner-disposal
|
||||
* cleanup. A signal already aborted before the call refuses to start.
|
||||
*
|
||||
* TODO(permissions): commands run with the executor's full authority. The
|
||||
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
|
||||
@@ -39,17 +27,33 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type { BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/** Config: whether the model may background commands (the producer-opt-in flag). */
|
||||
export interface Config {
|
||||
/**
|
||||
* Expose `run_in_background` in the bash schema (default true). Disabled,
|
||||
* the parameter is absent entirely — schema and capability never disagree.
|
||||
* Backgrounding also needs the `ctx.tasks` runtime at call time; a missing
|
||||
* one fails the call loud with the load-these-packages message.
|
||||
*/
|
||||
enableRunInBackground?: boolean
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
@@ -76,18 +80,6 @@ function validateBashArgs(args: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an empty `task_id`. Type and presence are guaranteed by the
|
||||
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
|
||||
* DSL can't express, is left to check here.
|
||||
*/
|
||||
function validateTaskId(value: string): BashTaskId {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
return BashTaskId(value)
|
||||
}
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
if (!output.truncated) return output.text
|
||||
@@ -131,6 +123,39 @@ export function renderResult(result: BashRunResult): string {
|
||||
return body + markers.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape one background-process read into the `task_output` delta the model
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes. Empty-delta
|
||||
* rendering (`(no new output)`) is the control surface's job, not this
|
||||
* producer's. Exported for tests.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @returns the delta text with any loss notice appended.
|
||||
*/
|
||||
export function renderProcessRead(read: BashProcessRead): string {
|
||||
if (!read.lossy) return read.delta
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
|
||||
const notice = `[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`
|
||||
if (read.delta.length === 0) return notice
|
||||
return `${read.delta}${read.delta.endsWith('\n') ? '' : '\n'}${notice}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a settled background process onto the generic task-outcome vocabulary:
|
||||
* `killed` stays `killed` (detail: the signal when one is known), everything
|
||||
* else is `completed` with the exit code as detail — a nonzero exit is
|
||||
* REPORTED, not failed, exactly like the foreground rendering. Exported for
|
||||
* tests.
|
||||
* @param proc - the settled process handle.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
|
||||
// renders a bash call's pending and completed states. They are display-only and
|
||||
@@ -153,7 +178,7 @@ export function renderResult(result: BashRunResult): string {
|
||||
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
|
||||
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
|
||||
* immediately (it never streams a terminal; its output is polled via
|
||||
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
|
||||
* `task_output`), so it is NOT marked terminal and renders as an ordinary
|
||||
* execute card. For a foreground run the `terminal.cwd` (header) is the model
|
||||
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
|
||||
* against the session cwd; when omitted the bridge fills the session workspace
|
||||
@@ -253,11 +278,6 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string
|
||||
return { exitCode: 0 }
|
||||
}
|
||||
|
||||
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
|
||||
function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
|
||||
return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for a bash call. Precedence: an explicit model
|
||||
* `workdir` wins; otherwise default to the calling agent's session cwd
|
||||
@@ -278,18 +298,11 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/** Status line for background task reads. */
|
||||
function statusLine(task: BashTask): string {
|
||||
switch (task.status) {
|
||||
case 'running': return '[status: running]'
|
||||
case 'killed': return `[status: killed${task.signal !== null ? ` by ${task.signal}` : ''}]`
|
||||
case 'completed': return `[status: completed, exit code: ${task.exitCode ?? 0}]`
|
||||
}
|
||||
}
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
// The bash tools' cross-call HABIT, which the per-tool descriptions cannot
|
||||
// carry (they describe one call each): the exit-code marker is only useful
|
||||
// The bash tool's cross-call HABIT, which the per-tool description cannot
|
||||
// carry (it describes one call): the exit-code marker is only useful
|
||||
// if the model actually checks it every time.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
@@ -297,72 +310,16 @@ export function apply(ctx: Context): void {
|
||||
text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's owner TOKEN — the owning agent's `session.header.id`, or
|
||||
* `undefined` for a non-agent caller. Read `session.header.id` (NOT
|
||||
* `session.id`): every other subsystem keys off the header id (the ACP bridge,
|
||||
* both persistence backends), and the sibling `resolveWorkdir` already reads
|
||||
* `session.header.cwd`, so using `session.id` here would be the asymmetry smell
|
||||
* the conventions flag. The two are equal in production, but the header is the
|
||||
* canonical identity.
|
||||
*/
|
||||
const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
|
||||
exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined
|
||||
|
||||
/**
|
||||
* Authorize a `bash_output`/`bash_kill` call against the task's stored owner
|
||||
* token. Rejects when the task HAS an owner and it differs from the caller's
|
||||
* token — using `!== undefined` semantics, NOT truthiness, so an empty-string
|
||||
* token is still a real owner (never treated as unowned). An unowned task
|
||||
* (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also
|
||||
* `undefined` here and then fails loudly at the subsequent
|
||||
* `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
|
||||
* (`callerToken` undefined) cannot match an owned task and is rejected.
|
||||
*/
|
||||
const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
|
||||
const owner = ctx.bash.ownerOf(taskId)
|
||||
if (owner !== undefined && owner !== callerToken(exec)) {
|
||||
throw new Error(`task ${taskId} belongs to another session`)
|
||||
}
|
||||
}
|
||||
|
||||
// Background completion → inject a notice into the owning agent's session.
|
||||
// Find the live agent by its session id token via the agent registry, read
|
||||
// opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
|
||||
// this listener runs from `task.done.then` on the bash fiber — a foreign
|
||||
// fiber — where the `ctx.agents` property proxy would throw through the
|
||||
// traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
|
||||
// registry mounted (`undefined`) → drop the notice. Match on
|
||||
// `agent.session.header.id`, NOT the registry key: a config agent's id differs
|
||||
// from its session id, and the owner token IS the session id.
|
||||
ctx.bash.onTaskDone((task) => {
|
||||
const ownerToken = ctx.bash.ownerOf(task.id)
|
||||
if (ownerToken === undefined) return
|
||||
const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken)
|
||||
if (!agent) return
|
||||
try {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `background bash task ${task.id} finished ${statusLine(task)}. Read its output with bash_output.` }],
|
||||
{ source: { kind: 'plugin', plugin: 'tool-bash' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// The ONE expected failure: the agent was disposed between task
|
||||
// completion and this injection (ReactLoopAgent.inject throws
|
||||
// `agent "<id>" is disposed`). That race is benign — drop the notice.
|
||||
// Anything else is a real bug and must surface, not be swallowed.
|
||||
if (error instanceof Error && error.message.includes('is disposed')) return
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash',
|
||||
description: 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.',
|
||||
+ (backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'read its output with `task_output` and stop it with `task_kill`.'
|
||||
: 'Background execution is not available; long-running commands must finish within the timeout.'),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The bash command to execute.' },
|
||||
description: {
|
||||
@@ -374,7 +331,9 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
|
||||
} : {},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
validateBashArgs(args)
|
||||
@@ -389,65 +348,48 @@ export function apply(ctx: Context): void {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Stamp the owner token (the agent's session id) onto the spec so the
|
||||
// executor stores it on the task — the isolation fence for bash_output/
|
||||
// bash_kill. Foreground runs pass no owner (they finish inline; nothing
|
||||
// to fence).
|
||||
const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
|
||||
return [{ type: 'text', text: `started background task ${task.id}` }]
|
||||
// The generic runtime owns everything task-shaped; without it a task
|
||||
// id would be uncollectable — fail loud with the fix, not a dangle.
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// A step already cancelled must not spawn; after the id is returned
|
||||
// the tool-call signal is deliberately NOT wired to the process
|
||||
// (cancellation belongs to task_kill / owner cleanup), so the check
|
||||
// happens here, once, instead of passing the signal to start().
|
||||
if (exec.signal?.aborted) throw new Error('command aborted')
|
||||
const proc = ctx.bash.start(ctx.bash.resolve(request))
|
||||
let id: string
|
||||
try {
|
||||
id = tasks.register({
|
||||
kind: 'bash',
|
||||
label: args.command,
|
||||
...exec.agent ? { owner: exec.agent } : {},
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderProcessRead(proc.readOutput()),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A failed registration must not leak the just-started process: the
|
||||
// model never received an id, so nothing could ever task_kill it.
|
||||
// Kill, await quiescence, then fail the call with the real cause.
|
||||
proc.kill()
|
||||
await proc.done
|
||||
throw error
|
||||
}
|
||||
return [{ type: 'text', text: `started background task ${id}` }]
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve(request))
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
...request,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
}))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result) }]
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
presentResult: presentBashResult,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash_output',
|
||||
description: 'Read new output from a background bash task started with `bash` + `run_in_background`. '
|
||||
+ 'Returns only output produced since the previous bash_output call, plus the task status. '
|
||||
+ 'Tasks keep running while you do other work; poll again later for more output.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
||||
},
|
||||
// execute is synchronous (registry reads + string shaping) but the
|
||||
// ToolDefinition contract wants a Promise — hence resolve(), not async.
|
||||
execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
assertTaskAccess(id, exec)
|
||||
const read = ctx.bash.readOutput(id)
|
||||
let text = read.delta.length > 0 ? read.delta : '(no new output)'
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
|
||||
const fullOutput = paths.length > 0 ? paths.join(', ') : '(unavailable)'
|
||||
text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
|
||||
}
|
||||
text += `\n${statusLine(read.task)}`
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Read output from', args),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash_kill',
|
||||
description: 'Ask the executor to kill a running background bash task by task id.',
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
|
||||
},
|
||||
execute(args, exec) {
|
||||
const id = validateTaskId(args.task_id)
|
||||
assertTaskAccess(id, exec)
|
||||
const killed = ctx.bash.kill(id)
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: killed ? `killed background task ${id}` : `task ${id} had already finished`,
|
||||
}])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Kill', args),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -7,15 +7,17 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL bash tool
|
||||
* through the agent loop, exercising the same seams a live model would
|
||||
* (tool/call + tool/result session events, agent.inject notifications).
|
||||
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
|
||||
* agent.inject completion notices).
|
||||
*/
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
@@ -25,6 +27,8 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -67,6 +71,16 @@ function resultText(event: SessionEvent): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Poll until `predicate` holds (background settlement races turn end). */
|
||||
async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`condition not met within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('bash tool through the agent loop', () => {
|
||||
it('foreground: model calls bash, sees the result, replies', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
@@ -116,53 +130,41 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start → poll → completion notice lands as context/message', async () => {
|
||||
it('background: start ack → completion notice as context/message → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh TaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
||||
toolCallResponse('call-2', 'bash_output', {}, undefined),
|
||||
textResponse('Started it in the background.'),
|
||||
toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }),
|
||||
textResponse('Background task finished.'),
|
||||
])
|
||||
// The second tool call needs the REAL task id from the first result;
|
||||
// a tools/pre-execute listener rewrites the scripted arguments. (This uses
|
||||
// the low-level capability to mutate `exec` before dispatch — the
|
||||
// unadvertised mechanism behind a future first-class input-rewrite decision;
|
||||
// here it is a test shim to thread the generated id, not a product feature.)
|
||||
let taskId = ''
|
||||
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
|
||||
|
||||
// Intercept the first tool result to capture the generated task id, then
|
||||
// rewrite the second scripted call's arguments to use it.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool/result' && taskId === '') {
|
||||
const match = /task (bash-\d+)/.exec(resultText(event))
|
||||
if (match) taskId = match[1]!
|
||||
}
|
||||
})
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
if (exec.name === 'bash_output') {
|
||||
exec.arguments = { task_id: taskId }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Wait for the background task itself (completion may race turn end).
|
||||
const task = ctx.bash.get(BashTaskId(taskId))
|
||||
if (!task) throw new Error(`task ${taskId} not registered`)
|
||||
await task.done
|
||||
const firstResult = findEvent(events(agent), 'tool/result')
|
||||
expect(firstResult.data.isError).toBe(false)
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
const log = events(agent)
|
||||
const firstResult = findEvent(log, 'tool/result')
|
||||
expect(resultText(firstResult)).toBe(`started background task ${taskId}`)
|
||||
|
||||
const notice = findEvent(log, 'context/message')
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// durable context/message into the owning agent's session (settlement may
|
||||
// race turn end, so poll for it).
|
||||
await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
|
||||
const notice = findEvent(events(agent), 'context/message')
|
||||
expect(notice.data.content.some(
|
||||
block => block.type === 'text' && block.text.includes(`background bash task ${taskId} finished`),
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
|
||||
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
|
||||
|
||||
// The next turn collects the output through the generic task tool.
|
||||
agent.send([{ type: 'text', text: 'collect it' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { renderResult } from '@deepseek-ai/dsh-tool-bash'
|
||||
import { processOutcome, renderProcessRead, renderResult } from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
|
||||
|
||||
/** Foreground-only harness: no task runtime (backgrounding fails loud here). */
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -27,39 +30,36 @@ async function setup() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Full harness: the generic task runtime + its control surface, then the bash tool. */
|
||||
async function setupWithTasks() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} whose session token is `sessionId`, REGISTER it in
|
||||
* `ctx.agents` (the completion-notice path finds the owning agent by scanning
|
||||
* the registry for a matching `session.header.id`), and return it. The returned
|
||||
* agent is also passed to `execute` as `exec.agent` so it owns the spawned task.
|
||||
* The registration disposer is tracked so {@link unregisterFakeAgents} can drop
|
||||
* it (simulating the owning session disconnecting before a task completes).
|
||||
* Build a fake {@link Agent} whose session token is `sessionId` and REGISTER it
|
||||
* in `ctx.agents` (an owned task registration attaches the awaited owner
|
||||
* cleanup via `ctx.agents.onCleanup`, which requires a live registered agent).
|
||||
* The agent id is deliberately DIFFERENT from the session token so a
|
||||
* wrong-field match fails the test.
|
||||
*/
|
||||
const fakeAgentDisposers = new Map<Context, (() => void)[]>()
|
||||
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
|
||||
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
|
||||
// token (session.header.id) — a config agent has `agentId !== sessionId`. The
|
||||
// owner token IS the session id, so the notice path must find the agent by
|
||||
// `session.header.id`, NOT the registry key. Using distinct values here makes
|
||||
// the test fail if a regression matched on the wrong field (a same-value fake
|
||||
// would pass either way — the "hits the line but not the scenario" trap).
|
||||
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
const dispose = ctx.agents.register(agent)
|
||||
const list = fakeAgentDisposers.get(ctx) ?? []
|
||||
list.push(dispose)
|
||||
fakeAgentDisposers.set(ctx, list)
|
||||
function registerFakeAgent(ctx: Context, sessionId: string): Agent {
|
||||
const agent = { id: `agent-${sessionId}`, inject: () => {}, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Unregister every fake agent in this ctx (simulate the owning session disconnecting). */
|
||||
function unregisterFakeAgents(ctx: Context): void {
|
||||
for (const dispose of fakeAgentDisposers.get(ctx) ?? []) dispose()
|
||||
fakeAgentDisposers.delete(ctx)
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
||||
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
@@ -83,56 +83,6 @@ async function callUntilText(
|
||||
throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
|
||||
}
|
||||
|
||||
class LossyReadBashExecutor extends BashExecutor {
|
||||
private readonly task: BashTask = {
|
||||
id: BashTaskId('bash-lossy'),
|
||||
command: 'fake',
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
}
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
|
||||
run(): Promise<BashRunResult> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
|
||||
start(): BashTask {
|
||||
return this.task
|
||||
}
|
||||
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return id === this.task.id ? this.task : undefined
|
||||
}
|
||||
|
||||
ownerOf(): OwnerToken | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [this.task]
|
||||
}
|
||||
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
|
||||
return { task: this.task, delta: 'tail', lossy: true }
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
describe('bash tool', () => {
|
||||
it('returns stdout for a successful command', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -205,7 +155,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/ENOENT/)
|
||||
})
|
||||
|
||||
it('surfaces aborts as isError', async () => {
|
||||
it('surfaces foreground aborts as isError', async () => {
|
||||
const ctx = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
@@ -220,7 +170,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/aborted/)
|
||||
})
|
||||
|
||||
// Type and required-key violations are now rejected by the harness
|
||||
// Type and required-key violations are rejected by the harness
|
||||
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
|
||||
it.each([
|
||||
[{}, /missing required property "command"/],
|
||||
@@ -250,15 +200,18 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
it('registers all three schemas in the system prompt assembly', async () => {
|
||||
it('registers the bash schema with run_in_background exposed by default', async () => {
|
||||
const ctx = await setup()
|
||||
const names = ctx.tools.schemas().map(schema => schema.name)
|
||||
expect(names).toEqual(['bash', 'bash_output', 'bash_kill'])
|
||||
const bashSchema = ctx.tools.schemas()[0]!
|
||||
const schemas = ctx.tools.schemas()
|
||||
expect(schemas.map(schema => schema.name)).toEqual(['bash'])
|
||||
const bashSchema = schemas[0]!
|
||||
expect(bashSchema.parameters).toMatchObject({
|
||||
type: 'object',
|
||||
required: ['command', 'description'],
|
||||
})
|
||||
expect(Object.keys(bashSchema.parameters.properties as Record<string, unknown>))
|
||||
.toContain('run_in_background')
|
||||
expect(bashSchema.description).toContain('task_output')
|
||||
})
|
||||
|
||||
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
|
||||
@@ -275,7 +228,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
@@ -292,348 +245,274 @@ describe('bash tool', () => {
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background tools', () => {
|
||||
it('bash with run_in_background returns a task id immediately', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 0.2; echo bg-done', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toMatch(/^started background task bash-\d+$/)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bash_output polls incrementally and reports status', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'echo first; sleep 1; echo second', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
const first = await callUntilText(ctx, 'bash_output', { task_id: id }, 'first')
|
||||
expect(text(first)).toContain('first')
|
||||
expect(text(first)).toContain('[status: running]')
|
||||
|
||||
await ctx.bash.get(id)!.done
|
||||
const second = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(second)).toContain('second')
|
||||
expect(text(second)).not.toContain('first')
|
||||
expect(text(second)).toContain('[status: completed, exit code: 0]')
|
||||
|
||||
const third = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(third)).toContain('(no new output)')
|
||||
})
|
||||
|
||||
it('bash_output flags lossy reads with spill paths', async () => {
|
||||
it('applies the built-in background default when apply() receives a bare config', async () => {
|
||||
// Bypasses the schemastery defaults on purpose: apply() must stand on its
|
||||
// own `?? true` fallback when embedded programmatically without the schema.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[some output was dropped from memory; full output: ')
|
||||
})
|
||||
|
||||
it('bash_output reports unavailable when a lossy read has no safe spill path', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LossyReadBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const read = await call(ctx, 'bash_output', { task_id: 'bash-lossy' })
|
||||
expect(text(read)).toBe('tail\n[some output was dropped from memory; full output: (unavailable)]\n[status: running]')
|
||||
})
|
||||
|
||||
it('bash_kill stops a running task; repeat reports already-finished', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
const killed = await call(ctx, 'bash_kill', { task_id: id })
|
||||
expect(text(killed)).toBe(`killed background task ${id}`)
|
||||
await ctx.bash.get(id)!.done
|
||||
|
||||
const again = await call(ctx, 'bash_kill', { task_id: id })
|
||||
expect(text(again)).toBe(`task ${id} had already finished`)
|
||||
|
||||
const status = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(status)).toContain('[status: killed by SIGTERM]')
|
||||
})
|
||||
|
||||
it('unknown task ids are isError for both tools', async () => {
|
||||
const ctx = await setup()
|
||||
const read = await call(ctx, 'bash_output', { task_id: 'bash-999' })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(text(read)).toMatch(/unknown bash task/)
|
||||
const kill = await call(ctx, 'bash_kill', { task_id: 'bash-999' })
|
||||
expect(kill.isError).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bash_output', {}, /missing required property "task_id"/],
|
||||
['bash_output', { task_id: 9 }, /"task_id" must be a string/],
|
||||
['bash_kill', { task_id: '' }, /invalid task_id/],
|
||||
])('%s rejects invalid task_id %j', async (tool, args, pattern) => {
|
||||
const ctx = await setup()
|
||||
const result = await call(ctx, tool, args)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(pattern)
|
||||
})
|
||||
|
||||
it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
|
||||
const ctx = await setup()
|
||||
const inject = vi.fn()
|
||||
// The notice path looks the agent up in ctx.agents by its session token, so
|
||||
// the agent must be REGISTERED (not merely passed to execute). Mount a
|
||||
// registry and register a fake whose session.header.id IS the owner token.
|
||||
const agent = registerFakeAgent(ctx, 'bg', inject)
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
const [content, options] = inject.mock.calls[0] as [
|
||||
{ type: string; text: string }[],
|
||||
{ source: { kind: string; plugin: string } },
|
||||
]
|
||||
expect(content[0]!.text).toContain(`background bash task ${id} finished`)
|
||||
expect(content[0]!.text).toContain('bash_output')
|
||||
expect(options.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
|
||||
})
|
||||
|
||||
it('swallows ONLY the disposed-agent inject error', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('agent "x" is disposed') })
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg2'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
|
||||
const ctx = await setup()
|
||||
// A real bug in inject (not the benign disposed race) must surface — the
|
||||
// base-class notifier contains it (logs, does not reject task.done), but
|
||||
// the listener itself must have thrown rather than silently eaten it.
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('unexpected inject bug') })
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg3'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
// notifyTaskDone caught and logged the rethrown error.
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
|
||||
expect(logged).toBe(true)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => {
|
||||
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its
|
||||
// per-session agent — e.g. the ACP session disconnects and its AgentHandle
|
||||
// disposes while the background task is still running. The owner token is
|
||||
// still on the task, but no live agent carries it anymore, so the registry
|
||||
// lookup finds nothing and the notice is dropped (no throw).
|
||||
const ctx = await setup()
|
||||
const inject = vi.fn()
|
||||
const agent = registerFakeAgent(ctx, 'bg', inject)
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('call-bg4'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true', description: 'test command', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Unregister the agent BEFORE the task completes (simulate disconnect).
|
||||
unregisterFakeAgents(ctx)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not notify when no agent owned the task', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
ToolBash.apply(ctx, {})
|
||||
const schema = ctx.tools.schemas()[0]!
|
||||
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
||||
.toContain('run_in_background')
|
||||
})
|
||||
})
|
||||
|
||||
describe('background task ownership (cross-session isolation)', () => {
|
||||
/** Run a tool on behalf of a specific agent (sets exec.agent). */
|
||||
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
// Ownership is by TOKEN (session.header.id), NOT agent object identity — so
|
||||
// each agent needs a DISTINCT session id, else every fake yields the same
|
||||
// token and the isolation tests pass for the wrong reason (all tasks owned by
|
||||
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
|
||||
// it.
|
||||
const fakeAgent = (sessionId: string) =>
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
describe('background execution through the task runtime', () => {
|
||||
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
|
||||
const ctx = await setupWithTasks()
|
||||
const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
|
||||
expect(started.isError).toBe(false)
|
||||
expect(text(started)).toBe('started background task bash-1')
|
||||
|
||||
it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => {
|
||||
const ctx = await setup()
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
// Agent A starts a long-running background task.
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
|
||||
// Agent B (a different session token) cannot read or kill A's task.
|
||||
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
||||
expect(readByB.isError).toBe(true)
|
||||
expect(text(readByB)).toMatch(/belongs to another session/)
|
||||
const killByB = await callAs(ctx, b, 'bash_kill', { task_id: id })
|
||||
expect(killByB.isError).toBe(true)
|
||||
expect(text(killByB)).toMatch(/belongs to another session/)
|
||||
|
||||
// The task is still running (B's kill did nothing) — A can still kill it.
|
||||
const killByA = await callAs(ctx, a, 'bash_kill', { task_id: id })
|
||||
expect(killByA.isError).toBe(false)
|
||||
expect(text(killByA)).toBe(`killed background task ${id}`)
|
||||
const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
|
||||
expect(text(read)).toContain('bg-ok')
|
||||
// A later read reports the terminal outcome in the generic status line.
|
||||
const final = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, '[status: completed, exit code: 0]')
|
||||
expect(final.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('a DIFFERENT Agent object with the SAME session token may access the task (ownership is by token, not object identity)', async () => {
|
||||
// Ownership fences by session.header.id, NOT Agent object identity. Two
|
||||
// distinct Agent objects sharing one session token (e.g. an agent re-created
|
||||
// on the same session) are the SAME owner.
|
||||
const ctx = await setup()
|
||||
const a1 = fakeAgent('sess-shared')
|
||||
const a2 = fakeAgent('sess-shared') // distinct object, same token
|
||||
const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id })
|
||||
expect(readByA2.isError).toBe(false)
|
||||
await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup
|
||||
it('a running background task is killable through the REAL task_kill tool', async () => {
|
||||
const ctx = await setupWithTasks()
|
||||
await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
|
||||
expect(text(killed)).toBe('requested cancellation of task bash-1')
|
||||
// The cancel reached the process handle; the task settles as killed with
|
||||
// the signal detail mapped by processOutcome.
|
||||
const final = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true })
|
||||
expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
|
||||
})
|
||||
|
||||
it('the no-agent (non-loop) caller cannot access an owned task', async () => {
|
||||
const ctx = await setup()
|
||||
const a = fakeAgent('sess-a')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// A call with no exec.agent has no token → cannot prove ownership of an owned task.
|
||||
const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(text(read)).toMatch(/belongs to another session/)
|
||||
await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
|
||||
})
|
||||
it('a background task started by an agent is registered with that agent as owner', async () => {
|
||||
// The fence SEMANTICS are pinned in dsh-tasks; this only pins that
|
||||
// tool-bash forwards exec.agent as the registration's owner.
|
||||
const ctx = await setupWithTasks()
|
||||
const agent = registerFakeAgent(ctx, 'sess-owner')
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }, agent)
|
||||
expect(text(started)).toBe('started background task bash-1')
|
||||
|
||||
it('an UNOWNED task (started with no agent) is accessible to anyone', async () => {
|
||||
const ctx = await setup()
|
||||
// Started by a non-loop caller (no exec.agent) → no owner token recorded.
|
||||
const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Any agent (and the no-agent caller) may read/kill it.
|
||||
const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id })
|
||||
expect(read.isError).toBe(false)
|
||||
const killed = await callAs(ctx, undefined, 'bash_kill', { task_id: id })
|
||||
const anon = await call(ctx, 'task_output', { task_id: 'bash-1' })
|
||||
expect(anon.isError).toBe(true)
|
||||
expect(text(anon)).toMatch(/belongs to another session/)
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' }, agent)
|
||||
expect(killed.isError).toBe(false)
|
||||
await call(ctx, 'task_output', { task_id: 'bash-1', wait: true }, agent) // await settlement — no orphan
|
||||
})
|
||||
|
||||
it('the owner can still access its task AFTER it completes (owner token persists on the task)', async () => {
|
||||
const ctx = await setup()
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
await ctx.bash.get(id)!.done
|
||||
// Completion does NOT clear ownership: B is still rejected, A still allowed.
|
||||
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
||||
expect(readByB.isError).toBe(true)
|
||||
expect(text(readByB)).toMatch(/belongs to another session/)
|
||||
const readByA = await callAs(ctx, a, 'bash_output', { task_id: id })
|
||||
expect(readByA.isError).toBe(false)
|
||||
it('fails loud when the task runtime is not loaded', async () => {
|
||||
const ctx = await setup() // no TaskService / ToolTasks
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
})
|
||||
|
||||
it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => {
|
||||
// The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT
|
||||
// in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor +
|
||||
// task survive) preserves ownership. This is the regression guard: a
|
||||
// plugin-local map would make B accessible after reload, and this test would
|
||||
// catch it.
|
||||
it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
|
||||
class CountingStartExecutor extends BashExecutor {
|
||||
starts = 0
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
|
||||
}
|
||||
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
this.starts += 1
|
||||
return {
|
||||
command: spec.command,
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
|
||||
const a = fakeAgent('sess-a')
|
||||
const b = fakeAgent('sess-b')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
// Before reload: B is rejected (A owns it).
|
||||
expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
|
||||
|
||||
// Reload ONLY tool-bash; the executor and its running task (with its owner
|
||||
// token) survive.
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.bash.get(id)?.status).toBe('running')
|
||||
expect(ctx.bash.ownerOf(id)).toBe('sess-a')
|
||||
|
||||
// After reload, ownership is INTACT → B is STILL rejected.
|
||||
expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
|
||||
await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('call-pre-aborted'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'sleep 60', description: 'test command', run_in_background: true },
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('command aborted')
|
||||
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
|
||||
})
|
||||
|
||||
it('a failed registration kills the just-started process (no orphan without an id)', async () => {
|
||||
class LeakProbeExecutor extends BashExecutor {
|
||||
kills = 0
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0 }
|
||||
}
|
||||
|
||||
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
let close!: () => void
|
||||
const done = new Promise<void>((res) => { close = res })
|
||||
const proc: BashProcess = {
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done,
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => {
|
||||
this.kills += 1
|
||||
proc.status = 'killed'
|
||||
close()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
}
|
||||
// TaskService WITHOUT any control surface: register() throws AFTER the
|
||||
// process already started — the producer must kill and await it.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LeakProbeExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// The call resolved only after the kill landed (the catch awaits done).
|
||||
expect((ctx.bash as LeakProbeExecutor).kills).toBe(1)
|
||||
})
|
||||
|
||||
it('enableRunInBackground: false removes the parameter and flips the description', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'bash')!
|
||||
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
||||
.toEqual(['command', 'description', 'timeoutMs', 'workdir'])
|
||||
expect(schema.description).toContain('Background execution is not available')
|
||||
expect(schema.description).not.toContain('run_in_background')
|
||||
// The registry-held definition agrees (schema and capability never disagree).
|
||||
const parameters = ctx.tools.get('bash')!.parameters as { properties: Record<string, unknown> }
|
||||
expect('run_in_background' in parameters.properties).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderProcessRead', () => {
|
||||
const base: BashProcessRead = { delta: 'out\n', lossy: false }
|
||||
|
||||
it('returns the delta verbatim for a lossless read', () => {
|
||||
expect(renderProcessRead(base)).toBe('out\n')
|
||||
expect(renderProcessRead({ delta: '', lossy: false })).toBe('')
|
||||
})
|
||||
|
||||
it('appends the loss notice with the available spill paths', () => {
|
||||
expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log' }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: /spill/out.log]')
|
||||
expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log', stderrSpillPath: '/spill/err.log' }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: /spill/out.log, /spill/err.log]')
|
||||
})
|
||||
|
||||
it('reports (unavailable) when a lossy read has no safe spill path', () => {
|
||||
expect(renderProcessRead({ ...base, lossy: true }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('an empty lossy delta is the notice alone', () => {
|
||||
expect(renderProcessRead({ delta: '', lossy: true, stderrSpillPath: '/spill/err.log' }))
|
||||
.toBe('[some output was dropped from memory; full output: /spill/err.log]')
|
||||
})
|
||||
|
||||
it('inserts the separating newline only when the delta lacks one', () => {
|
||||
expect(renderProcessRead({ delta: 'tail', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
expect(renderProcessRead({ delta: 'tail\n', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOutcome', () => {
|
||||
function settled(over: Partial<BashProcess>): BashProcess {
|
||||
return {
|
||||
command: 'x',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
it('maps a signal-killed process to killed with the signal detail', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
|
||||
.toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
})
|
||||
|
||||
it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', exitCode: null })))
|
||||
.toEqual({ status: 'killed', detail: 'killed before exit' })
|
||||
})
|
||||
|
||||
it('maps a completed process to its exit code', () => {
|
||||
expect(processOutcome(settled({ exitCode: 3 })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 3' })
|
||||
})
|
||||
|
||||
it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
|
||||
expect(processOutcome(settled({ exitCode: null })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 0' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-cwd routing (per-session workdir)', () => {
|
||||
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`cwd-${++callCounter}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
// An agent whose session header carries a cwd (what session/new records).
|
||||
const agentInCwd = (cwd: string) =>
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
|
||||
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as Agent
|
||||
|
||||
it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
|
||||
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
|
||||
expect(text(result).trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
|
||||
it('an explicit absolute workdir overrides the session cwd', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callAs(ctx, agentInCwd('/'), { command: 'pwd', description: 'pwd', workdir: '/tmp' })
|
||||
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: '/tmp' }, agentInCwd('/'))
|
||||
expect(text(result).trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
|
||||
it('a relative workdir is resolved against the session cwd', async () => {
|
||||
const ctx = await setup()
|
||||
// session cwd /usr + relative 'bin' → /usr/bin
|
||||
const result = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd', workdir: 'bin' })
|
||||
const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: 'bin' }, agentInCwd('/usr'))
|
||||
expect(text(result).trim()).toMatch(/\/usr\/bin$/)
|
||||
})
|
||||
|
||||
it('two sessions with different cwds each run bash in their own dir', async () => {
|
||||
const ctx = await setup()
|
||||
const inUsr = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd' })
|
||||
const inTmp = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
|
||||
const inUsr = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/usr'))
|
||||
const inTmp = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
|
||||
expect(text(inUsr).trim()).toMatch(/\/usr$/)
|
||||
expect(text(inTmp).trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
@@ -697,35 +576,6 @@ describe('renderResult', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('status lines', () => {
|
||||
it('reports kills without a recorded signal (executor raced process exit)', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const task = ctx.bash.get(id)!
|
||||
|
||||
await call(ctx, 'bash_kill', { task_id: id })
|
||||
await task.done
|
||||
// Simulate the variant where the close event carried no signal.
|
||||
task.signal = null
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[status: killed]')
|
||||
})
|
||||
|
||||
it('reports completed tasks with a null exit code as exit 0', async () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
|
||||
const task = ctx.bash.get(id)!
|
||||
await task.done
|
||||
// Defensive: completed tasks always carry an exit code in practice; the
|
||||
// ?? 0 fallback covers task shapes from other executor implementations.
|
||||
task.exitCode = null
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -851,14 +701,6 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ card: 'generic', title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ card: 'generic', title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
})
|
||||
|
||||
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
|
||||
const ctx = await setup()
|
||||
// defineTool wraps presentCall to soft-validate against the schema and fall
|
||||
@@ -879,8 +721,8 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
* future refactor that blindly forwards `...args` — which would silently thread
|
||||
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
|
||||
* (the credential scrub in dsh-bash-local is the security control; see the
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
|
||||
* unused here.
|
||||
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
|
||||
* hands back an already-settled fake handle so the task registration completes.
|
||||
*/
|
||||
class RecordingBashExecutor extends BashExecutor {
|
||||
readonly requests: BashExecRequest[] = []
|
||||
@@ -893,7 +735,6 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
run(): Promise<BashRunResult> {
|
||||
@@ -902,12 +743,17 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
start(): BashTask { throw new Error('unused') }
|
||||
get(): BashTask | undefined { return undefined }
|
||||
ownerOf(): OwnerToken | undefined { return undefined }
|
||||
list(): BashTask[] { return [] }
|
||||
readOutput(): BashTaskRead { throw new Error('unused') }
|
||||
kill(): boolean { return false }
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
return {
|
||||
command: spec.command,
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupRecording() {
|
||||
@@ -915,6 +761,8 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
@@ -947,9 +795,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
|
||||
it('a background bash call likewise carries no env/stdin', async () => {
|
||||
const { ctx, bash } = await setupRecording()
|
||||
// start() throws in this recorder, but resolve() runs first and records the
|
||||
// request — which is all this no-forward assertion needs.
|
||||
await ctx.tools.execute({
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('no-forward-2'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
@@ -960,13 +806,14 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
stdin: 'x',
|
||||
},
|
||||
})
|
||||
// The call really went down the background path (the recorder sees the real
|
||||
// request the consumer built, so the absent env/stdin below is a real
|
||||
// negative, not a recorder that drops everything).
|
||||
expect(text(result)).toBe('started background task bash-1')
|
||||
expect(bash.requests).toHaveLength(1)
|
||||
const request = bash.requests[0]!
|
||||
expect(request.command).toBe('sleep 1')
|
||||
expect('env' in request).toBe(false)
|
||||
expect('stdin' in request).toBe(false)
|
||||
// The owner token IS set on a background call (the isolation fence) — proving
|
||||
// the recorder sees the real request the consumer built, so the absent
|
||||
// env/stdin above is a real negative, not a recorder that drops everything.
|
||||
expect('owner' in request).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
@@ -25,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user