mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks
# Conflicts: # docs/config-catalog.md # docs/core-data-structures/bash.md # packages/bash/bash-local/src/index.ts # packages/bash/bash/src/types.ts # packages/bash/bash/tests/service.spec.ts # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<void> {
|
||||
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
|
||||
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<typeof collector.finalize>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
27
packages/bash/tool-bash/src/background.ts
Normal file
27
packages/bash/tool-bash/src/background.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Generic-task adaptation for background bash process handles.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-bash/background
|
||||
*/
|
||||
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/**
|
||||
* Map a settled background process onto the generic task-outcome vocabulary:
|
||||
* `killed` stays `killed` (detail: the signal when one is known), everything
|
||||
* else is `completed` with the exit code as detail. A nonzero command exit is
|
||||
* reported, not failed, exactly like the foreground rendering.
|
||||
* @param proc - the settled process handle.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
124
packages/bash/tool-bash/src/render.ts
Normal file
124
packages/bash/tool-bash/src/render.ts
Normal file
@@ -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 }
|
||||
}
|
||||
@@ -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<BashRunResult> { 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>): 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,
|
||||
|
||||
@@ -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<void>;\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<void>;\n sandbox?: BashSandboxInfo;\n readOutput(): BashProcessRead;\n kill(): boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashProcessRead',
|
||||
|
||||
Reference in New Issue
Block a user