feat(tools): add persistent bash and str-replace editor
This commit is contained in:
6
packages/pty/tool-bash-persistent/README.i18n.yaml
Normal file
6
packages/pty/tool-bash-persistent/README.i18n.yaml
Normal 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
|
||||
50
packages/pty/tool-bash-persistent/README.md
Normal file
50
packages/pty/tool-bash-persistent/README.md
Normal 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.
|
||||
50
packages/pty/tool-bash-persistent/README.zh.md
Normal file
50
packages/pty/tool-bash-persistent/README.zh.md
Normal 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`,而非包默认描述。
|
||||
55
packages/pty/tool-bash-persistent/package.json
Normal file
55
packages/pty/tool-bash-persistent/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
380
packages/pty/tool-bash-persistent/src/index.ts
Normal file
380
packages/pty/tool-bash-persistent/src/index.ts
Normal 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)
|
||||
}
|
||||
30
packages/pty/tool-bash-persistent/src/invariant.ts
Normal file
30
packages/pty/tool-bash-persistent/src/invariant.ts
Normal 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 */
|
||||
@@ -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)
|
||||
})
|
||||
385
packages/pty/tool-bash-persistent/tests/tools.spec.ts
Normal file
385
packages/pty/tool-bash-persistent/tests/tools.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
16
packages/pty/tool-bash-persistent/tsconfig.json
Normal file
16
packages/pty/tool-bash-persistent/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user