The persistent bash tool types each wrapped command into an interactive bash, which applies history expansion before parsing. A `!` after a single quote made bash reject the whole line with `event not found`, so neither marker printed and the call waited for the full timeout; `!!` silently substituted the previous history entry. Disable history expansion when the shell is initialized, as OpenHands does for its interactive terminal. Raw control characters other than newline and carriage return still reach readline as typed keys; the README records that limitation.
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
/**
|
|
* Model-facing persistent `bash` tool over the owner-scoped PTY seam.
|
|
* @module @deepseek-ai/dsh-tool-bash-persistent
|
|
*/
|
|
|
|
import type {} from '@deepseek-ai/dsh-working-directory'
|
|
import { randomUUID } from 'node:crypto'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention'
|
|
import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
|
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
|
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
|
|
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
|
|
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
|
|
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
|
|
// Status trailer for a command that never reported an exit code; settled
|
|
// commands append `[Command finished with exit code N]` instead (see renderCaptured).
|
|
const TIMEOUT_STATUS_MARKER = '[Command timed out or OOM]'
|
|
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
|
|
// One page is enough to find a just-emitted completion marker; the full
|
|
// scrollback is assembled only when a command settles or needs partial output.
|
|
const SCROLLBACK_PAGE_LINES = 1_000
|
|
const POLL_INTERVAL_MS = 25
|
|
|
|
const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
|
|
|
|
interface ResolvedConfig {
|
|
backendType: string
|
|
timeoutMs: number
|
|
maxOutputChars: number
|
|
description: string
|
|
}
|
|
|
|
interface CommandMarkers {
|
|
start: string
|
|
end: string
|
|
}
|
|
|
|
interface RetainedOutput {
|
|
text: string
|
|
truncated: boolean
|
|
}
|
|
|
|
interface CapturedOutput {
|
|
text: string
|
|
incomplete: boolean
|
|
exitCode?: number
|
|
}
|
|
|
|
interface PersistentShells {
|
|
get(owner: Agent, signal: AbortSignal): Promise<TerminalSessionId>
|
|
reset(owner: Agent, reason: string): Promise<void>
|
|
}
|
|
|
|
function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
|
|
if (content.length <= maxOutputChars && !incomplete) return content
|
|
return content.length <= maxOutputChars
|
|
? content + TRUNCATED_MESSAGE
|
|
: truncateWithoutSplittingSurrogatePair(content, maxOutputChars) + TRUNCATED_MESSAGE
|
|
}
|
|
|
|
function markers(): CommandMarkers {
|
|
const nonce = randomUUID()
|
|
return {
|
|
start: `__DSH_PERSISTENT_BASH_START_${nonce}__`,
|
|
end: `__DSH_PERSISTENT_BASH_END_${nonce}:`,
|
|
}
|
|
}
|
|
|
|
function quoteForBash(value: string): string {
|
|
return `$'${value
|
|
.replaceAll('\\', '\\\\')
|
|
.replaceAll("'", "\\'")
|
|
.replaceAll('\r', '\\r')
|
|
.replaceAll('\n', '\\n')}'`
|
|
}
|
|
|
|
function wrapCommand(command: string, marker: CommandMarkers): string {
|
|
// Keep the wrapper on one physical line. An interactive bash prints PS2 for
|
|
// embedded newlines before executing the buffer, which would leak terminal
|
|
// prompts and marker source text into the model-facing result.
|
|
return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"`
|
|
}
|
|
|
|
function trimTrailingNewline(text: string): string {
|
|
return text.replace(/(?:\r?\n)+$/, '')
|
|
}
|
|
|
|
function commandOutput(
|
|
snapshot: RetainedOutput,
|
|
marker: CommandMarkers,
|
|
): CapturedOutput | undefined {
|
|
const text = snapshot.text
|
|
const end = text.lastIndexOf(marker.end)
|
|
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
|
|
if (status === undefined) return undefined
|
|
const startMarker = text.lastIndexOf(marker.start, end)
|
|
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
|
|
return {
|
|
text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')),
|
|
incomplete: startMarker < 0,
|
|
exitCode: Number(status),
|
|
}
|
|
}
|
|
|
|
function partialOutput(
|
|
snapshot: RetainedOutput,
|
|
marker: CommandMarkers,
|
|
fallback: string,
|
|
fallbackTruncated = false,
|
|
): CapturedOutput {
|
|
const startMarker = snapshot.text.lastIndexOf(marker.start)
|
|
if (startMarker >= 0) {
|
|
return {
|
|
text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
|
|
incomplete: false,
|
|
}
|
|
}
|
|
const fallbackStart = fallback.lastIndexOf(marker.start)
|
|
const afterStart = fallbackStart < 0
|
|
? fallback
|
|
: fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
|
|
const fallbackEnd = afterStart.lastIndexOf(marker.end)
|
|
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
|
|
return {
|
|
text: trimTrailingNewline(beforeEnd),
|
|
incomplete: fallbackTruncated || fallbackStart < 0,
|
|
}
|
|
}
|
|
|
|
async function pause(): Promise<void> {
|
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
}
|
|
|
|
function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined {
|
|
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
|
|
return page.lineEnd
|
|
}
|
|
|
|
function retainedScrollback(
|
|
ctx: Context,
|
|
owner: Agent,
|
|
id: TerminalSessionId,
|
|
latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
|
|
): RetainedOutput {
|
|
const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
|
|
let offset = latest.lineEnd
|
|
let truncated = latest.truncated
|
|
while (true) {
|
|
if (offset >= latest.totalLines) break
|
|
const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
|
|
truncated ||= page.truncated
|
|
if (page.text.length > 0) pages.unshift(page.text)
|
|
const next = nextScrollbackOffset(page, offset)
|
|
if (next === undefined || next >= page.totalLines) break
|
|
offset = next
|
|
}
|
|
return { text: pages.join('\n'), truncated }
|
|
}
|
|
|
|
function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
|
|
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
|
|
const withPrefix = output.incomplete && output.text.length > 0
|
|
? LOST_PREFIX_MESSAGE + rendered
|
|
: rendered
|
|
const marker = output.exitCode !== undefined
|
|
? `[Command finished with exit code ${output.exitCode}]`
|
|
: undefined
|
|
return appendStatusMarker(withPrefix, marker)
|
|
}
|
|
|
|
function appendStatusMarker(content: string, marker: string | undefined): string {
|
|
if (marker === undefined) return content
|
|
return content.length === 0 ? marker : `${content}\n${marker}`
|
|
}
|
|
|
|
function renderShellExitStatus(
|
|
content: string,
|
|
exitCode: number | null,
|
|
signal: NodeJS.Signals | null,
|
|
): string {
|
|
const marker = signal !== null
|
|
? `[shell killed by signal: ${signal}]`
|
|
: exitCode !== null
|
|
? `[shell exited: code ${exitCode}]`
|
|
: '[shell exited]'
|
|
return appendStatusMarker(content, marker)
|
|
}
|
|
|
|
/**
|
|
* Render the exited-session result, reset the owner's shell, and reset the
|
|
* message that tells the model the next call starts fresh.
|
|
* @param shells - the owner-scoped registry to reset.
|
|
* @param status - the exited session status (exit code and signal).
|
|
* @returns the complete model-facing result.
|
|
*/
|
|
async function respondToSessionExit(
|
|
ctx: Context,
|
|
shells: PersistentShells,
|
|
owner: Agent,
|
|
id: TerminalSessionId,
|
|
status: { exitCode: number | null; signal: NodeJS.Signals | null },
|
|
marker: CommandMarkers,
|
|
fallback: string,
|
|
fallbackTruncated: boolean,
|
|
config: ResolvedConfig,
|
|
): Promise<string> {
|
|
const snapshot = retainedScrollback(ctx, owner, id)
|
|
await shells.reset(owner, 'persistent bash shell exited')
|
|
return [
|
|
renderShellExitStatus(
|
|
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
|
|
status.exitCode,
|
|
status.signal,
|
|
),
|
|
SHELL_RESET_MESSAGE,
|
|
].filter(part => part.length > 0).join('\n')
|
|
}
|
|
|
|
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
|
|
const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
|
|
const live = new Map<Agent, TerminalSessionId>()
|
|
const creating = new Set<Promise<TerminalSessionId>>()
|
|
const ownerCleanupInstalled = new WeakSet<Agent>()
|
|
const lifecycle = new AbortController()
|
|
|
|
const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise<void> => {
|
|
if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return
|
|
await ctx.terminals.kill(owner, id, reason)
|
|
}
|
|
|
|
ctx.effect(() => async () => {
|
|
lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
|
|
await Promise.allSettled([...creating])
|
|
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
|
|
await Promise.all(closing)
|
|
live.clear()
|
|
}, 'tool-bash-persistent shell cleanup')
|
|
|
|
const reset = async (owner: Agent, reason: string): Promise<void> => {
|
|
pending.delete(owner)
|
|
const id = live.get(owner)
|
|
live.delete(owner)
|
|
if (id !== undefined) await close(owner, id, reason)
|
|
}
|
|
|
|
const get = (owner: Agent, signal: AbortSignal): Promise<TerminalSessionId> => {
|
|
const existing = pending.get(owner)
|
|
if (existing !== undefined) return existing
|
|
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
|
|
const creation = (async () => {
|
|
try {
|
|
const cwd = await ctx.workingDirectory.ensure(owner, combinedSignal)
|
|
const spawned = await ctx.terminals.spawn(owner, {
|
|
type: config.backendType,
|
|
cwd,
|
|
}, combinedSignal)
|
|
live.set(owner, spawned.sessionId)
|
|
if (!ownerCleanupInstalled.has(owner)) {
|
|
ownerCleanupInstalled.add(owner)
|
|
owner.ctx.effect(() => () => {
|
|
pending.delete(owner)
|
|
live.delete(owner)
|
|
}, 'tool-bash-persistent owner cache cleanup')
|
|
}
|
|
// Suppress input echo and history expansion, which would otherwise
|
|
// rewrite `!` sequences or discard the whole wrapper line before bash
|
|
// parses it. The prompt stays the backend's own, so the backend's
|
|
// prompt-based readiness detection keeps working.
|
|
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
|
|
text: 'stty -echo; set +H',
|
|
submit: true,
|
|
signal: combinedSignal,
|
|
})
|
|
const result = await setup.done
|
|
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
|
|
throw new Error('persistent bash shell did not accept initialization')
|
|
}
|
|
return spawned.sessionId
|
|
} catch (error: unknown) {
|
|
await reset(owner, 'persistent bash initialization failed')
|
|
throw error
|
|
}
|
|
})()
|
|
const tracked = creation.finally(() => {
|
|
creating.delete(tracked)
|
|
})
|
|
creating.add(tracked)
|
|
pending.set(owner, tracked)
|
|
return tracked
|
|
}
|
|
|
|
return { get, reset }
|
|
}
|
|
|
|
async function executeCommand(
|
|
ctx: Context,
|
|
shells: PersistentShells,
|
|
owner: Agent,
|
|
command: string,
|
|
config: ResolvedConfig,
|
|
upstream: AbortSignal,
|
|
): Promise<string> {
|
|
using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
|
|
let id: TerminalSessionId
|
|
try {
|
|
id = await shells.get(owner, commandDeadline.signal)
|
|
} catch (error: unknown) {
|
|
// Initialization owns rollback; only this caller's cancellation becomes ABORTED.
|
|
if (upstream.aborted && error === upstream.reason) return ''
|
|
throw error
|
|
}
|
|
const marker = markers()
|
|
const wrapped = wrapCommand(command, marker)
|
|
let first = true
|
|
let fallback = ''
|
|
let fallbackTruncated = false
|
|
|
|
while (true) {
|
|
// The shell may flip to exited between iterations (a fast `exit` can
|
|
// settle the previous send while its exit event is still in flight);
|
|
// re-observing status before the next send closes that gap.
|
|
const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status
|
|
if (status?.kind === 'exited') {
|
|
return await respondToSessionExit(
|
|
ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config,
|
|
)
|
|
}
|
|
let operation
|
|
let result
|
|
try {
|
|
operation = ctx.terminals.startSend(owner, id, {
|
|
text: first ? wrapped : '',
|
|
submit: first,
|
|
signal: commandDeadline.signal,
|
|
})
|
|
first = false
|
|
result = await operation.done
|
|
} catch (error: unknown) {
|
|
await shells.reset(owner, 'persistent bash send failed')
|
|
throw error
|
|
}
|
|
const incremental = operation.readOutput()
|
|
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
|
|
fallbackTruncated ||= incremental.truncated || result.truncated
|
|
const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
|
|
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
|
|
if (timedOut !== undefined) {
|
|
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
|
const partial = renderCaptured(
|
|
partialOutput(snapshot, marker, fallback, fallbackTruncated),
|
|
config.maxOutputChars,
|
|
)
|
|
await shells.reset(owner, 'persistent bash command timed out')
|
|
return [
|
|
// TODO: Report a timeout only; this signal does not establish an OOM.
|
|
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
|
|
appendStatusMarker(partial, TIMEOUT_STATUS_MARKER),
|
|
SHELL_RESET_MESSAGE,
|
|
].join('\n')
|
|
}
|
|
if (commandDeadline.signal.aborted) {
|
|
await shells.reset(owner, 'persistent bash command aborted')
|
|
// ToolRuntime publishes ABORTED after this cancelled invocation settles.
|
|
return ''
|
|
}
|
|
if (latest.text.includes(marker.end)) {
|
|
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
|
|
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
|
|
}
|
|
if (result.sessionStatus.kind === 'exited') {
|
|
return await respondToSessionExit(
|
|
ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config,
|
|
)
|
|
}
|
|
// The shell reads stdin again (its prompt, or a foreground child's own
|
|
// read) without having printed the end marker — e.g. `exec`, an interrupt,
|
|
// or an interactive child. Return what was captured instead of spinning
|
|
// until the command deadline.
|
|
if (result.waitReason === 'stdin_read') {
|
|
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
|
return renderCaptured(
|
|
partialOutput(snapshot, marker, fallback, fallbackTruncated),
|
|
config.maxOutputChars,
|
|
)
|
|
}
|
|
await pause()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the model-facing persistent `bash` tool.
|
|
* @param ctx - plugin context carrying tools and the owner-scoped PTY service.
|
|
* @param config - selected PTY backend and command deadline.
|
|
*/
|
|
function registerPersistentBash(ctx: Context, config: ResolvedConfig): void {
|
|
const shells = persistentShells(ctx, config)
|
|
const queues = new WeakMap<Agent, Promise<void>>()
|
|
|
|
const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
|
|
const prior = queues.get(owner) ?? Promise.resolve()
|
|
const run = prior.then(operation, operation)
|
|
const tail = run.then(() => undefined, () => undefined)
|
|
queues.set(owner, tail)
|
|
try {
|
|
return await run
|
|
} finally {
|
|
if (queues.get(owner) === tail) queues.delete(owner)
|
|
}
|
|
}
|
|
|
|
ctx.tools.register(defineTool({
|
|
name: 'bash',
|
|
description: config.description,
|
|
parameters: {
|
|
command: {
|
|
type: 'string',
|
|
required: true,
|
|
description: 'The bash command to run. Relative path is preferred in the command.',
|
|
},
|
|
},
|
|
output: {
|
|
schema: { type: 'string' },
|
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
},
|
|
async execute(args, exec) {
|
|
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
|
|
const owner = exec.agent
|
|
if (owner === undefined) throw new Error('bash requires an owning agent session')
|
|
return serialized(owner, async () => {
|
|
if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement.
|
|
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
|
|
})
|
|
},
|
|
presentCall: args => ({ card: 'terminal', title: args.command }),
|
|
}))
|
|
}
|
|
|
|
export const name = 'tool-bash-persistent'
|
|
export const inject = ['tools', 'terminals', 'workingDirectory']
|
|
|
|
/** Configuration for the persistent Bash tool. */
|
|
export interface Config {
|
|
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
|
|
backendType?: string
|
|
/** Wall-clock limit for one command (default 300000). */
|
|
timeoutMs?: number
|
|
/** Maximum returned command-output characters before clipping (default 16000). */
|
|
maxOutputChars?: number
|
|
/** Model-facing tool description; deployments may describe their environment. */
|
|
description?: string
|
|
}
|
|
|
|
/** Runtime configuration schema for the persistent Bash tool. */
|
|
export const Config: z<Config> = z.object({
|
|
backendType: z.string().default('shell'),
|
|
timeoutMs: z.number().default(300_000),
|
|
maxOutputChars: z.number().default(16_000),
|
|
description: z.string().default(DEFAULT_DESCRIPTION),
|
|
})
|
|
|
|
/** Register one owner-scoped persistent `bash` tool. */
|
|
export function apply(ctx: Context, config: Config): void {
|
|
const resolved: ResolvedConfig = {
|
|
backendType: config.backendType ?? 'shell',
|
|
timeoutMs: config.timeoutMs ?? 300_000,
|
|
maxOutputChars: config.maxOutputChars ?? 16_000,
|
|
description: config.description ?? DEFAULT_DESCRIPTION,
|
|
}
|
|
if (resolved.backendType.trim().length === 0) {
|
|
throw new Error('tool-bash-persistent: backendType must be non-empty')
|
|
}
|
|
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
|
|
throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer')
|
|
}
|
|
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
|
|
throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer')
|
|
}
|
|
if (resolved.description.trim().length === 0) {
|
|
throw new Error('tool-bash-persistent: description must be non-empty')
|
|
}
|
|
registerPersistentBash(ctx, resolved)
|
|
}
|