feat(tools): add persistent bash and str-replace editor

This commit is contained in:
Yichen Jiang
2026-07-29 14:12:27 +08:00
parent 75b32f7d76
commit 665c21693b
59 changed files with 2880 additions and 91 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/pty/tool-bash-persistent/README.md
README.md: 04c714d5489dbae8572e9339a4387a148450a0e9
README.zh.md: adfb38b10409174d9558b963f5a2359cf819f04b

View File

@@ -0,0 +1,50 @@
# @deepseek-ai/dsh-tool-bash-persistent
English | [中文](README.zh.md)
Model-facing `bash(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend and sandbox policy.
## Config
| Key | Default | Meaning |
|---|---:|---|
| `backendType` | `shell` | Registered PTY backend used for each Agent shell. |
| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. |
| `maxOutputChars` | `16000` | Prefix characters retained before the clipping notice. |
| `description` | Persistent-shell description | Model-facing environment contract. |
## Model Experience
### Tool schema
#### What the model sees
The generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance.
#### Token effect
Fixed schema cost while `bash` is visible.
#### KV Cache effect
Prefix-stable while the configured description and schema remain unchanged.
### Tool results
#### What the model sees
Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and tells the model that the next call starts fresh.
#### Token effect
Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice.
#### KV Cache effect
Append-only tool results follow the reusable request prefix.
## Known Limitations and Deferred Work
- The tool requires an owning Agent and a real PTY backend.
- Explicit `exit`, timeout, or cancellation discards shell state; the next call starts a fresh shell.
- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default.

View File

@@ -0,0 +1,50 @@
# @deepseek-ai/dsh-tool-bash-persistent
[English](README.md) | 中文
模型可见的 `bash(command)`,底层复用一个按所有者隔离的 `ctx.pty` shell。该包拥有工具契约和 shell 复用PTY 后端与沙箱策略由部署选择。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 |
| `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 |
| `maxOutputChars` | `16000` | 截断提示前保留的前缀字符数。 |
| `description` | 持久 shell 描述 | 面向模型的环境契约。 |
## 模型体验
### 工具 schema
#### 模型所见
生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段persona 与环境指导由部署负责。
#### Token 影响
`bash` 可见时产生固定的 schema 成本。
#### KV Cache 影响
配置的描述与 schema 不变时前缀稳定。
### 工具结果
#### 模型所见
每个 Agent 的命令共享一个 shell因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell并告知模型下次调用从新 shell 开始。
#### Token 影响
随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
#### KV Cache 影响
工具结果以追加方式位于可复用请求前缀之后。
## 已知限制与延后工作
- 工具需要拥有它的 Agent 和真实 PTY 后端。
- 显式 `exit`、超时或取消会丢弃 shell 状态;下次调用创建新 shell。
- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-tool-bash-persistent",
"description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,380 @@
/**
* 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 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
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.'
const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
const SCROLLBACK_PAGE_LINES = 1_000
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
}
interface PersistentShells {
get(owner: Agent, signal: AbortSignal): Promise<PtySessionId>
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 stripPrompt(text: string): string {
let result = text
while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) {
result = result.slice(0, result.endsWith('\r\n')
? -SHELL_PROMPT.length - 2
: -SHELL_PROMPT.length - 1)
}
while (result.endsWith(SHELL_PROMPT)) {
result = result.slice(0, -SHELL_PROMPT.length)
}
return result.endsWith('\n') ? result.slice(0, -1) : result
}
function commandOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
): CapturedOutput | undefined {
const text = snapshot.text
const end = text.lastIndexOf(marker.end)
if (end < 0) return undefined
const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0 || snapshot.truncated,
}
}
function promptCompleted(result: PtySendResult): boolean {
return result.viewport.endsWith(SHELL_PROMPT)
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
}
function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
fallback: string,
): CapturedOutput {
const startMarker = snapshot.text.lastIndexOf(marker.start)
if (startMarker >= 0) {
return {
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
incomplete: snapshot.truncated,
}
}
return {
text: stripPrompt(fallback),
incomplete: snapshot.truncated,
}
}
async function pause(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined {
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
return page.lineEnd
}
function retainedScrollback(
ctx: Context,
owner: Agent,
id: PtySessionId,
): RetainedOutput {
const pages: string[] = []
let offset = 0
let truncated = false
while (true) {
const page = ctx.pty.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)
return output.incomplete && output.text.length > 0
? LOST_PREFIX_MESSAGE + rendered
: rendered
}
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
const pending = new WeakMap<Agent, Promise<PtySessionId>>()
const live = new Map<Agent, PtySessionId>()
const ownerCleanupInstalled = new WeakSet<Agent>()
const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => {
if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
await ctx.pty.kill(owner, id, reason)
}
ctx.effect(() => async () => {
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<PtySessionId> => {
const existing = pending.get(owner)
if (existing !== undefined) return existing
const creating = (async () => {
try {
const cwd = owner.session.header.cwd
const spawned = await ctx.pty.spawn(owner, {
type: config.backendType,
...cwd === undefined ? {} : { cwd },
}, signal)
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')
}
const setup = ctx.pty.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
submit: true,
signal,
})
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
}
})()
pending.set(owner, creating)
return creating
}
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 = ''
while (true) {
const operation = ctx.pty.startSend(owner, id, {
text: first ? wrapped : '',
submit: first,
signal: commandDeadline.signal,
})
first = false
const result = await operation.done
fallback += result.viewport
const snapshot = retainedScrollback(ctx, owner, id)
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
if (timedOut !== undefined) {
const partial = renderCaptured(
partialOutput(snapshot, marker, fallback),
config.maxOutputChars,
)
await shells.reset(owner, 'persistent bash command timed out')
return [
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
partial,
SHELL_RESET_MESSAGE,
].join('\n')
}
const complete = commandOutput(snapshot, marker)
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
if (result.sessionStatus.kind === 'exited') {
await shells.reset(owner, 'persistent bash shell exited')
return [
renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars),
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
if (commandDeadline.signal.aborted) {
await shells.reset(owner, 'persistent bash command aborted')
commandDeadline.signal.throwIfAborted()
}
if (promptCompleted(result)) {
return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated)
}
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', 'pty']
/** 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)
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`.
* @module @deepseek-ai/dsh-tool-bash-persistent/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent'
/** Cordis companion plugin name. */
export const name = 'tool-bash-persistent-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the tool adapter owns no independent durable state;
* PTY ownership and filesystem mutation relations stay with their services.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,156 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService from '@deepseek-ai/dsh-pty'
import * as PtyLocal from '@deepseek-ai/dsh-pty-local'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function agent(ctx: Context, cwd: string): Agent {
const id = SessionId('persistent-bash-loader-agent')
const scope = ctx.plugin(() => {})
const value: Agent = {
id,
options: {},
session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }),
status: 'idle',
acceptsNextStep: false,
ctx: scope.ctx,
followup: () => {},
steer: () => {},
inject: () => {},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
suite('persistent Bash through a real cordis.yml Loader composition', () => {
it('preserves cwd and environment across calls', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-pty'",
"- name: '@deepseek-ai/dsh-test-sandbox'",
"- name: '@deepseek-ai/dsh-sandbox-policy'",
' config:',
' mode: danger-full-access',
` workspaceRoot: ${JSON.stringify(root)}`,
"- name: '@deepseek-ai/dsh-pty-local'",
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 100',
' handoffGraceMs: 100',
' scrollbackLines: 20000',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-bash-persistent'",
' config:',
' timeoutMs: 5000',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRegistry],
['@deepseek-ai/dsh-pty', PtyService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-pty-local', PtyLocal],
['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await context.loader.await()
const owner = agent(context, root)
const signal = new AbortController().signal
const execute = (id: string, command: string) => context!.tools.execute({
signal,
callId: CallId(id),
name: 'bash',
arguments: { command },
agent: owner,
})
expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash'])
await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested')
const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"'))
expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
expect(observed).not.toContain('DSH_PERSISTENT_BASH')
const multiline = text(await execute(
'multiline',
'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"',
))
expect(multiline).toBe("line one:it's fine")
expect(multiline).not.toContain('DSH_PERSISTENT_BASH')
const heredoc = text(await execute(
'heredoc',
"cat <<'EOF'\nalpha\nbeta\nEOF",
))
expect(heredoc).toBe('alpha\nbeta')
const large = text(await execute('large-output', 'seq 1 12050'))
expect(large.startsWith('1\n2\n3\n')).toBe(true)
expect(large).toContain('<response clipped>')
expect(large).not.toContain('beginning of this command output was dropped')
const exited = text(await execute('exit', 'exit'))
expect(exited).toContain('next bash call starts from the workspace')
expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root)
}, 20_000)
})

