diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 06795e687d..8587080dfe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -154,7 +154,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:30`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:27`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -181,7 +181,7 @@ export interface Config extends LocalConfig { Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-sandbox/src/index.ts) +Source: [`packages/bash/bash-sandbox/src/index.ts:26`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -847,7 +847,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-bash/src/index.ts:47`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:48`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 40a199fbce..3f2602e613 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -177,8 +177,6 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o ```ts type-equiv interface BashProcess { - /** The command line this process runs. */ - readonly command: string /** Process lifecycle state (settled exactly once). */ status: BashProcessStatus /** Exit code once finished (null = killed by signal / still running). */ diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index ff057d26a9..6048f886a3 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -2,6 +2,8 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. + ## Config ```yaml diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index cb494bab5c..f80ea45e98 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -23,9 +23,6 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { DEFAULT_GRACE_MS, runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' -export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts' -export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts' - /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { /** Default working directory for commands (default: process.cwd()). */ @@ -171,7 +168,6 @@ export class LocalBashExecutor extends BashExecutor { let stdoutOffset = 0 let stderrOffset = 0 const proc: BashProcess = { - command: spec.command, status: 'running', exitCode: null, signal: null, @@ -184,7 +180,7 @@ export class LocalBashExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, running) + this.onProcessDone(proc, running.stderr.readFrom(0).text) this.live.delete(proc) }, (error: unknown) => { // Spawn-level failure (bad workdir, …): the process never ran. The @@ -192,7 +188,7 @@ export class LocalBashExecutor extends BashExecutor { // suffices — runBash only rejects with Error instances. proc.status = 'killed' running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`)) - this.onProcessDone(proc, running) + this.onProcessDone(proc, running.stderr.readFrom(0).text) this.live.delete(proc) }), readOutput: (): BashProcessRead => { @@ -230,9 +226,9 @@ export class LocalBashExecutor extends BashExecutor { * {@link BashProcess.done} resolves. The base implementation is intentionally * empty. * @param _proc - the settled process handle. - * @param _running - the process collectors, including full in-memory stderr. + * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. */ - protected onProcessDone(_proc: BashProcess, _running: RunningBash): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string): void {} } export default LocalBashExecutor diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 8ed8b7699c..02e7a963be 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -184,27 +184,6 @@ export class OutputCollector { writeSync(this.spillFd, chunk) } - // TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at - // the bottom of this file) and `totalBytes` is read only by a test. The live - // background-poll path goes through `readFrom()`, so inline snapshot() into - // finalize() and drop or privatize the totalBytes getter. - /** - * Read the collected tail without finalizing (the final-result snapshot). - * @returns the retained tail text, the truncation flag, and the spill path when one was created. - */ - snapshot(): CollectedOutput { - return { - text: Buffer.concat(this.chunks).toString('utf8'), - truncated: this.dropped, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } - - /** Total bytes ever pushed (including bytes dropped from memory). */ - get totalBytes(): number { - return this.total - } - /** * Incremental read in whole-stream byte coordinates: returns everything * pushed since `fromByte`. When `fromByte` has already slid out of the @@ -243,7 +222,11 @@ export class OutputCollector { } this.spillFd = undefined } - return this.snapshot() + return { + text: Buffer.concat(this.chunks).toString('utf8'), + truncated: this.dropped, + ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, + } } } diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index e2d799f5ae..b176cbaf86 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -134,7 +134,6 @@ describe('LocalBashExecutor.start (background process handles)', () => { const before = Date.now() const proc = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' })) expect(Date.now() - before).toBeLessThan(150) - expect(proc.command).toBe('sleep 0.2; echo done') expect(proc.status).toBe('running') await proc.done expect(proc.status).toBe('completed') diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index fde84189cb..923b3adf7b 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local' -import type { RunningBash } from '@deepseek-ai/dsh-bash-local' +import { killGroup, OutputCollector, runBash } from '../src/run.ts' +import type { RunningBash } from '../src/run.ts' const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } })) vi.mock('node:fs', async (importOriginal) => { @@ -49,7 +49,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - if (running.stdout.snapshot().text.includes(expected)) return + if (running.stdout.readFrom(0).text.includes(expected)) return await new Promise(resolve => setTimeout(resolve, 20)) } throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) @@ -295,19 +295,11 @@ describe('OutputCollector', () => { expect(third.spillPath).toBeDefined() }) - it('tracks totalBytes across drops', () => { - const collector = new OutputCollector(4, 'test', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - expect(collector.totalBytes).toBe(8) - expect(collector.finalize().text).toBe('bbbb') - }) - it('contains close failures and drops the spill path', () => { const collector = new OutputCollector(4, 'closefail', spillDir) collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('bbbb')) - expect(collector.snapshot().spillPath).toBeDefined() + expect(collector.readFrom(0).spillPath).toBeDefined() failNextClose.value = true let out: ReturnType diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index ae5acfe7b5..ab96db61c4 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -15,7 +15,6 @@ import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' -import type { RunningBash } from '@deepseek-ai/dsh-bash-local' /** * Plugin config: the local executor's knobs plus the sandbox policy. All @@ -185,11 +184,10 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Stamp per-process sandbox facts before `done` settles. Full-access processes * have no facts; signal deaths are not denials. */ - protected override onProcessDone(proc: BashProcess, running: RunningBash): void { + protected override onProcessDone(proc: BashProcess, stderr: string): void { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - const stderr = running.stderr.readFrom(0).text // 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 @@ -202,7 +200,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { ...(runnerFailed ? { runnerFailed } : {}), } } - super.onProcessDone(proc, running) + super.onProcessDone(proc, stderr) } /** diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 3dcd184d26..8cf579b767 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -152,8 +152,6 @@ export interface BashProcessRead { * and awaits {@link done}. */ export interface BashProcess { - /** The command line this process runs. */ - readonly command: string /** Process lifecycle state (settled exactly once). */ status: BashProcessStatus /** Exit code once finished (null = killed by signal / still running). */ diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 24fc9ee782..a869f4834c 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -32,9 +32,8 @@ class StubExecutor extends BashExecutor { } } - start(spec: BashExecSpec): BashProcess { + start(): BashProcess { const proc: BashProcess = { - command: spec.command, status: 'running', exitCode: null, signal: null, @@ -62,7 +61,6 @@ describe('BashExecutor service seam', () => { expect(result.stdout.text).toBe('ok') const proc = ctx.bash.start(spec) - expect(proc.command).toBe('echo hi') expect(proc.status).toBe('running') expect(proc.readOutput()).toEqual({ delta: '', lossy: false }) expect(proc.kill()).toBe(true) diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 87eab1f89b..f2a68e4bd9 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -4,6 +4,8 @@ The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreg Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain implementation details covered by same-package tests. + The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on. ## Tools diff --git a/packages/bash/tool-bash/src/background.ts b/packages/bash/tool-bash/src/background.ts new file mode 100644 index 0000000000..6ba67cdfde --- /dev/null +++ b/packages/bash/tool-bash/src/background.ts @@ -0,0 +1,27 @@ +/** + * Generic-task adaptation for background bash process handles. + * + * @module @deepseek-ai/dsh-tool-bash/background + */ + +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +/** + * Map a settled background process onto the generic task-outcome vocabulary: + * `killed` stays `killed` (detail: the signal when one is known), everything + * else is `completed` with the exit code as detail. A nonzero command exit is + * reported, not failed, exactly like the foreground rendering. + * @param proc - the settled process handle. + * @returns the outcome for the `ctx.tasks` registration. + */ +export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } { + // TODO(background-infrastructure-outcome): widen BashProcess with an explicit + // infrastructure-failure outcome, then map spawn failures and + // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn + // failure with a signal-less kill and a runner failure with an ordinary + // wrapper exit; real nonzero command exits must remain `completed`. + if (proc.status === 'killed') { + return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + } + return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } +} diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 0871413723..1784a2dd89 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -38,7 +38,8 @@ import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash' -import type { BashProcess, BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash' +import { processOutcome } from './background.ts' +import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] @@ -128,110 +129,6 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'it — but it does not forbid attempting or escalating other commands later.' } -/** 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 - return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` -} - -/** - * Shape one finished run into model-visible stdout, marked stderr, and status - * facts. Non-zero exits and sandbox denials remain ordinary results; only - * infrastructure failure or abort makes the tool call itself fail. - * - * @param result - the completed foreground run from the executor. - * @param escalationModes - escalation targets advertised by this composition. - * @returns the model-facing text: output body (or `(no output)`), then any sandbox/timeout/signal/exit markers, each on its own line. - */ -export function renderResult(result: BashRunResult, escalationModes: readonly SandboxMode[] = []): string { - const out = streamText(result.stdout) - const err = streamText(result.stderr) - - let body = out - if (err.length > 0) { - // Single newline between sections (stdout usually ends with one already). - if (body.length > 0 && !body.endsWith('\n')) body += '\n' - body += `[stderr]\n${err}` - } - if (body.length === 0) body = '(no output)' - - const markers: string[] = [] - if (result.sandbox?.denied) { - markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`) - 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. - if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) - if (result.signal !== null) { - markers.push(`[killed by signal: ${result.signal}]`) - } else if (result.exitCode !== 0) { - markers.push(`[exit code: ${result.exitCode}]`) - } - if (markers.length === 0) return body - - if (!body.endsWith('\n')) body += '\n' - 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. - * @param sandbox - settled sandbox facts, when this was a confined process. - * @param escalationModes - escalation targets advertised by this composition. - * @returns the delta text with any loss or sandbox notice appended. - */ -export function renderProcessRead( - read: BashProcessRead, - sandbox?: BashSandboxInfo, - escalationModes: readonly SandboxMode[] = [], -): string { - const notices: string[] = [] - if (read.lossy) { - const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined) - notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) - } - if (sandbox?.runnerFailed) { - notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) - } else if (sandbox?.denied) { - notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`) - if (escalationModes.length > 0) { - notices.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') - } - } - if (notices.length === 0) return read.delta - return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` -} - -/** - * 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 } { - // TODO(background-infrastructure-outcome): widen BashProcess with an explicit - // infrastructure-failure outcome, then map spawn failures and - // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn - // failure with a signal-less kill and a runner failure with an ordinary - // wrapper exit; real nonzero command exits must remain `completed`. - if (proc.status === 'killed') { - return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } - } - return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } -} - // --------------------------------------------------------------------------- // 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 @@ -302,18 +199,6 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | return { card: 'terminal', output: raw, ...parseExitStatus(raw) } } -/** - * Recover exit status from the final marked line emitted by {@link renderResult}. - * A program whose own final line exactly mimics a marker remains ambiguous for UI display. - */ -function parseExitStatus(text: string): { exitCode: number } | { signal: string } { - const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) - if (signal?.[1] !== undefined) return { signal: signal[1] } - const exit = /\n\[exit code: (\d+)\]$/.exec(text) - if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) } - return { exitCode: 0 } -} - /** * Resolve an explicit workdir first, making a relative one session-cwd-relative; * otherwise use the session cwd and leave executor defaulting as the fallback. diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts new file mode 100644 index 0000000000..12b407f364 --- /dev/null +++ b/packages/bash/tool-bash/src/render.ts @@ -0,0 +1,124 @@ +/** + * Model-facing result rendering for the bash tool. + * + * @module @deepseek-ai/dsh-tool-bash/render + */ + +import type { BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' + +/** Append the truncation notice (with the full-output spill path) to a stream's text. */ +function streamText(output: CollectedOutput): string { + if (!output.truncated) return output.text + return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` +} + +/** + * 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 + * 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. + * @param escalationModes - the escalation targets this composition advertises; + * non-empty adds the same-turn escalation hint after a denial marker + * (default `[]`: no hint). + * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. + */ +export function renderResult( + result: BashRunResult, + escalationModes: readonly SandboxMode[] = [], +): string { + const out = streamText(result.stdout) + const err = streamText(result.stderr) + + let body = out + if (err.length > 0) { + // Single newline between sections (stdout usually ends with one already). + if (body.length > 0 && !body.endsWith('\n')) body += '\n' + body += `[stderr]\n${err}` + } + 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. + 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. + 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. + if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) + if (result.signal !== null) { + markers.push(`[killed by signal: ${result.signal}]`) + } else if (result.exitCode !== 0) { + markers.push(`[exit code: ${result.exitCode}]`) + } + if (markers.length === 0) return body + + if (!body.endsWith('\n')) body += '\n' + return body + markers.join('\n') +} + +/** + * Shape one background-process read into the `task_output` delta the model + * sees: the incremental delta, plus the lossy-read notice (with full-stream + * spill paths) when in-memory truncation dropped unread bytes. Empty-delta + * rendering (`(no new output)`) is the generic control surface's job. + * @param read - one incremental read from the process handle. + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the delta text with any loss or sandbox notice appended. + */ +export function renderProcessRead( + read: BashProcessRead, + sandbox?: BashSandboxInfo, + escalationModes: readonly SandboxMode[] = [], +): string { + const notices: string[] = [] + if (read.lossy) { + const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) + notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) + } + if (sandbox?.runnerFailed) { + notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) + } else if (sandbox?.denied) { + notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`) + if (escalationModes.length > 0) { + notices.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') + } + } + if (notices.length === 0) return read.delta + return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` +} + +/** + * Recover the structured exit status from a rendered {@link renderResult} + * string — the inverse of the status markers it appends. A killed marker + * yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both + * means a clean exit 0. + * + * Replay only retains the rendered content text, not the original + * `BashRunResult`, so terminal presentation must recover the exit pill here. + * Requiring a leading newline and the end of the string keeps ordinary output + * that merely ends with marker-like text from matching unless the final line + * is indistinguishable from a real marker. + * @param text - rendered model-facing bash result. + * @returns the recovered terminal exit code or signal. + */ +export function parseExitStatus(text: string): { exitCode: number } | { signal: string } { + const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) + if (signal?.[1] !== undefined) return { signal: signal[1] } + const exit = /\n\[exit code: (\d+)\]$/.exec(text) + if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) } + return { exitCode: 0 } +} diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 2d5918e9d7..a289da4f33 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -16,7 +16,8 @@ import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { processOutcome, renderProcessRead, renderResult } from '@deepseek-ai/dsh-tool-bash' +import { processOutcome } from '../src/background.ts' +import { renderProcessRead, renderResult } from '../src/render.ts' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) @@ -123,7 +124,6 @@ class RecordingSandboxExecutor extends BashExecutor { start(spec: BashExecSpec): BashProcess { this.modes.push(spec.sandboxMode) return { - command: spec.command, status: 'completed', exitCode: 0, signal: null, @@ -145,10 +145,9 @@ class CountingStartExecutor extends BashExecutor { run(): Promise { return Promise.reject(new Error('unused')) } - start(spec: BashExecSpec): BashProcess { + start(): BashProcess { this.starts += 1 return { - command: spec.command, status: 'completed', exitCode: 0, signal: null, @@ -652,7 +651,6 @@ describe('renderProcessRead', () => { describe('processOutcome', () => { function settled(over: Partial): BashProcess { return { - command: 'x', status: 'completed', exitCode: 0, signal: null, @@ -955,9 +953,8 @@ describe('the model-facing bash tool builds its request from named args only (no stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, }) } - start(spec: BashExecSpec): BashProcess { + start(): BashProcess { return { - command: spec.command, status: 'completed', exitCode: 0, signal: null, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d563e0ab25..61b64466b6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -575,7 +575,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BashProcess', - declaration: 'export interface BashProcess {\n readonly command: string;\n status: BashProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: BashSandboxInfo;\n readOutput(): BashProcessRead;\n kill(): boolean;\n}', + declaration: 'export interface BashProcess {\n status: BashProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: BashSandboxInfo;\n readOutput(): BashProcessRead;\n kill(): boolean;\n}', }, { name: 'BashProcessRead',