Merge remote-tracking branch 'origin/master' into feat/read-presenter

# Conflicts:
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
Chinesezjc
2026-07-30 19:05:32 +08:00
447 changed files with 10444 additions and 4264 deletions

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/README.md
README.md: 4d954455ea920be4882530bcfe90b48a364c29b5
README.zh.md: ed154cbbaf7c7f45c42c17799957254fee918153
README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07
README.zh.md: ee64a617aa0d9549bcaf00b20821a6d59c6673c8

View File

@@ -12,6 +12,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
| `tool-str-replace-editor/` | Model-facing `str_replace_editor` with view/create/unique literal replace/line insert operations over `ctx.fs` | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).

View File

@@ -12,6 +12,7 @@
| `fs-policy/` | 策略门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) |
| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升权字段 | (注册到 `ctx.tools` |
| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools` |
| `tool-str-replace-editor/` | 基于 `ctx.fs` 提供查看/创建/唯一字面量替换/按行插入的模型可见 `str_replace_editor` | (注册到 `ctx.tools` |
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、策略门禁或面向模型的工具 schema`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。策略(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它只会使策略失效,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam搜索是在 bash 执行器上运行 `rg`、基于进程的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,其结果便可供后续读取,这也是其 README 所述的共置部署。

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: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f
README.zh.md: 48358eb3c9d81ddad6a83c4ff3ef0cf6542096b1

View File

@@ -0,0 +1,52 @@
# @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. |
## Tool
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; 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. Mutations preserve tabs outside the requested edit.
## 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. 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
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.
- 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

@@ -0,0 +1,52 @@
# @deepseek-ai/dsh-tool-str-replace-editor
[English](README.md) | 中文
基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
## 工具
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 不变时前缀稳定。
### 工具结果
#### 模型所见
查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
#### Token 影响
随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
#### KV Cache 影响
工具结果以追加方式位于可复用请求前缀之后。
## 已知限制与延后工作
- 操作面向 UTF-8 文本,不支持二进制文件。
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
- 每个修改操作都会经过 `fs/write-intent``fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。

View File

@@ -0,0 +1,54 @@
{
"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-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"
},
"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-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:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,522 @@
/**
* 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, 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 { 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>'
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 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,
signal: AbortSignal,
): Promise<FsTarget> {
if (path.trim().length === 0) throw new Error('path must be a non-empty string')
if (!isAbsolute(path)) {
throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
}
return ctx.fs.resolve(path, { 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 = lines
.map((line, index) => `${String(initialLine + index).padStart(6, ' ')} ${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 !== '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) {
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 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`
}
async function viewPath(
ctx: Context,
path: string,
viewRange: number[] | undefined,
maxOutputChars: number,
exec: ToolRunContext,
): Promise<string> {
const target = await resolveTarget(ctx, path, exec.signal)
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,
policy: MutationPolicy,
path: string,
fileText: string | undefined,
exec: ToolRunContext,
): Promise<string> {
const content = requiredForCommand(fileText, 'file_text', 'create')
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, exec.signal)
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 intent = await ctx.waterfall(
'fs/write-intent',
target,
exec,
() => ({ kind: 'createIfAbsent' } as const),
)
let outcome
try {
outcome = await ctx.fs.writeText(
target,
content,
intent,
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,
exec: ToolRunContext,
): Promise<string> {
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, exec.signal)
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false)
const newValue = 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 = await ctx.fs.readText(target, exec.signal)
const offsets = matchOffsets(before, oldValue)
const offset = offsets[0]
if (offset === undefined) {
throw new FsError(
`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
'FS_EDIT_NOT_FOUND',
)
}
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',
)
}
let outcome
try {
outcome = await ctx.fs.writeText(
target,
before.slice(0, offset) + newValue + before.slice(offset + oldValue.length),
intent === undefined
? { kind: 'replaceIfVersion', version: info.version }
: { kind: 'replaceIfVersion', version: intent.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,
exec: ToolRunContext,
): Promise<string> {
if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
const value = requiredForCommand(newStr, 'new_str', 'insert')
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, exec.signal)
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 = 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 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.`
}
interface ResolvedConfig {
maxOutputChars: number
description: string
}
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,
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, exec)
case 'create':
return createFile(ctx, policy, args.path, args.file_text, exec)
case 'str_replace':
return replaceInFile(
ctx,
policy,
args.path,
args.old_str,
args.new_str,
exec,
)
case 'insert':
return insertInFile(
ctx,
policy,
args.path,
args.insert_line,
args.new_str,
exec,
)
}
},
presentCall: presentEditorCall,
}))
}
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
}
/** 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),
})
/** 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,
}
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,547 @@
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 * 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'
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: () => {},
updateInbox: () => 'not-found',
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 = {},
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()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
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)
const fiber = await ctx.plugin(ToolStrReplaceEditor, config)
return { ctx, root, fiber, owner: agent(ctx, root) }
}
describe('tool-str-replace-editor', () => {
it('registers the standalone schema and configurable description', async () => {
const { ctx, fiber } = 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',
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',
locations: [{ path: '/workspace/a.txt', line: 1 }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'create',
path: '/workspace/empty.txt',
})).toMatchObject({
diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'str_replace',
path: '/workspace/a.txt',
})).toMatchObject({
diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'insert',
path: '/workspace/a.txt',
})).toMatchObject({
locations: [{ path: '/workspace/a.txt' }],
})
await fiber.dispose()
expect(ctx.tools.schemas()).toEqual([])
expect(ctx.tools.get('str_replace_editor')).toBeUndefined()
})
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('writes replacement text literally', async () => {
const { ctx, root, owner } = await setup()
const sample = join(root, 'literal.txt')
const replacement = "$&|$`|$'|$$"
await writeFile(sample, 'before OLD after')
expect((await call(ctx, owner, {
command: 'str_replace',
path: sample,
old_str: 'OLD',
new_str: replacement,
})).isError).toBe(false)
expect(await readFile(sample, 'utf8')).toBe(`before ${replacement} after`)
})
it('lists visible entries to depth two and clips at the configured view limit', async () => {
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) => {
const entries = await listDir(target, signal)
return target.displayPath === join(root, 'dir')
? [
{ name: 'same-target', type: 'other', target: otherTarget },
{ name: 'other', type: 'other', target: otherTarget },
...entries.toReversed(),
]
: entries
}
const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
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')
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>')
})
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')
expect((await call(ctx, undefined, {
command: 'create',
path: join(root, 'ownerless.txt'),
file_text: 'ownerless',
})).isError).toBe(false)
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')
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 mixedEol = join(root, 'mixed-eol.txt')
await writeFile(mixedEol, 'alpha\r\nbeta\nmiddle\nalpha\nbeta')
expect((await call(ctx, owner, {
command: 'str_replace',
path: mixedEol,
old_str: 'alpha\r\nbeta',
new_str: 'replaced',
})).isError).toBe(false)
expect(await readFile(mixedEol, 'utf8')).toBe('replaced\nmiddle\nalpha\nbeta')
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('alpha\nbeta\nmiddle\nalpha\nbeta')
})
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('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: 'insert',
path: existing,
insert_line: 1,
new_str: 'tail',
})).isError).toBe(false)
expect(await readFile(existing, 'utf8')).toBe('after\ntail')
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]')
const ownerless = await call(ctx, undefined, {
command: 'create',
path: join(root, 'ownerless-blocked.txt'),
file_text: 'blocked',
})
expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
})
it('preserves tabs outside the edited region', async () => {
const { ctx, root, owner } = await setup()
const path = join(root, 'Makefile')
await writeFile(path, 'target:\n\told\nremove\n')
expect(text(await call(ctx, owner, { command: 'view', path })))
.toContain(' 2 \told')
await call(ctx, owner, {
command: 'str_replace',
path,
old_str: '\told',
new_str: '\tnew',
})
await call(ctx, owner, {
command: 'str_replace',
path,
old_str: 'remove\n',
})
await call(ctx, owner, {
command: 'insert',
path,
insert_line: 1,
new_str: '\tkept',
})
expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n')
})
it('reports missing sandbox-policy composition during plugin startup', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-'))
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 })
Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' })
await expect(ctx.plugin(ToolStrReplaceEditor))
.rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing')
})
it('maps unexpected backend write failures for replace and insert', async () => {
const { ctx, root, owner } = await setup()
const path = join(root, 'backend-error.txt')
await writeFile(path, 'old\n')
const failWrite = async (): Promise<never> => {
throw new Error('backend write failed')
}
ctx.fs.writeText = failWrite
const replace = await call(ctx, owner, {
command: 'str_replace',
path,
old_str: 'old',
new_str: 'new',
})
expect(replace.isError).toBe(true)
expect(text(replace)).toContain('backend write failed')
const insert = await call(ctx, owner, {
command: 'insert',
path,
insert_line: 1,
new_str: 'new',
})
expect(insert.isError).toBe(true)
expect(text(insert)).toContain('backend write failed')
})
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,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/tools" },
{ "path": "../fs" },
{ "path": "../../sandbox/sandbox" },
{ "path": "../../sandbox/sandbox-policy" },
{ "path": "../../support/invariants" }
]
}