View File

@@ -0,0 +1,385 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService from '@deepseek-ai/dsh-pty'
import type {
PtyBackend,
PtyBackendSession,
PtyReadRequest,
PtySendOperation,
PtySendRequest,
PtySessionStatus,
PtySignal,
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
const contexts: Context[] = []
let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
})
function agent(ctx: Context, cwd: string | undefined): Agent {
const id = SessionId(`persistent-bash-owner-${callNumber}`)
const scope = ctx.plugin(() => {})
const value: Agent = {
id,
options: {},
session: new Session(id, [], {
version: 0,
id,
createdAt: 0,
...cwd === undefined ? {} : { cwd },
}),
status: 'idle',
acceptsNextStep: false,
ctx: scope.ctx,
followup: () => {},
steer: () => {},
inject: () => {},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
function call(
ctx: Context,
owner: Agent | undefined,
command: string,
signal = new AbortController().signal,
) {
return ctx.tools.execute({
signal,
callId: CallId(`persistent-bash-${++callNumber}`),
name: 'bash',
arguments: { command },
...owner === undefined ? {} : { agent: owner },
})
}
type StubMode =
| 'normal'
| 'prompt-only'
| 'prompt-crlf'
| 'empty-read'
| 'stalled-read'
| 'exit'
| 'wait-for-abort'
| 'idle-then-normal'
| 'large'
| 'end-only'
| 'init-exit'
| 'init-timeout'
| 'spawn-error'
class StubPtySession implements PtyBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
readonly pid = 123
statusValue: PtySessionStatus = { kind: 'running' }
scrollback = this.motd
closed: string[] = []
mode: StubMode
sends = 0
pendingText = ''
constructor(mode: StubMode) {
this.mode = mode
}
startSend(request: PtySendRequest): PtySendOperation {
this.sends += 1
if (request.text.startsWith('stty -echo')) {
if (this.mode === 'init-exit') {
this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
return this.operation(Promise.resolve(this.result('', 'session_exit')))
}
if (this.mode === 'init-timeout') {
return this.operation(Promise.resolve(this.result('', 'timeout')))
}
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
}
if (this.mode === 'wait-for-abort') {
const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
request.signal?.addEventListener('abort', () => {
this.scrollback += 'partial output'
resolve(this.result('partial output', 'stdin_read'))
}, { once: true })
})
return this.operation(done)
}
if (this.mode === 'idle-then-normal') {
this.mode = 'normal'
this.pendingText = request.text
return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
}
if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
const output = `bash: syntax error${newline}${this.motd}${newline}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const sent = request.text.length > 0 ? request.text : this.pendingText
this.pendingText = ''
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0]
const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0]
if (this.mode === 'end-only') {
const output = `recovered output\n${end ?? ''}0\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub'
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}`
this.scrollback += output
if (this.mode === 'exit') {
const exitedOutput = `${start ?? ''}\nhello from stub\n`
this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
}
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
read(_request: PtyReadRequest) {
if (this.mode === 'empty-read') {
return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
}
if (this.mode === 'stalled-read') {
return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
}
const lines = this.scrollback.split('\n')
return {
text: this.scrollback,
totalLines: lines.length,
lineBegin: 0,
lineEnd: lines.length,
truncated: false,
}
}
signal(_signal: PtySignal) {
return Promise.resolve({ delivered: true as const, targetPgid: 123 })
}
status() {
return this.statusValue
}
async close(reason: string) {
this.closed.push(reason)
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
}
private result(viewport: string, waitReason: PtyWaitReason) {
return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
}
private operation(done: Promise<ReturnType<StubPtySession['result']>>): PtySendOperation {
return {
done,
readOutput: () => ({ delta: '', truncated: false }),
cancel: () => false,
}
}
}
function stubBackend(initialMode: StubMode = 'normal') {
const sessions: StubPtySession[] = []
const backend: PtyBackend = {
type: 'stub',
async spawn() {
if (initialMode === 'spawn-error') throw new Error('stub spawn failed')
const session = new StubPtySession(initialMode)
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(
config: ToolBashPersistent.Config = { backendType: 'stub' },
initialMode: StubMode = 'normal',
) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
const stub = stubBackend(initialMode)
ctx.pty.registerBackend(stub.backend)
const fiber = await ctx.plugin(ToolBashPersistent, config)
return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
}
describe('tool-bash-persistent', () => {
it('registers a configurable schema and reuses one owner shell', async () => {
const { ctx, owner, stub } = await setup({
backendType: 'stub',
description: 'deployment-specific persistent shell',
})
const schema = ctx.tools.schemas()[0]
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash'])
expect(schema?.description).toBe('deployment-specific persistent shell')
expect(schema?.parameters).toMatchObject({
required: ['command'],
properties: { command: { type: 'string' } },
})
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' }))
.toEqual({ card: 'terminal', title: 'pwd' })
expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub')
expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(1)
expect(stub.sessions[0]?.sends).toBe(3)
const ownerWithoutCwd = agent(ctx, undefined)
expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
})
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
maxOutputChars: 10,
})
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'idle-then-normal'
expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('bash: synt')
expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'prompt-crlf'
session.scrollback = ''
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
expect(crlfPromptFallback).toContain('bash: synt')
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'end-only'
session.scrollback = ''
const missingStart = text(await call(ctx, owner, 'recover marker'))
expect(missingStart).toContain('recovered')
expect(missingStart).toContain('beginning of this command output was dropped')
expect(missingStart).toContain('<response clipped>')
session.mode = 'large'
expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
session.mode = 'exit'
const exited = text(await call(ctx, owner, 'exit'))
expect(exited).toContain('hello from')
expect(exited).toContain('next bash call starts from the workspace')
expect(session.closed).toContain('persistent bash shell exited')
await call(ctx, owner, 'new shell')
expect(stub.sessions).toHaveLength(2)
const externallyClosed = ctx.pty.list(owner)[0]?.sessionId
expect(externallyClosed).toBeDefined()
await ctx.pty.kill(owner, externallyClosed!, 'external cleanup')
await fiber.dispose()
expect(stub.sessions[1]?.closed).toEqual(['external cleanup'])
})
it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'end-only'
session.scrollback = ''
expect(text(await call(ctx, owner, 'missing start')))
.toContain('beginning of this command output was dropped')
session.mode = 'empty-read'
expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub')
session.mode = 'stalled-read'
expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
})
it('closes a timed-out shell and reports bounded partial output', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'wait-for-abort'
const result = await call(ctx, owner, 'hang')
expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error')
expect(text(result)).toContain('partial output')
expect(text(result)).toContain('next bash call starts from the workspace')
expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out')
})
it('cancels in-flight work, resets the shell, and releases a queued call', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'wait-for-abort'
const controller = new AbortController()
const cancelled = call(ctx, owner, 'hang', controller.signal)
const queued = call(ctx, owner, 'after cancellation')
setTimeout(() => {
controller.abort(new Error('caller stopped'))
}, 5)
expect((await cancelled).isError).toBe(true)
expect(text(await queued)).toBe('hello from stub')
expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
expect(stub.sessions).toHaveLength(2)
})
it.each(['init-exit', 'init-timeout'] as const)(
'fails initialization and closes the unusable shell for %s',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed')
},
)
it('clears a failed spawn without trying to close an unpublished shell', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
})
it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
expect(() => {
ToolBashPersistent.apply(new Context(), { backendType: '' })
}).toThrow('backendType must be non-empty')
expect(() => {
ToolBashPersistent.apply(new Context(), { timeoutMs: 0 })
}).toThrow('timeoutMs must be a positive safe integer')
expect(() => {
ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 })
}).toThrow('maxOutputChars must be a positive safe integer')
expect(() => {
ToolBashPersistent.apply(new Context(), { description: ' ' })
}).toThrow('description must be non-empty')
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/agent" },
{ "path": "../../core/tools" },
{ "path": "../pty" },
{ "path": "../../support/invariants" },
{ "path": "../../util/timeout" }
]
}