Add filesystem capability seam and tools
This commit is contained in:
33
packages/fs/tool-fs/README.md
Normal file
33
packages/fs/tool-fs/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-tool-fs
|
||||
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the consumer third of the filesystem capability; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import).
|
||||
|
||||
```ts ignore-check
|
||||
// Load a ctx.fs provider first, then the tools.
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
```
|
||||
|
||||
Each tool also ships as a subpath plugin for focused deployments:
|
||||
|
||||
```ts ignore-check
|
||||
import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read'
|
||||
import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write'
|
||||
import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit'
|
||||
```
|
||||
|
||||
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` (the backend enforces it); creating a new file does not. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read`. |
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
## How the read-before-write policy is enforced
|
||||
|
||||
The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then calls `ctx.fs.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fs` derives the file-state owner (normally the agent session) from that context and owns the prior-observation and stale-version policy. Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
|
||||
|
||||
Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`.
|
||||
51
packages/fs/tool-fs/package.json
Normal file
51
packages/fs/tool-fs/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs",
|
||||
"description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./read": {
|
||||
"types": "./lib/read.d.ts",
|
||||
"default": "./lib/read.js"
|
||||
},
|
||||
"./write": {
|
||||
"types": "./lib/write.d.ts",
|
||||
"default": "./lib/write.js"
|
||||
},
|
||||
"./edit": {
|
||||
"types": "./lib/edit.d.ts",
|
||||
"default": "./lib/edit.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "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.6"
|
||||
}
|
||||
}
|
||||
82
packages/fs/tool-fs/src/edit.ts
Normal file
82
packages/fs/tool-fs/src/edit.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
|
||||
* literal text, requiring a unique match by default. Execution goes through
|
||||
* `ctx.fs`, which enforces prior observation and the stale-version guard and
|
||||
* owns the literal-match semantics.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/edit
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
interface EditInput {
|
||||
filePath: string
|
||||
oldString: string
|
||||
newString: string
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string')
|
||||
if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ')
|
||||
return {
|
||||
filePath: args.file_path,
|
||||
oldString: args.old_string,
|
||||
newString: args.new_string,
|
||||
replaceAll: args.replace_all ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an edit outcome as a Claude-style model-facing success message. */
|
||||
export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string {
|
||||
return outcome.replaceAll
|
||||
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
|
||||
: `The file ${displayPath} has been updated successfully.`
|
||||
}
|
||||
|
||||
/** Register the `edit` tool and its system-prompt guidance. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:edit',
|
||||
order: 102,
|
||||
text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'edit',
|
||||
description: 'Edit an existing UTF-8 text file by replacing literal text.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' },
|
||||
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
|
||||
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseEditArgs(args)
|
||||
const target = await ctx.fs.resolve(input.filePath)
|
||||
const outcome = await ctx.fs.edit(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
exec,
|
||||
exec.signal,
|
||||
)
|
||||
return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'fs-edit'
|
||||
|
||||
/** Services required by the `edit` tool plugin. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Named helper for direct registration in the root plugin and tests. */
|
||||
export const applyEditTool = apply
|
||||
35
packages/fs/tool-fs/src/index.ts
Normal file
35
packages/fs/tool-fs/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
|
||||
* `ctx.fs` seam. This root plugin registers all three tools by composing the
|
||||
* per-tool registration helpers; each tool is also exposed as a subpath plugin
|
||||
* (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused deployments.
|
||||
*
|
||||
* The package owns model-facing concerns only — tool names, JSON schemas,
|
||||
* argument validation, prompt sections, result formatting. All filesystem
|
||||
* execution goes through `ctx.fs`; this package never imports `node:fs`,
|
||||
* `node:path`, or an `@deepseek-ai/dsh-fs-local` implementation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { applyReadTool } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
|
||||
export { READ_LIMIT, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts'
|
||||
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
|
||||
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs'
|
||||
|
||||
/** Services required by the filesystem tool suite. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
|
||||
export function apply(ctx: Context): void {
|
||||
applyReadTool(ctx)
|
||||
applyWriteTool(ctx)
|
||||
applyEditTool(ctx)
|
||||
}
|
||||
95
packages/fs/tool-fs/src/read.ts
Normal file
95
packages/fs/tool-fs/src/read.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The model-facing `read` tool: inspect a UTF-8 text file and return
|
||||
* line-numbered content with pagination guidance. Execution goes through
|
||||
* `ctx.fs` — this module owns only the model-facing schema, argument
|
||||
* validation, and result formatting, never filesystem I/O.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/read
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsReadOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
export const READ_LIMIT = 2000
|
||||
|
||||
/** Validated `read` arguments after defaulting. */
|
||||
interface ReadInput {
|
||||
filePath: string
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
|
||||
const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit')
|
||||
if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`)
|
||||
return { filePath: args.file_path, offset, limit }
|
||||
}
|
||||
|
||||
/** Format a read outcome as one OpenCode-style line-numbered text block body. */
|
||||
export function formatReadOutput(displayPath: string, outcome: FsReadOutcome): string {
|
||||
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
|
||||
let footer: string
|
||||
if (outcome.truncatedByBytes) {
|
||||
footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
|
||||
} else if (endLine < outcome.totalLines) {
|
||||
footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
|
||||
} else {
|
||||
footer = `(End of file - total ${outcome.totalLines} lines)`
|
||||
}
|
||||
const body = outcome.lines.length > 0
|
||||
? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${body}
|
||||
</content>`
|
||||
}
|
||||
|
||||
/** Register the `read` tool and its system-prompt guidance. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
order: 100,
|
||||
text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read',
|
||||
description: 'Read a UTF-8 text file and return line-numbered content.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
|
||||
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args)
|
||||
const target = await ctx.fs.resolve(input.filePath)
|
||||
const outcome = await ctx.fs.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal)
|
||||
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'fs-read'
|
||||
|
||||
/** Services required by the `read` tool plugin. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Named helper for direct registration in the root plugin and tests. */
|
||||
export const applyReadTool = apply
|
||||
63
packages/fs/tool-fs/src/write.ts
Normal file
63
packages/fs/tool-fs/src/write.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The model-facing `write` tool: create or fully replace a UTF-8 text file.
|
||||
* Execution goes through `ctx.fs`, which enforces the read-before-overwrite
|
||||
* policy (updating an existing file requires a prior read in the same
|
||||
* execution context; creating a new file does not).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/write
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
return { filePath: args.file_path, content: args.content }
|
||||
}
|
||||
|
||||
/** Format a write outcome as one model-facing text block body. */
|
||||
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
|
||||
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${verb} file
|
||||
</content>`
|
||||
}
|
||||
|
||||
/** Register the `write` tool and its system-prompt guidance. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:write',
|
||||
order: 101,
|
||||
text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the backend requires it) and prefer edit for targeted changes.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'write',
|
||||
description: 'Create or fully replace a UTF-8 text file.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
|
||||
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseWriteArgs(args)
|
||||
const target = await ctx.fs.resolve(input.filePath)
|
||||
const outcome = await ctx.fs.write(target, input.content, exec, exec.signal)
|
||||
return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'fs-write'
|
||||
|
||||
/** Services required by the `write` tool plugin. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Named helper for direct registration in the root plugin and tests. */
|
||||
export const applyWriteTool = apply
|
||||
143
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
143
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Integration tests: the real local backend (`dsh-fs-local`) plus the model
|
||||
* tools (`dsh-tool-fs`), exercised through `ctx.tools.execute()` so nothing
|
||||
* bypasses the tool registry. These verify the WORLD — files are read back from
|
||||
* disk and asserted byte-for-byte — not the tool's self-report.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
// A stable session object stands in for an agent session (the file-state owner).
|
||||
const session = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: { session } as never,
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('write → disk', () => {
|
||||
it('creates a file with exactly the requested bytes', async () => {
|
||||
const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
|
||||
})
|
||||
|
||||
it('rejects overwriting an existing file without reading it first', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
// The world is unchanged.
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('allows overwriting after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read', () => {
|
||||
it('returns line-numbered content', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
||||
const result = await call('read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
expect(text(result)).toContain('2: beta')
|
||||
expect(text(result)).toContain('(End of file - total 2 lines)')
|
||||
})
|
||||
|
||||
it('reports a binary file as an error', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
|
||||
const result = await call('read', { file_path: 'bin' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit → disk', () => {
|
||||
it('applies a unique literal replacement after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('rejects an edit before any read, leaving the file untouched', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('rejects an edit after only a partial read, leaving the file untouched', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello\nworld')
|
||||
await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello\nworld')
|
||||
})
|
||||
|
||||
it('rejects an ambiguous match without replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
|
||||
})
|
||||
|
||||
it('replaces all matches with replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
it('supports a full write→edit cycle without an intervening read', async () => {
|
||||
await call('write', { file_path: 'a.txt', content: 'one two' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
|
||||
})
|
||||
})
|
||||
74
packages/fs/tool-fs/tests/subpaths.spec.ts
Normal file
74
packages/fs/tool-fs/tests/subpaths.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`,
|
||||
* `/write`, `/edit`): each registers exactly one tool, injects the same
|
||||
* services, and cleans up on disposal.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsReadOutcome,
|
||||
FsTarget,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read'
|
||||
import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write'
|
||||
import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit'
|
||||
|
||||
class StubFs extends FileSystem {
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: path, displayPath: path }
|
||||
}
|
||||
override async readPage(): Promise<FsReadOutcome> {
|
||||
return { offset: 1, limit: 1, lines: [], totalLines: 0, version: 'v', view: 'full' }
|
||||
}
|
||||
override async createOrReplace(): Promise<FsWriteOutcome> {
|
||||
return { operation: 'create', version: 'v' }
|
||||
}
|
||||
override async applyEdit(): Promise<FsEditOutcome> {
|
||||
return { replacements: 1, replaceAll: false, version: 'v' }
|
||||
}
|
||||
}
|
||||
|
||||
async function base() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubFs)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('subpath plugins', () => {
|
||||
it('each registers exactly its one tool', async () => {
|
||||
const cases: Array<[unknown, string]> = [
|
||||
[readPlugin, 'read'],
|
||||
[writePlugin, 'write'],
|
||||
[editPlugin, 'edit'],
|
||||
]
|
||||
for (const [plugin, toolName] of cases) {
|
||||
const ctx = await base()
|
||||
await ctx.plugin(plugin as Parameters<Context['plugin']>[0])
|
||||
expect(ctx.tools.schemas().map(s => s.name)).toEqual([toolName])
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans up on disposal (HMR safety)', async () => {
|
||||
const ctx = await base()
|
||||
const fiber = await ctx.plugin(readPlugin as Parameters<Context['plugin']>[0])
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stays pending without a ctx.fs provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(writePlugin as Parameters<Context['plugin']>[0])
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
270
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
270
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Consumer-surface tests for the filesystem tools using a fake `ctx.fs` that
|
||||
* records the execution context it received and returns canned outcomes. These
|
||||
* verify schemas, argument validation, result formatting, FsError→isError
|
||||
* propagation, and that each tool passes `exec` straight through to `ctx.fs`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem, FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsExecContext,
|
||||
FsReadOutcome,
|
||||
FsReadRequest,
|
||||
FsTarget,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
/**
|
||||
* Records the public-API calls (and the exec each received) and returns canned
|
||||
* outcomes; lets a test arm a rejection. Overrides the public methods directly
|
||||
* (not the primitives) so we observe exactly what the tool passed.
|
||||
*/
|
||||
class FakeFs extends FileSystem {
|
||||
calls: Array<{ op: string; exec: FsExecContext | undefined; target: FsTarget }> = []
|
||||
rejectWith?: FsError
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: `key:${path}`, displayPath: `/abs/${path}` }
|
||||
}
|
||||
|
||||
override async readPage(): Promise<FsReadOutcome> {
|
||||
throw new Error('not used: tool tests override read()')
|
||||
}
|
||||
override async createOrReplace(): Promise<FsWriteOutcome> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
override async applyEdit(): Promise<FsEditOutcome> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
|
||||
override async read(target: FsTarget, _request: FsReadRequest, exec?: FsExecContext): Promise<FsReadOutcome> {
|
||||
this.calls.push({ op: 'read', exec, target })
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
return {
|
||||
offset: 1,
|
||||
limit: 2000,
|
||||
lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }],
|
||||
totalLines: 2,
|
||||
version: 'v1',
|
||||
view: 'full',
|
||||
}
|
||||
}
|
||||
|
||||
override async write(target: FsTarget, _content: string, exec?: FsExecContext): Promise<FsWriteOutcome> {
|
||||
this.calls.push({ op: 'write', exec, target })
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
return { operation: 'create', version: 'v1' }
|
||||
}
|
||||
|
||||
override async edit(target: FsTarget, _edit: FsEditRequest, exec?: FsExecContext): Promise<FsEditOutcome> {
|
||||
this.calls.push({ op: 'edit', exec, target })
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
return { replacements: 1, replaceAll: false, version: 'v1' }
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(ToolFs)
|
||||
const fs = ctx.fs as FakeFs
|
||||
return { ctx, fs }
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: object) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent ? { agent: agent as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers read, write, and edit', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
|
||||
})
|
||||
|
||||
it('registers prompt sections for each tool', async () => {
|
||||
const { ctx } = await setup()
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the read tool')
|
||||
expect(prompt).toContain('Use the write tool')
|
||||
expect(prompt).toContain('Use the edit tool')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.fs exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFs) // no fs provider
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
const fiber = await ctx.plugin(ToolFs)
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read tool', () => {
|
||||
it('formats line-numbered content with a footer', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`<path>/abs/a.txt</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: hello
|
||||
2: world
|
||||
|
||||
(End of file - total 2 lines)
|
||||
</content>`)
|
||||
})
|
||||
|
||||
it('rejects a non-positive offset via arg validation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('offset must be a positive integer')
|
||||
})
|
||||
|
||||
it('rejects a limit above the cap', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('less than or equal to 2000')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: ' ' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('passes the execution context through to ctx.fs', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = {}
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
expect(fs.calls).toHaveLength(1)
|
||||
expect(fs.calls[0]?.op).toBe('read')
|
||||
expect(fs.calls[0]?.exec?.agent?.session).toBe(session)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
const base = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: 'v', view: 'full' as const }
|
||||
|
||||
it('reports a byte-capped read', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
|
||||
expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports a more-remaining page', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99 })
|
||||
expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports end-of-file', () => {
|
||||
expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)')
|
||||
})
|
||||
|
||||
it('renders an empty file as just the footer', () => {
|
||||
const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 })
|
||||
expect(out).toContain('(End of file - total 0 lines)')
|
||||
expect(out).not.toContain(': ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('write tool', () => {
|
||||
it('formats a create result', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Created file')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates a backend FsError as an isError result carrying its code', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit tool', () => {
|
||||
it('formats a single-replacement success', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
|
||||
})
|
||||
|
||||
it('rejects identical old/new strings', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must differ')
|
||||
})
|
||||
|
||||
it('rejects an empty old_string', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('old_string must be a non-empty string')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_OBSERVED from the backend', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('read first', 'FS_NOT_OBSERVED')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('propagates FS_PARTIAL_OBSERVATION from the backend', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('read fully first', 'FS_PARTIAL_OBSERVATION')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
|
||||
})
|
||||
})
|
||||
16
packages/fs/tool-fs/tsconfig.json
Normal file
16
packages/fs/tool-fs/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
18
packages/fs/tool-fs/tsdown.config.ts
Normal file
18
packages/fs/tool-fs/tsdown.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* tool-fs exposes one package root plus one entry per tool plugin, so each tool
|
||||
* can be loaded or replaced independently as a subpath plugin
|
||||
* (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`). The root tsdown config
|
||||
* only auto-discovers `src/index.ts`, so the subpath entries are declared here.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/read.ts', 'src/write.ts', 'src/edit.ts'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
})
|
||||
Reference in New Issue
Block a user