fix(tools): harden persistent tool integrations

This commit is contained in:
Yichen Jiang
2026-07-29 15:21:56 +08:00
parent 73379d9c68
commit 260ea24594
26 changed files with 927 additions and 112 deletions

View File

@@ -105,7 +105,11 @@ export interface Config {
sessionTitle?: SessionTitleConfig
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
/**
* Skill registry, local provider, and model-facing consumer config.
* Skills use `enabled` because one nested config controls a provider stack;
* single model-tool plugins use `Config | false` to disable that one consumer.
*/
skills?: SkillConfig
/** Model-facing bash tool config, or false when another plugin owns `bash`. */
toolBash?: toolBash.Config | false

View File

@@ -2,5 +2,5 @@
# 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/fs/tool-str-replace-editor/README.md
README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b
README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d
README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2
README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39

View File

@@ -11,6 +11,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w
| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. |
| `description` | Editor command guide | Model-facing tool description. |
| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. |
| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. |
## Tool
@@ -36,7 +37,7 @@ Prefix-stable while the configured description and schema remain unchanged.
#### What the model sees
Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
#### Token effect
@@ -50,5 +51,5 @@ Append-only tool results follow the reusable request prefix.
- Operations target UTF-8 text; binary files are unsupported.
- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument.
- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor.
- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins.
- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files.
- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins.

View File

@@ -11,6 +11,7 @@
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 |
| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 |
## 工具
@@ -36,7 +37,7 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使
#### 模型所见
查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
#### Token 影响
@@ -50,5 +51,5 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使
- 操作面向 UTF-8 文本,不支持二进制文件。
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。
- 安全与先读后改策略委托给挂载的文件系统和策略插件。
- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。
- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。

View File

@@ -28,6 +28,8 @@
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -38,8 +40,12 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "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-tools": "workspace:^",

View File

@@ -7,9 +7,12 @@ import { isAbsolute } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs'
import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ToolCallView, ToolRunContext } 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>'
@@ -49,17 +52,68 @@ function expandTabs(content: string, tabSize = 8): string {
return result
}
function codepointCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function matchOffsets(content: string, search: string): number[] {
const offsets: number[] = []
let offset = 0
while (true) {
const match = content.indexOf(search, offset)
if (match < 0) return offsets
offsets.push(match)
offset = match + search.length
}
}
function lineNumbersAt(content: string, offsets: readonly number[]): number[] {
let line = 1
let cursor = 0
return offsets.map((offset) => {
while (cursor < offset) {
if (content[cursor] === '\n') line += 1
cursor += 1
}
return line
})
}
class MutationPolicy {
private readonly policy: SandboxPolicyService | undefined
constructor(ctx: Context) {
this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) {
throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing')
}
}
resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined {
return this.policy?.resolve({
...exec.agent === undefined ? {} : { session: exec.agent.session },
})
}
mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown {
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
const mode = (policy as SandboxExecutionPolicy).mode
return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error })
}
}
async function resolveTarget(
ctx: Context,
path: string,
requireAbsolutePath: boolean,
exec: ToolRunContext,
workspaceRoot?: string,
): Promise<FsTarget> {
if (path.trim().length === 0) throw new Error('path must be a non-empty string')
if (requireAbsolutePath && !isAbsolute(path)) {
throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
}
const cwd = exec.agent?.session.header.cwd
const cwd = exec.agent?.session.header.cwd ?? workspaceRoot
return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal })
}
@@ -158,8 +212,8 @@ async function listDirectory(
const rows: string[] = []
for (const entry of entries.filter(candidate =>
!candidate.name.startsWith('.')
&& !candidate.name.startsWith('node_modules')
&& !candidate.name.startsWith('__pycache__'))) {
&& candidate.name !== 'node_modules'
&& candidate.name !== '__pycache__')) {
const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
rows.push(`${type}\t${entry.target.displayPath}`)
if (entry.type === 'directory' && depth < 2) {
@@ -172,7 +226,7 @@ async function listDirectory(
rows.sort((left, right) => {
const leftPath = left.slice(left.indexOf('\t') + 1)
const rightPath = right.slice(right.indexOf('\t') + 1)
return leftPath.localeCompare(rightPath)
return codepointCompare(leftPath, rightPath)
})
const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars)
return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`
@@ -204,78 +258,124 @@ async function viewPath(
async function createFile(
ctx: Context,
policy: MutationPolicy,
path: string,
fileText: string | undefined,
requireAbsolutePath: boolean,
exec: ToolRunContext,
): Promise<string> {
const content = requiredForCommand(fileText, 'file_text', 'create')
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
if (await ctx.fs.stat(target, exec.signal) !== undefined) {
throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)
}
const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal)
const intent = await ctx.waterfall(
'fs/write-intent',
target,
exec,
() => ({ kind: 'createIfAbsent' } as const),
)
let outcome
try {
outcome = await ctx.fs.writeText(
target,
content,
intent ?? { kind: 'createIfAbsent' },
exec.signal,
sandboxPolicy,
)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
return `New file created successfully at: ${target.displayPath}`
}
async function replaceInFile(
ctx: Context,
policy: MutationPolicy,
path: string,
oldStr: string | undefined,
newStr: string | undefined,
requireAbsolutePath: boolean,
expandTabsOnMutation: boolean,
exec: ToolRunContext,
): Promise<string> {
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false))
const newValue = expandTabs(newStr ?? '')
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false)
const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue
const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? ''
const info = await statExisting(ctx, target, 'str_replace', exec)
if (info.type !== 'file') {
throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
const before = expandTabs(await ctx.fs.readText(target, exec.signal))
const occurrences = before.split(oldValue).length - 1
if (occurrences === 0) {
const rawBefore = await ctx.fs.readText(target, exec.signal)
const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore
const offsets = matchOffsets(before, oldValue)
if (offsets.length === 0) {
throw new FsError(
`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
'FS_EDIT_NOT_FOUND',
)
}
if (occurrences > 1) {
const lines = before.split('\n')
.flatMap((line, index) => line.includes(oldValue) ? [index + 1] : [])
if (offsets.length > 1) {
const lines = lineNumbersAt(before, offsets)
throw new FsError(
`No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`,
'FS_AMBIGUOUS_EDIT',
)
}
const outcome = await ctx.fs.writeText(
target,
before.replace(oldValue, newValue),
{ kind: 'replaceIfVersion', version: info.version },
exec.signal,
)
let outcome
try {
outcome = expandTabsOnMutation
? await ctx.fs.writeText(
target,
before.replace(oldValue, newValue),
intent === undefined
? { kind: 'replaceIfVersion', version: info.version }
: { kind: 'replaceIfVersion', version: intent.version },
exec.signal,
sandboxPolicy,
)
: await ctx.fs.editText(
target,
{ oldString: oldValue, newString: newValue, replaceAll: false },
intent ?? { version: info.version },
exec.signal,
sandboxPolicy,
)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
async function insertInFile(
ctx: Context,
policy: MutationPolicy,
path: string,
insertLine: number | undefined,
newStr: string | undefined,
requireAbsolutePath: boolean,
expandTabsOnMutation: boolean,
exec: ToolRunContext,
): Promise<string> {
if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert'))
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
const rawValue = requiredForCommand(newStr, 'new_str', 'insert')
const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const info = await statExisting(ctx, target, 'insert', exec)
if (info.type !== 'file') {
throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
const before = expandTabs(await ctx.fs.readText(target, exec.signal))
const rawBefore = await ctx.fs.readText(target, exec.signal)
const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore
const lines = before.split('\n')
if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) {
throw new Error(
@@ -287,12 +387,15 @@ async function insertInFile(
...value.split('\n'),
...lines.slice(insertLine),
].join('\n')
const outcome = await ctx.fs.writeText(
target,
after,
{ kind: 'replaceIfVersion', version: info.version },
exec.signal,
)
const expected: FsWriteIntent = intent === undefined
? { kind: 'replaceIfVersion', version: info.version }
: { kind: 'replaceIfVersion', version: intent.version }
let outcome
try {
outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
@@ -301,10 +404,59 @@ interface ResolvedConfig {
maxOutputChars: number
description: string
requireAbsolutePath: boolean
expandTabsOnMutation: boolean
}
function presentEditorCall(args: {
command: 'view' | 'create' | 'str_replace' | 'insert'
path: string
file_text?: string
insert_line?: number
new_str?: string
old_str?: string
}): ToolCallView {
switch (args.command) {
case 'view':
return {
card: 'generic',
title: `view ${args.path}`,
kind: 'read',
locations: [{ path: args.path }],
}
case 'create':
return {
card: 'diff',
title: `create ${args.path}`,
diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }],
locations: [{ path: args.path }],
}
case 'str_replace':
return {
card: 'diff',
title: `str_replace ${args.path}`,
diffs: [{
path: args.path,
oldText: args.old_str ?? null,
newText: args.new_str ?? '',
}],
locations: [{ path: args.path }],
}
case 'insert':
return {
card: 'generic',
title: `insert ${args.path}`,
kind: 'edit',
locations: [{
path: args.path,
...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) },
}],
}
}
}
/** Register the model-facing `str_replace_editor` tool. */
function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
const policy = new MutationPolicy(ctx)
ctx.tools.register(defineTool({
name: 'str_replace_editor',
description: config.description,
@@ -351,18 +503,32 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
case 'view':
return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec)
case 'create':
return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec)
return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec)
case 'str_replace':
return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec)
return replaceInFile(
ctx,
policy,
args.path,
args.old_str,
args.new_str,
config.requireAbsolutePath,
config.expandTabsOnMutation,
exec,
)
case 'insert':
return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec)
return insertInFile(
ctx,
policy,
args.path,
args.insert_line,
args.new_str,
config.requireAbsolutePath,
config.expandTabsOnMutation,
exec,
)
}
},
presentCall: args => ({
card: 'generic',
title: `${args.command} ${args.path}`,
kind: args.command === 'view' ? 'read' : 'edit',
}),
presentCall: presentEditorCall,
}))
}
@@ -377,6 +543,8 @@ export interface Config {
description?: string
/** Require local absolute paths like the canonical editor contract (default true). */
requireAbsolutePath?: boolean
/** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */
expandTabsOnMutation?: boolean
}
/** Runtime configuration schema for the string-replacement editor tool. */
@@ -384,6 +552,7 @@ export const Config: z<Config> = z.object({
maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION),
requireAbsolutePath: z.boolean().default(true),
expandTabsOnMutation: z.boolean().default(true),
})
/** Register one `str_replace_editor` tool over `ctx.fs`. */
@@ -392,6 +561,7 @@ export function apply(ctx: Context, config: Config): void {
maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION,
requireAbsolutePath: config.requireAbsolutePath ?? true,
expandTabsOnMutation: config.expandTabsOnMutation ?? true,
}
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer')

