Download packages/shell/tool-bash-persistent/src/index.ts from SaylorTwift/deepseek-harness: direct link, hf CLI and curl.
- Browser
- Download file 18.2 kB
-
https://huggingface.co/SaylorTwift/deepseek-harness/resolve/main/packages/shell/tool-bash-persistent/src/index.ts
- Command line
-
hf download hf://SaylorTwift/deepseek-harness/packages/shell/tool-bash-persistent/src/index.ts
-
curl -L -o index.ts https://huggingface.co/SaylorTwift/deepseek-harness/resolve/main/packages/shell/tool-bash-persistent/src/index.ts
18.2 kB
| /** | |
| * Model-facing persistent `bash` tool over the owner-scoped PTY seam. | |
| * @module @deepseek-ai/dsh-tool-bash-persistent | |
| */ | |
| 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 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 | |
| : content.slice(0, 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 = owner.session.header.cwd | |
| const spawned = await ctx.terminals.spawn(owner, { | |
| type: config.backendType, | |
| ...cwd === undefined ? {} : { 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') | |
| } | |
| // Echo suppression only: 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', | |
| 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) | |
| const id = await shells.get(owner, commandDeadline.signal) | |
| 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') | |
| commandDeadline.signal.throwIfAborted() | |
| } | |
| 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 () => { | |
| exec.signal.throwIfAborted() | |
| 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'] | |
| /** 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) | |
| } | |