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/fs/tool-str-replace-editor/README.md
README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b
README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d

View File

@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-tool-str-replace-editor
English | [中文](README.zh.md)
Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface.
## Config
| Key | Default | Meaning |
|---|---:|---|
| `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. |
## Tool
The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline.
## Model Experience
### Tool schema
#### What the model sees
The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section.
#### Token effect
Fixed schema cost while `str_replace_editor` is visible.
#### KV Cache effect
Prefix-stable while the configured description and schema remain unchanged.
### Tool results
#### 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.
#### 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
- 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.

View File

@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-tool-str-replace-editor
[English](README.md) | 中文
基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 |
## 工具
Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。
## 模型体验
### 工具 schema
#### 模型所见
生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。
#### Token 影响
`str_replace_editor` 可见时产生固定的 schema 成本。
#### KV Cache 影响
配置的描述与 schema 不变时前缀稳定。
### 工具结果
#### 模型所见
查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
#### Token 影响
随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
#### KV Cache 影响
工具结果以追加方式位于可复用请求前缀之后。
## 已知限制与延后工作
- 操作面向 UTF-8 文本,不支持二进制文件。
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。
- 安全与先读后改策略委托给挂载的文件系统和策略插件。

View File

@@ -0,0 +1,48 @@
{
"name": "@deepseek-ai/dsh-tool-str-replace-editor",
"description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem 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-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,403 @@
/**
* Model-facing `str_replace_editor` over the Harness filesystem seam.
* @module @deepseek-ai/dsh-tool-str-replace-editor
*/
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 { defineTool } from '@deepseek-ai/dsh-tools'
import type { 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>'
const DEFAULT_DESCRIPTION = `
Custom editing tool for viewing, creating and editing files
* State is persistent across command calls and discussions with the user
* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep
* The \`create\` command cannot be used if the specified \`path\` already exists as a file
* If a \`command\` generates a long output, it will be truncated and marked with \`<response clipped>\`
Notes for using the \`str_replace\` command:
* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique
* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\`
`.trim()
function maybeTruncate(content: string, maxOutputChars: number): string {
return content.length <= maxOutputChars
? content
: content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
}
function expandTabs(content: string, tabSize = 8): string {
let column = 0
let result = ''
for (const character of content) {
if (character === '\t') {
const spaces = tabSize - (column % tabSize)
result += ' '.repeat(spaces)
column += spaces
continue
}
result += character
if (character === '\n' || character === '\r') column = 0
else column += 1
}
return result
}
async function resolveTarget(
ctx: Context,
path: string,
requireAbsolutePath: boolean,
exec: ToolRunContext,
): 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
return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal })
}
async function statExisting(
ctx: Context,
target: FsTarget,
command: 'view' | 'str_replace' | 'insert',
exec: ToolRunContext,
): Promise<FsInfo> {
const info = await ctx.fs.stat(target, exec.signal)
if (info === undefined) {
throw new FsError(
`The path ${target.displayPath} does not exist. Please provide a valid path.`,
'FS_NOT_FOUND',
)
}
if (info.type === 'directory' && command !== 'view') {
throw new FsError(
`The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`,
'FS_NOT_REGULAR_FILE',
)
}
return info
}
function requiredForCommand(
value: string | undefined,
parameter: string,
command: string,
allowEmpty = true,
): string {
if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`)
if (!allowEmpty && value.length === 0) {
throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`)
}
return value
}
function formatFileView(
path: string,
content: string,
maxOutputChars: number,
viewRange?: number[],
): string {
const allLines = content.split('\n')
let lines = allLines
let initialLine = 1
let finalLine: number | undefined
let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)`
if (viewRange !== undefined) {
const [requestedInitialLine, requestedFinalLine] = viewRange
if (
viewRange.length !== 2
|| requestedInitialLine === undefined
|| requestedFinalLine === undefined
|| !viewRange.every(Number.isInteger)
) {
throw new Error('Invalid `view_range`. It should be a list of two integers.')
}
initialLine = requestedInitialLine
finalLine = requestedFinalLine
if (initialLine < 1 || initialLine > allLines.length) {
throw new Error(
`Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`,
)
}
if (finalLine > allLines.length) {
throw new Error(
`Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``,
)
}
if (finalLine !== -1 && finalLine < initialLine) {
throw new Error(
`Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``,
)
}
lines = finalLine === -1
? allLines.slice(initialLine - 1)
: allLines.slice(initialLine - 1, finalLine)
prompt += ` with view_range=[${initialLine}, ${finalLine}]`
}
const numbered = expandTabs(lines
.map((line, index) => `${String(initialLine + index).padStart(6, ' ')}\t${line}`)
.join('\n'))
return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars)
}
async function listDirectory(
ctx: Context,
target: FsTarget,
maxOutputChars: number,
exec: ToolRunContext,
): Promise<string> {
async function visit(dir: FsTarget, depth: number): Promise<string[]> {
const entries = await ctx.fs.listDir(dir, exec.signal)
const rows: string[] = []
for (const entry of entries.filter(candidate =>
!candidate.name.startsWith('.')
&& !candidate.name.startsWith('node_modules')
&& !candidate.name.startsWith('__pycache__'))) {
const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
rows.push(`${type}\t${entry.target.displayPath}`)
if (entry.type === 'directory' && depth < 2) {
rows.push(...await visit(entry.target, depth + 1))
}
}
return rows
}
const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)]
rows.sort((left, right) => {
const leftPath = left.slice(left.indexOf('\t') + 1)
const rightPath = right.slice(right.indexOf('\t') + 1)
return leftPath.localeCompare(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`
}
async function viewPath(
ctx: Context,
path: string,
viewRange: number[] | undefined,
maxOutputChars: number,
requireAbsolutePath: boolean,
exec: ToolRunContext,
): Promise<string> {
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
const info = await statExisting(ctx, target, 'view', exec)
if (info.type === 'directory') {
if (viewRange !== undefined) {
throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.')
}
return listDirectory(ctx, target, maxOutputChars, exec)
}
if (info.type !== 'file') {
throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE')
}
const content = await ctx.fs.readText(target, exec.signal)
ctx.emit('fs/observed', target, info.version, exec)
return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
}
async function createFile(
ctx: Context,
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)
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)
ctx.emit('fs/observed', target, outcome.version, exec)
return `New file created successfully at: ${target.displayPath}`
}
async function replaceInFile(
ctx: Context,
path: string,
oldStr: string | undefined,
newStr: string | undefined,
requireAbsolutePath: 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 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) {
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] : [])
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,
)
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
async function insertInFile(
ctx: Context,
path: string,
insertLine: number | undefined,
newStr: string | undefined,
requireAbsolutePath: 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 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 lines = before.split('\n')
if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) {
throw new Error(
`Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`,
)
}
const after = [
...lines.slice(0, insertLine),
...value.split('\n'),
...lines.slice(insertLine),
].join('\n')
const outcome = await ctx.fs.writeText(
target,
after,
{ kind: 'replaceIfVersion', version: info.version },
exec.signal,
)
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
interface ResolvedConfig {
maxOutputChars: number
description: string
requireAbsolutePath: boolean
}
/** Register the model-facing `str_replace_editor` tool. */
function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
ctx.tools.register(defineTool({
name: 'str_replace_editor',
description: config.description,
parameters: {
command: {
type: 'string',
required: true,
enum: ['view', 'create', 'str_replace', 'insert'],
description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.',
},
path: {
type: 'string',
required: true,
description: 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.',
},
file_text: {
type: 'string',
description: 'Required parameter of `create` command, with the content of the file to be created.',
},
insert_line: {
type: 'integer',
description: 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.',
},
new_str: {
type: 'string',
description: 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.',
},
old_str: {
type: 'string',
description: 'Required parameter of `str_replace` command containing the string in `path` to replace.',
},
view_range: {
type: 'array',
items: { type: 'integer' },
description: 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
switch (args.command) {
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)
case 'str_replace':
return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec)
case 'insert':
return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec)
}
},
presentCall: args => ({
card: 'generic',
title: `${args.command} ${args.path}`,
kind: args.command === 'view' ? 'read' : 'edit',
}),
}))
}
export const name = 'tool-str-replace-editor'
export const inject = ['tools', 'fs']
/** Configuration for the string-replacement editor tool. */
export interface Config {
/** Maximum returned view characters before clipping (default 16000). */
maxOutputChars?: number
/** Model-facing tool description. */
description?: string
/** Require local absolute paths like the canonical editor contract (default true). */
requireAbsolutePath?: boolean
}
/** Runtime configuration schema for the string-replacement editor tool. */
export const Config: z<Config> = z.object({
maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION),
requireAbsolutePath: z.boolean().default(true),
})
/** Register one `str_replace_editor` tool over `ctx.fs`. */
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = {
maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION,
requireAbsolutePath: config.requireAbsolutePath ?? true,
}
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer')
}
if (resolved.description.trim().length === 0) {
throw new Error('tool-str-replace-editor: description must be non-empty')
}
registerStrReplaceEditor(ctx, resolved)
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`.
* @module @deepseek-ai/dsh-tool-str-replace-editor/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor'
/** Cordis companion plugin name. */
export const name = 'tool-str-replace-editor-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;
* filesystem mutation relations stay with the provider and policy plugins.
*/
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,313 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FsVersion } from '@deepseek-ai/dsh-fs'
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 LocalFileSystem from '@deepseek-ai/dsh-fs-local'
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'
const contexts: Context[] = []
const roots: string[] = []
let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
function agent(ctx: Context, cwd: string): Agent {
const id = SessionId(`str-replace-editor-owner-${callNumber}`)
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('')
}
function call(ctx: Context, owner: Agent | undefined, args: unknown) {
return ctx.tools.execute({
signal: new AbortController().signal,
callId: CallId(`str-replace-editor-${++callNumber}`),
name: 'str_replace_editor',
arguments: args,
...owner === undefined ? {} : { agent: owner },
})
}
async function setup(config: ToolStrReplaceEditor.Config = {}) {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: root })
await ctx.plugin(ToolStrReplaceEditor, config)
return { ctx, root, owner: agent(ctx, root) }
}
describe('tool-str-replace-editor', () => {
it('registers the standalone schema and configurable description', async () => {
const { ctx } = await setup({ description: 'custom editor description' })
const schema = ctx.tools.schemas()[0]
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor'])
expect(schema?.description).toBe('custom editor description')
const properties = (schema?.parameters as {
properties: Record<string, { type?: string; items?: { type?: string } }>
}).properties
expect(properties).not.toHaveProperty('replace_all')
expect(properties.insert_line?.type).toBe('integer')
expect(properties.view_range?.items?.type).toBe('integer')
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'view',
path: '/workspace/a.txt',
})).toMatchObject({ card: 'generic', kind: 'read' })
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' })
})
it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
const { ctx, root, owner } = await setup()
const sample = join(root, 'sample.txt')
expect(text(await call(ctx, owner, {
command: 'create',
path: sample,
file_text: 'one\ntwo\nthree\n',
}))).toBe(`New file created successfully at: ${sample}`)
expect(text(await call(ctx, owner, {
command: 'view',
path: sample,
view_range: [2, -1],
}))).toBe([
`Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`,
' 2 two',
' 3 three',
' 4 ',
'',
].join('\n'))
expect(text(await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'two',
new_str: 'TWO',
}))).toBe(`The file ${sample} has been edited successfully.`)
expect(text(await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'TWO',
}))).toBe(`The file ${sample} has been edited successfully.`)
expect(text(await call(ctx, owner, {
command: 'insert',
path: sample,
insert_line: 1,
new_str: 'between',
}))).toBe(`The file ${sample} has been edited successfully.`)
expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
})
it('lists visible entries to depth two and clips at the configured view limit', async () => {
const { ctx, root, owner } = await setup({ maxOutputChars: 10 })
await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true })
await mkdir(join(root, 'dir', '__pycache__'), { 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', '__pycache__', 'module.pyc'), 'cache')
const listDir = ctx.fs.listDir.bind(ctx.fs)
const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
ctx.fs.listDir = async (target, signal) => {
const entries = await listDir(target, signal)
return target.displayPath === join(root, 'dir')
? [...entries, { name: 'other', type: 'other', target: otherTarget }]
: entries
}
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')
await writeFile(join(root, 'large.txt'), 'x'.repeat(100))
expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') })))
.toContain('<response clipped>')
})
it('matches canonical empty-line, range, and end-insert behavior', async () => {
const { ctx, root, owner } = await setup()
const empty = join(root, 'empty.txt')
const newline = join(root, 'newline.txt')
const plain = join(root, 'plain.txt')
await writeFile(empty, '')
await writeFile(newline, '\n')
await writeFile(plain, 'one\ntwo')
expect(text(await call(ctx, owner, { command: 'view', path: empty })))
.toContain('(which has a total of 1 lines):\n 1 \n')
expect(text(await call(ctx, owner, { command: 'view', path: newline })))
.toContain('(which has a total of 2 lines):\n 1 \n 2 \n')
expect(text(await call(ctx, owner, {
command: 'view',
path: plain,
view_range: [1, 2],
}))).toContain(' 2 two')
expect(text(await call(ctx, undefined, {
command: 'view',
path: plain,
}))).toContain(' 1 one')
await call(ctx, owner, {
command: 'insert',
path: plain,
insert_line: 2,
new_str: 'three',
})
expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree')
await writeFile(newline, 'one\n')
await call(ctx, owner, {
command: 'insert',
path: newline,
insert_line: 2,
new_str: 'three',
})
expect(await readFile(newline, 'utf8')).toBe('one\n\nthree')
})
it('uses old_str-only replacement failures and rejects relative paths', async () => {
const { ctx, root, owner } = await setup()
const ambiguous = join(root, 'ambiguous.txt')
await writeFile(ambiguous, 'same\nother\nsame')
const missing = await call(ctx, owner, {
command: 'str_replace',
path: ambiguous,
old_str: 'absent',
new_str: 'x',
})
expect(missing.isError).toBe(true)
expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`)
expect(text(missing)).not.toContain('old_string')
const repeated = await call(ctx, owner, {
command: 'str_replace',
path: ambiguous,
old_str: 'same',
new_str: 'x',
})
expect(repeated.isError).toBe(true)
expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]')
expect(text(repeated)).not.toContain('replace_all')
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')
})
it('reports invalid commands or arguments without mutating files', async () => {
const { ctx, root, owner } = await setup()
const ambiguous = join(root, 'ambiguous.txt')
const empty = join(root, 'empty.txt')
const trailingNewline = join(root, 'trailing-newline.txt')
const threeLines = join(root, 'three-lines.txt')
const directory = join(root, 'directory')
await writeFile(ambiguous, 'same same')
await writeFile(empty, '')
await writeFile(trailingNewline, 'one\n')
await writeFile(threeLines, 'one\ntwo\nthree')
await mkdir(directory)
const cases = [
{ command: 'view', path: '' },
{ command: 'view', path: join(root, 'missing.txt') },
{ command: 'view', path: ambiguous, view_range: [1] },
{ command: 'view', path: ambiguous, view_range: [0, 1] },
{ command: 'view', path: ambiguous, view_range: [1.5, 2] },
{ command: 'view', path: threeLines, view_range: [1, 99] },
{ command: 'view', path: threeLines, view_range: [2, 1] },
{ command: 'view', path: directory, view_range: [1, 1] },
{ command: 'create', path: join(root, 'new.txt') },
{ command: 'create', path: ambiguous, file_text: 'overwrite' },
{ command: 'str_replace', path: ambiguous, new_str: 'x' },
{ command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' },
{ command: 'insert', path: ambiguous, new_str: 'x' },
{ command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' },
{ command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' },
{ command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' },
{ command: 'insert', path: empty, insert_line: 2, new_str: 'x' },
{ command: 'insert', path: directory, insert_line: 0, new_str: 'x' },
]
for (const args of cases) {
expect((await call(ctx, owner, args)).isError).toBe(true)
}
expect(await readFile(ambiguous, 'utf8')).toBe('same same')
ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' })
const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') })
expect(special.isError).toBe(true)
expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
expect((await call(ctx, owner, {
command: 'str_replace',
path: join(root, 'special'),
old_str: 'x',
new_str: 'y',
})).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
expect((await call(ctx, owner, {
command: 'insert',
path: join(root, 'special'),
insert_line: 0,
new_str: 'x',
})).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
})
it('can opt into session-relative paths for non-canonical deployments', async () => {
const { ctx, root, owner } = await setup({ requireAbsolutePath: false })
await writeFile(join(root, 'relative.txt'), 'relative')
expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' })))
.toContain("Here's the content of")
})
it('rejects invalid plugin config', () => {
expect(() => {
ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 })
}).toThrow('maxOutputChars must be a positive safe integer')
expect(() => {
ToolStrReplaceEditor.apply(new Context(), { description: ' ' })
}).toThrow('description must be non-empty')
})
})

View File

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