View File

@@ -9,6 +9,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
@@ -57,7 +60,10 @@ function call(ctx: Context, owner: Agent | undefined, args: unknown) {
})
}
async function setup(config: ToolStrReplaceEditor.Config = {}) {
async function setup(
config: ToolStrReplaceEditor.Config = {},
options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {},
) {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
roots.push(root)
const ctx = new Context()
@@ -65,7 +71,13 @@ async function setup(config: ToolStrReplaceEditor.Config = {}) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: root })
if (options.sandboxMode === undefined) {
await ctx.plugin(LocalFileSystem, { cwd: root })
} else {
await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root })
await ctx.plugin(SandboxedFileSystem, { cwd: root })
}
if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
await ctx.plugin(ToolStrReplaceEditor, config)
return { ctx, root, owner: agent(ctx, root) }
}
@@ -85,13 +97,38 @@ describe('tool-str-replace-editor', () => {
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'view',
path: '/workspace/a.txt',
})).toMatchObject({ card: 'generic', kind: 'read' })
})).toMatchObject({
card: 'generic',
kind: 'read',
locations: [{ path: '/workspace/a.txt' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'create',
path: '/workspace/a.txt',
file_text: 'hello',
})).toMatchObject({
card: 'diff',
diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'str_replace',
path: '/workspace/a.txt',
old_str: 'old',
new_str: 'new',
})).toMatchObject({
card: 'diff',
diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'insert',
path: '/workspace/a.txt',
insert_line: 0,
new_str: 'x',
})).toMatchObject({ card: 'generic', kind: 'edit' })
})).toMatchObject({
card: 'generic',
kind: 'edit',
locations: [{ path: '/workspace/a.txt', line: 1 }],
})
})
it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
@@ -136,16 +173,20 @@ describe('tool-str-replace-editor', () => {
})
it('lists visible entries to depth two and clips at the configured view limit', async () => {
const { ctx, root, owner } = await setup({ maxOutputChars: 10 })
const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 })
await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true })
await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true })
await mkdir(join(root, 'dir', '__pycache__'), { recursive: true })
await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true })
await writeFile(join(root, 'dir', 'visible.txt'), 'ok')
await writeFile(join(root, 'dir', '.hidden'), 'hidden')
await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child')
await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep')
await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency')
await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source')
await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache')
await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source')
const listDir = ctx.fs.listDir.bind(ctx.fs)
const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
ctx.fs.listDir = async (target, signal) => {
@@ -156,14 +197,19 @@ describe('tool-str-replace-editor', () => {
}
const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
expect(listing).toContain('<response clipped>')
expect(listing).not.toContain('.hidden')
expect(listing).not.toContain('too-deep.txt')
expect(listing).not.toContain('index.js')
expect(listing).not.toContain('module.pyc')
expect(listing).toContain('node_modules_old/kept.js')
expect(listing).toContain('__pycache__backup/kept.py')
await writeFile(join(root, 'large.txt'), 'x'.repeat(100))
expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') })))
const clipped = await setup({ maxOutputChars: 10 })
await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
expect(text(await call(clipped.ctx, clipped.owner, {
command: 'view',
path: join(clipped.root, 'large.txt'),
})))
.toContain('<response clipped>')
})
@@ -233,10 +279,20 @@ describe('tool-str-replace-editor', () => {
expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]')
expect(text(repeated)).not.toContain('replace_all')
await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta')
const repeatedMultiline = await call(ctx, owner, {
command: 'str_replace',
path: ambiguous,
old_str: 'alpha\nbeta',
new_str: 'x',
})
expect(text(repeatedMultiline))
.toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]')
const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' })
expect(relative.isError).toBe(true)
expect(text(relative)).toContain('is not an absolute path')
expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame')
expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta')
})
it('reports invalid commands or arguments without mutating files', async () => {
@@ -302,6 +358,63 @@ describe('tool-str-replace-editor', () => {
.toContain("Here's the content of")
})
it('delegates read-before-edit decisions to fs-policy', async () => {
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
const existing = join(root, 'existing.txt')
const created = join(root, 'created.txt')
await writeFile(existing, 'before')
const blindEdit = await call(ctx, owner, {
command: 'str_replace',
path: existing,
old_str: 'before',
new_str: 'after',
})
expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
expect(await readFile(existing, 'utf8')).toBe('before')
await call(ctx, owner, { command: 'view', path: existing })
expect((await call(ctx, owner, {
command: 'str_replace',
path: existing,
old_str: 'before',
new_str: 'after',
})).isError).toBe(false)
expect(await readFile(existing, 'utf8')).toBe('after')
expect((await call(ctx, owner, {
command: 'create',
path: created,
file_text: 'new',
})).isError).toBe(false)
expect(await readFile(created, 'utf8')).toBe('new')
})
it('passes the session sandbox policy to every mutation', async () => {
const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' })
const path = join(root, 'blocked.txt')
const result = await call(ctx, owner, {
command: 'create',
path,
file_text: 'blocked',
})
expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
})
it('can preserve tabs outside the edited region', async () => {
const { ctx, root, owner } = await setup({ expandTabsOnMutation: false })
const path = join(root, 'Makefile')
await writeFile(path, 'target:\n\told\n')
await call(ctx, owner, {
command: 'str_replace',
path,
old_str: 'old',
new_str: 'new',
})
expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n')
})
it('rejects invalid plugin config', () => {
expect(() => {
ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 })

View File

@@ -9,6 +9,8 @@
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/tools" },
{ "path": "../fs" },
{ "path": "../../sandbox/sandbox" },
{ "path": "../../sandbox/sandbox-policy" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -16,7 +16,10 @@ const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this comma
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'
// 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.'
@@ -101,7 +104,7 @@ function commandOutput(
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0 || snapshot.truncated,
incomplete: startMarker < 0,
}
}
@@ -115,22 +118,29 @@ function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
fallback: string,
fallbackTruncated = false,
): 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,
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: stripPrompt(fallback),
incomplete: snapshot.truncated,
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
incomplete: fallbackTruncated || fallbackStart < 0,
}
}
async function pause(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
}
function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined {
@@ -142,11 +152,13 @@ function retainedScrollback(
ctx: Context,
owner: Agent,
id: PtySessionId,
latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
): RetainedOutput {
const pages: string[] = []
let offset = 0
let truncated = false
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.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
truncated ||= page.truncated
if (page.text.length > 0) pages.unshift(page.text)
@@ -167,7 +179,10 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
const pending = new WeakMap<Agent, Promise<PtySessionId>>()
const live = new Map<Agent, PtySessionId>()
const creating = new Set<Promise<PtySessionId>>()
const ownerCleanupInstalled = new WeakSet<Agent>()
const lifecycle = new AbortController()
let disposed = false
const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => {
if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
@@ -175,6 +190,9 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
}
ctx.effect(() => async () => {
disposed = true
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()
@@ -188,15 +206,17 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
}
const get = (owner: Agent, signal: AbortSignal): Promise<PtySessionId> => {
if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed'))
const existing = pending.get(owner)
if (existing !== undefined) return existing
const creating = (async () => {
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
const creation = (async () => {
try {
const cwd = owner.session.header.cwd
const spawned = await ctx.pty.spawn(owner, {
type: config.backendType,
...cwd === undefined ? {} : { cwd },
}, signal)
}, combinedSignal)
live.set(owner, spawned.sessionId)
if (!ownerCleanupInstalled.has(owner)) {
ownerCleanupInstalled.add(owner)
@@ -208,7 +228,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
const setup = ctx.pty.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
submit: true,
signal,
signal: combinedSignal,
})
const result = await setup.done
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
@@ -220,8 +240,12 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
throw error
}
})()
pending.set(owner, creating)
return creating
const tracked = creation.finally(() => {
creating.delete(tracked)
})
creating.add(tracked)
pending.set(owner, tracked)
return tracked
}
return { get, reset }
@@ -241,21 +265,32 @@ async function executeCommand(
const wrapped = wrapCommand(command, marker)
let first = true
let fallback = ''
let fallbackTruncated = false
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)
let operation
let result
try {
operation = ctx.pty.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.pty.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),
partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
await shells.reset(owner, 'persistent bash command timed out')
@@ -265,12 +300,15 @@ async function executeCommand(
SHELL_RESET_MESSAGE,
].join('\n')
}
const complete = commandOutput(snapshot, marker)
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
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') {
const snapshot = retainedScrollback(ctx, owner, id, latest)
await shells.reset(owner, 'persistent bash shell exited')
return [
renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars),
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
@@ -279,7 +317,11 @@ async function executeCommand(
commandDeadline.signal.throwIfAborted()
}
if (promptCompleted(result)) {
return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated)
const snapshot = retainedScrollback(ctx, owner, id, latest)
return renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
}
await pause()
}

