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:
Yichen Jiang
2026-07-15 13:38:17 +08:00
16 changed files with 181 additions and 182 deletions

View File

@@ -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

View File

@@ -0,0 +1,27 @@
/**
* Generic-task adaptation for background bash process handles.
*
* @module @deepseek-ai/dsh-tool-bash/background
*/
import type { BashProcess } from '@deepseek-ai/dsh-bash'
/**
* Map a settled background process onto the generic task-outcome vocabulary:
* `killed` stays `killed` (detail: the signal when one is known), everything
* else is `completed` with the exit code as detail. A nonzero command exit is
* reported, not failed, exactly like the foreground rendering.
* @param proc - the settled process handle.
* @returns the outcome for the `ctx.tasks` registration.
*/
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
if (proc.status === 'killed') {
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
}
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
}

View File

@@ -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.

View 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 }
}

View File

@@ -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,