docs(tasks): condense background task prose
The background-task change repeated its lifecycle design across implemented RFCs, package READMEs, JSDoc, test commentary, and model-visible schemas. That repetition obscured the contracts that maintainers must preserve and added avoidable prompt tokens. Rewrite the implemented RFCs around the current design, keep authorization, exact-owner cleanup, wait/abort ordering, producer quiescence, and teardown-failure guarantees at their owning surfaces, and remove peer surveys, review history, control-flow narration, and emphatic restatement. Shorten the task and subagent schema wording, synchronize the bilingual tool cookbook, and regenerate the config, service, RFC, tool, and replay snapshot derivatives. Runtime behavior is unchanged; test edits update prose-only assertions and descriptions.
This commit is contained in:
@@ -1,17 +1,7 @@
|
||||
/**
|
||||
* `LocalBashExecutor`: the local-subprocess implementation of the
|
||||
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
|
||||
* own process group (see `./run.ts` for the plumbing and the agent-tool
|
||||
* survey notes), tracks live background processes for disposal quiescence
|
||||
* ONLY (task semantics live in `ctx.tasks`), and kills everything on dispose.
|
||||
*
|
||||
* TODO(permissions/sandbox): execution policy does NOT belong here — use
|
||||
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md
|
||||
* § Extending The Harness) or implement a sandboxing `BashExecutor`.
|
||||
* Reference points:
|
||||
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
|
||||
* seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -47,10 +37,8 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,11 +49,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
/**
|
||||
* Live background processes, tracked for DISPOSAL only: an entry leaves
|
||||
* the map the moment its process settles (callers keep reading through
|
||||
* their own {@link BashProcess} handle — the buffers live on it).
|
||||
*/
|
||||
/** 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 = {}
|
||||
@@ -75,17 +59,14 @@ 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 [proc, running] of this.live) {
|
||||
proc.status = 'killed'
|
||||
@@ -116,20 +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 } : {},
|
||||
// A local executor carries the override as an explicit inert fact; a
|
||||
// sandboxing subclass resolves it to its configured fallback.
|
||||
// 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,
|
||||
@@ -140,21 +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): BashProcess {
|
||||
// No timeout for background processes (matches Claude Code, which
|
||||
// detaches the timeout when backgrounding); callers stop them via the
|
||||
// handle's kill() — or via spec.signal, which the seam contract honors
|
||||
// for background runs too (runBash wires it to the group kill). No
|
||||
// deadline is created here, so spec.timeoutMs is ignored by design —
|
||||
// background processes stay timeout-free (see the timeout-library RFC).
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
@@ -172,9 +142,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: running.done.then((outcome) => {
|
||||
// Caller-aborted and signal-terminated processes report as killed, not
|
||||
// completed. The signal check also covers commands that terminate
|
||||
// themselves without aborting the upstream signal.
|
||||
// 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'
|
||||
}
|
||||
@@ -183,9 +151,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
this.onProcessDone(proc, running.stderr.readFrom(0).text)
|
||||
this.live.delete(proc)
|
||||
}, (error: unknown) => {
|
||||
// Spawn-level failure (bad workdir, …): the process never ran. The
|
||||
// error is surfaced through the read path, not a rejection. String()
|
||||
// suffices — runBash only rejects with Error instances.
|
||||
// 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)
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
* 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 {
|
||||
@@ -70,10 +70,8 @@ export function classifyRunnerFailure(result: BashRunResult, signatures: readonl
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
* Shared classifier for failed runs. Signatures are case-insensitive and may
|
||||
* include runtime values such as an executable path.
|
||||
*/
|
||||
function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
@@ -104,15 +102,10 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
/**
|
||||
* Per-process facts, keyed by handle from `start()` until the settle stamp
|
||||
* consumes them: the mode the process runs under (per-call — an escalated process
|
||||
* differs from its neighbors) plus its wrap facts. The seam returns facts
|
||||
* PER WRAP — a provider may legally vary enforcement or dialect between
|
||||
* calls — so overlapping background processes must each classify against their
|
||||
* OWN wrap; a single latest-wrap field would let a later `start()` clobber
|
||||
* an earlier process's facts before it settles. A `danger-full-access` process
|
||||
* has NO entry (nothing confined it), which is what the settle stamp keys
|
||||
* off.
|
||||
* 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 processFacts = new Map<BashProcess, {
|
||||
mode: ConfinedSandboxMode
|
||||
@@ -123,10 +116,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())
|
||||
}
|
||||
@@ -168,11 +158,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
// Same stamped-by-resolve invariant as run().
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Sandbox facts are stamped at settle time by onProcessDone()
|
||||
// (denial classification runs against the settled task's collected
|
||||
// stderr). The map entry lands synchronously after spawn, strictly
|
||||
// before the earliest possible settle (including a spawn rejection, whose
|
||||
// promise reaction cannot run until this synchronous start call returns).
|
||||
// Install facts synchronously; promise settlement cannot run before start() returns.
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const proc = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
@@ -188,10 +174,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// Runner failure outranks denial (the command never ran; the runner's
|
||||
// own error text can contain denial words). A settled task has no
|
||||
// error channel left, so the fact IS the surface here — the foreground
|
||||
// path throws instead.
|
||||
// Runner failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
/**
|
||||
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
|
||||
* bash backend does — run foreground commands, start background processes —
|
||||
* without saying HOW. Implementations subclass {@link BashExecutor} and
|
||||
* register themselves as the `bash` service; `@deepseek-ai/dsh-bash-local`
|
||||
* (local subprocesses) is the first. Future implementations swap in
|
||||
* sandboxes, containers, or remote exec servers without touching the tool
|
||||
* schemas that consume them (`@deepseek-ai/dsh-tool-bash`).
|
||||
*
|
||||
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
|
||||
* surveyed agents: pi hides execution behind a `BashOperations` interface
|
||||
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
|
||||
*
|
||||
* The seam is deliberately TASK-FREE: `start()` hands back a
|
||||
* {@link BashProcess} handle (incremental reads, kill, a quiescence promise)
|
||||
* and nothing else. Task ids, owner isolation, polling tools, and completion
|
||||
* notices are the generic `ctx.tasks` runtime's job (`@deepseek-ai/dsh-tasks`)
|
||||
* — the tool layer adapts the handle into a task registration. This keeps a
|
||||
* remote/sandbox executor free of any session or registry dependency.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -49,21 +33,15 @@ declare module 'cordis' {
|
||||
* implementation per context; loading a second throws, which is cordis'
|
||||
* standard duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link run} REJECTS only for infrastructure failures (unusable workdir,
|
||||
* missing shell, pre-aborted signal). Nonzero exits, timeout kills, and
|
||||
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
|
||||
* a failed command is the tool layer's job, not an exception.
|
||||
* 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 (callers stop them via {@link BashProcess.kill} or the spec's
|
||||
* AbortSignal). The handle's `done` settles at process close and never
|
||||
* rejects (a spawn failure settles as `killed` with the error readable on
|
||||
* stderr).
|
||||
* 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
|
||||
* re-deliver output. Implementations bound their buffers; reads that lost
|
||||
* data flag `lossy` and point at full-stream spill files when available.
|
||||
* - Disposal kills every running background process and awaits their exit
|
||||
* (no orphan processes survive `fiber.dispose()`).
|
||||
* 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 {
|
||||
constructor(ctx: Context) {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Background TASK semantics (ids, ownership, polling protocol, completion
|
||||
* listeners) deliberately do NOT live here: the seam starts a background
|
||||
* PROCESS and returns a {@link BashProcess} handle; the caller (the tool
|
||||
* layer) registers that handle with the generic `ctx.tasks` runtime
|
||||
* (`@deepseek-ai/dsh-tasks`), which owns everything task-shaped.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -67,12 +59,9 @@ export interface BashExecRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 processes, `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
|
||||
@@ -80,20 +69,11 @@ 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:
|
||||
* 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
|
||||
/** Resolved sandbox mode; ignored by executors that do not confine. */
|
||||
@@ -144,12 +124,9 @@ export interface BashProcessRead {
|
||||
}
|
||||
|
||||
/**
|
||||
* A live background process handle, returned by {@link BashExecutor.start}.
|
||||
* The HANDLE is the only access path (no executor-level id lookup): the
|
||||
* caller holds it, adapts it into a `ctx.tasks` registration, or drops it.
|
||||
* Reads stay valid after the process exits (the remaining buffered output is
|
||||
* still consumable); the executor's own disposal kills every running process
|
||||
* and awaits {@link done}.
|
||||
* 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). */
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
/**
|
||||
* 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 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.
|
||||
*
|
||||
* 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
|
||||
* sandboxing `BashExecutor` implementations — see docs/architecture.md
|
||||
* § Extending The Harness.
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -44,14 +26,9 @@ import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
|
||||
export const name = 'tool-bash'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/** Config: whether the model may background commands (the producer-opt-in flag). */
|
||||
/** Configures whether the model may background commands. */
|
||||
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.
|
||||
*/
|
||||
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
|
||||
enableRunInBackground?: boolean
|
||||
}
|
||||
|
||||
@@ -59,14 +36,7 @@ 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
|
||||
* 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 and a positive, finite
|
||||
* timeout.
|
||||
*/
|
||||
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
@@ -129,39 +99,14 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S
|
||||
+ 'it — but it does not forbid attempting or escalating other commands later.'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// pure — a UI may call them during live streaming AND a session-log 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
|
||||
* `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
|
||||
* 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',
|
||||
@@ -171,7 +116,6 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView
|
||||
content: [{ type: 'text', text: args.description }],
|
||||
}
|
||||
}
|
||||
// A foreground run is a terminal; an explicit workdir supplies its cwd.
|
||||
return {
|
||||
card: 'terminal',
|
||||
title: args.command,
|
||||
@@ -189,13 +133,10 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
|
||||
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 supplies raw output and parsed exit status.
|
||||
// The bridge derives the no-capability fenced fallback from `output`.
|
||||
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
|
||||
}
|
||||
|
||||
@@ -251,9 +192,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:bash',
|
||||
order: 105,
|
||||
@@ -291,12 +230,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
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.
|
||||
// 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.
|
||||
// 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)
|
||||
@@ -308,27 +242,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// The schema omission is advertising, not enforcement — the arg
|
||||
// validator deliberately allows undeclared keys, so a caller (or a
|
||||
// model that has seen the parameter elsewhere) can still send it.
|
||||
// A disabled deployment must refuse at execution time, loud.
|
||||
// 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)')
|
||||
}
|
||||
// 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().
|
||||
// Reject pre-start cancellation; returned tasks use their own lifecycle.
|
||||
if (exec.signal?.aborted) throw new Error('command aborted')
|
||||
// tasks.start preflights (surface fence, owner cleanup) BEFORE run()
|
||||
// spawns anything, and cannot fail after — the process can never
|
||||
// start without a collectable id.
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'bash',
|
||||
label: args.command,
|
||||
|
||||
@@ -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}]`)
|
||||
|
||||
@@ -419,8 +419,7 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
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.
|
||||
// The producer must forward exec.agent as the task 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)
|
||||
@@ -466,9 +465,7 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
|
||||
// TaskService WITHOUT any control surface: tasks.start preflights that
|
||||
// fence BEFORE invoking the producer's run(), so the executor is never
|
||||
// asked to spawn — there is no orphan to roll back.
|
||||
// With no control surface, task preflight fails before the executor can spawn.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -500,9 +497,7 @@ describe('background execution through the task runtime', () => {
|
||||
const parameters = ctx.tools.get('bash')!.parameters as { properties: Record<string, unknown> }
|
||||
expect('run_in_background' in parameters.properties).toBe(false)
|
||||
|
||||
// Schema omission is advertising, not enforcement: the arg validator
|
||||
// allows undeclared keys, so a forced run_in_background must be REFUSED
|
||||
// at execution time (review finding) — while foreground still works.
|
||||
// Schema omission is advertising; execution must also enforce the opt-out.
|
||||
const forced = await call(ctx, 'bash', { command: 'echo hi', description: 'test command', run_in_background: true })
|
||||
expect(forced.isError).toBe(true)
|
||||
expect(text(forced)).toContain('run_in_background is disabled for this deployment')
|
||||
|
||||
Reference in New Issue
Block a user