View File

@@ -85,6 +85,8 @@ type StubMode =
| 'init-exit'
| 'init-timeout'
| 'spawn-error'
| 'send-error'
| 'prompt-after-idle'
class StubPtySession implements PtyBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
@@ -95,6 +97,7 @@ class StubPtySession implements PtyBackendSession {
mode: StubMode
sends = 0
pendingText = ''
historyTruncated = false
constructor(mode: StubMode) {
this.mode = mode
@@ -112,6 +115,7 @@ class StubPtySession implements PtyBackendSession {
}
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
}
if (this.mode === 'send-error') throw new Error('stub send failed')
if (this.mode === 'wait-for-abort') {
const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
request.signal?.addEventListener('abort', () => {
@@ -126,6 +130,17 @@ class StubPtySession implements PtyBackendSession {
this.pendingText = request.text
return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
}
if (this.mode === 'prompt-after-idle') {
if (request.text.length > 0) {
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
const output = `${start ?? ''}\npartial syntax output\n`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
}
const output = `bash: syntax error\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
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}`
@@ -166,7 +181,7 @@ class StubPtySession implements PtyBackendSession {
totalLines: lines.length,
lineBegin: 0,
lineEnd: lines.length,
truncated: false,
truncated: this.historyTruncated,
}
}
@@ -316,6 +331,29 @@ describe('tool-bash-persistent', () => {
expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', 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 = 'prompt-after-idle'
session.scrollback = ''
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('bash: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
})
it('does not attribute old scrollback truncation to a complete current command', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.historyTruncated = true
const result = text(await call(ctx, owner, 'short command'))
expect(result).toBe('hello from stub')
expect(result).not.toContain('<response clipped>')
expect(result).not.toContain('beginning of this command output was dropped')
})
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')
@@ -359,6 +397,48 @@ describe('tool-bash-persistent', () => {
expect(stub.sessions).toHaveLength(0)
})
it('resets a cached shell after startSend fails', async () => {
const { ctx, owner, stub } = await setup()
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'send-error'
expect((await call(ctx, owner, 'fails')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent bash send failed')
expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
})
it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
const spawnStarted = Promise.withResolvers<undefined>()
const spawnAborted = Promise.withResolvers<undefined>()
ctx.pty.registerBackend({
type: 'slow',
spawn: spec => new Promise((_resolve, reject) => {
spawnStarted.resolve(undefined)
spec.signal?.addEventListener('abort', () => {
spawnAborted.resolve(undefined)
const reason: unknown = spec.signal?.reason
reject(reason instanceof Error
? reason
: new Error('slow PTY spawn aborted', { cause: reason }))
}, { once: true })
}),
})
const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' })
const owner = agent(ctx, '/workspace')
const running = call(ctx, owner, 'pwd')
await spawnStarted.promise
await fiber.dispose()
await spawnAborted.promise
expect((await running).isError).toBe(true)
expect(ctx.pty.list(owner)).toEqual([])
})
it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)