feat(tools): add persistent bash and str-replace editor
This commit is contained in:
6
packages/fs/tool-str-replace-editor/README.i18n.yaml
Normal file
6
packages/fs/tool-str-replace-editor/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/fs/tool-str-replace-editor/README.md
|
||||
README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b
|
||||
README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d
|
||||
54
packages/fs/tool-str-replace-editor/README.md
Normal file
54
packages/fs/tool-str-replace-editor/README.md
Normal 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.
|
||||
54
packages/fs/tool-str-replace-editor/README.zh.md
Normal file
54
packages/fs/tool-str-replace-editor/README.zh.md
Normal 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` 参数。
|
||||
- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。
|
||||
- 安全与先读后改策略委托给挂载的文件系统和策略插件。
|
||||
48
packages/fs/tool-str-replace-editor/package.json
Normal file
48
packages/fs/tool-str-replace-editor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
403
packages/fs/tool-str-replace-editor/src/index.ts
Normal file
403
packages/fs/tool-str-replace-editor/src/index.ts
Normal 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)
|
||||
}
|
||||
30
packages/fs/tool-str-replace-editor/src/invariant.ts
Normal file
30
packages/fs/tool-str-replace-editor/src/invariant.ts
Normal 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 */
|
||||
313
packages/fs/tool-str-replace-editor/tests/tools.spec.ts
Normal file
313
packages/fs/tool-str-replace-editor/tests/tools.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
14
packages/fs/tool-str-replace-editor/tsconfig.json
Normal file
14
packages/fs/tool-str-replace-editor/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user