Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters
# Conflicts: # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/session/README.md # packages/core/session/src/types.ts # packages/core/session/tests/surface.spec.ts # packages/examples/acp-demo/tests/acp-agent.spec.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/session-query/session-query/tests/session-query.spec.ts # packages/support/invariants/tests/invariants.spec.ts # packages/ui/acp/tests/harness.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# fs/ - filesystem capability family
|
||||
|
||||
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages.
|
||||
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
@@ -8,9 +8,10 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|
||||
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `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/*`) | (registers on `ctx.tools`) |
|
||||
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (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. 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 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. 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. 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 results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
|
||||
|
||||
## No timeouts on file IO
|
||||
|
||||
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web, which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md). A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.
|
||||
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web (which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md)) and the bash-backed `glob`/`grep` (whose declared `timeoutMs` is enforced by `@deepseek-ai/dsh-timeout-policy`): those are process-backed, where a deadline can really kill the work. A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-fs-local
|
||||
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eight `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
|
||||
```ts ignore-check
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -12,8 +12,8 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
|
||||
## Behavior
|
||||
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Dirent, Stats } from 'node:fs'
|
||||
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { BigIntStats, Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
@@ -63,9 +63,9 @@ async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', si
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque version token from a stat: mtime (ns precision) + size. */
|
||||
function versionOf(info: Stats): FsVersion {
|
||||
return FsVersion(`${info.mtimeMs}:${info.size}`)
|
||||
/** Opaque version token from high-resolution identity and freshness metadata. */
|
||||
function versionOf(info: BigIntStats): FsVersion {
|
||||
return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +98,14 @@ export interface PathInfo {
|
||||
size: number
|
||||
}
|
||||
|
||||
/** Result of probing a path without following the final symlink component. */
|
||||
export interface PathLinkInfo {
|
||||
version: FsVersion
|
||||
mode: number
|
||||
type: 'file' | 'directory' | 'symlink' | 'other'
|
||||
size: number
|
||||
}
|
||||
|
||||
/** One local directory child with a resolved target and cheap metadata. */
|
||||
export interface LocalDirEntry {
|
||||
name: string
|
||||
@@ -150,22 +158,62 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
}
|
||||
}
|
||||
|
||||
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
|
||||
if (info.isFile()) return 'file'
|
||||
if (info.isDirectory()) return 'directory'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function pathLinkType(info: Stats | BigIntStats): PathLinkInfo['type'] {
|
||||
if (info.isSymbolicLink()) return 'symlink'
|
||||
return pathType(info)
|
||||
}
|
||||
|
||||
async function probeStats<T extends Stats | BigIntStats>(
|
||||
absolutePath: string,
|
||||
readStats: (path: string) => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await readStats(absolutePath)
|
||||
} catch (error: unknown) {
|
||||
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
|
||||
// the target is absent; any other metadata failure is a real permission/IO
|
||||
// fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error) && !isENOTDIR(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a path for its version, mode, type, and size. Null if absent.
|
||||
* @param absolutePath - the path to stat (typically a target key; symlinks are followed).
|
||||
* @returns the metadata, or null when the path — or a parent segment — does not exist.
|
||||
*/
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
|
||||
} catch (error: unknown) {
|
||||
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
|
||||
// the target is absent; any other stat failure is a real permission/IO fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error) && !isENOTDIR(error)) throw error
|
||||
return null
|
||||
const info = await probeStats(absolutePath, path => stat(path, { bigint: true }))
|
||||
if (!info) return null
|
||||
return {
|
||||
version: versionOf(info),
|
||||
mode: Number(info.mode & 0o777n),
|
||||
type: pathType(info),
|
||||
size: Number(info.size),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a path without following the final symlink component.
|
||||
* @param absolutePath - the path entry to inspect with `lstat` semantics.
|
||||
* @returns path-entry metadata, or null when the entry is absent.
|
||||
*/
|
||||
export async function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null> {
|
||||
const info = await probeStats(absolutePath, path => lstat(path, { bigint: true }))
|
||||
if (!info) return null
|
||||
return {
|
||||
version: versionOf(info),
|
||||
mode: Number(info.mode & 0o777n),
|
||||
type: pathLinkType(info),
|
||||
size: Number(info.size),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { resolve } from 'node:path'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
listDirectory,
|
||||
normalizeLineEndings,
|
||||
probe,
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
@@ -80,14 +83,26 @@ export class LocalFileSystem extends FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
|
||||
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
|
||||
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
|
||||
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
|
||||
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
|
||||
return { targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
|
||||
const info = await probe(target.targetKey)
|
||||
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
|
||||
if (!info) return undefined
|
||||
return { version: info.version, type: info.type, size: info.size }
|
||||
}
|
||||
|
||||
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
|
||||
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
|
||||
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
||||
const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
|
||||
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
|
||||
if (!info) return undefined
|
||||
return { version: info.version, type: info.type, size: info.size }
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* `dsh-fs-policy`, so it is not exercised here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
@@ -73,6 +73,18 @@ describe('resolve', () => {
|
||||
const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
|
||||
expect(await fs.readText(target)).toBe('absolute')
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await expect(fs.resolve('a.txt', { signal: AbortSignal.abort() })).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('honors a signal aborted while resolution is in flight', async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = fs.resolve('a.txt', { signal: controller.signal })
|
||||
controller.abort()
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat', () => {
|
||||
@@ -87,11 +99,104 @@ describe('stat', () => {
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('changes version after a same-size rewrite even when mtime is restored', async () => {
|
||||
const path = join(dir, 'same-size.txt')
|
||||
await writeFile(path, 'first')
|
||||
const target = await fs.resolve(path)
|
||||
const beforeInfo = await stat(path)
|
||||
const beforeVersion = await versionOf(target)
|
||||
|
||||
await fs.writeText(target, 'other')
|
||||
await utimes(path, beforeInfo.atime, beforeInfo.mtime)
|
||||
|
||||
expect((await stat(path)).size).toBe(beforeInfo.size)
|
||||
expect(await versionOf(target)).not.toBe(beforeVersion)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('lstat', () => {
|
||||
it('reports path metadata without following the final symlink component', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
|
||||
expect((await fs.lstat('real.txt'))?.type).toBe('file')
|
||||
expect((await fs.lstat('link.txt'))?.type).toBe('symlink')
|
||||
expect(await fs.lstat('missing.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => {
|
||||
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
|
||||
try {
|
||||
await writeFile(join(other, 'x.txt'), 'in other')
|
||||
expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file')
|
||||
await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
} finally {
|
||||
await rm(other, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('metadata cancellation', () => {
|
||||
it('rejects stat and lstat when their signals abort while the metadata probes are in flight', async () => {
|
||||
await writeFile(join(dir, 'slow.txt'), 'hello')
|
||||
const statStarted = Promise.withResolvers<undefined>()
|
||||
const statRelease = Promise.withResolvers<undefined>()
|
||||
const lstatStarted = Promise.withResolvers<undefined>()
|
||||
const lstatRelease = Promise.withResolvers<undefined>()
|
||||
let isolatedCtx: Context | undefined
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async stat(path: string) {
|
||||
statStarted.resolve(undefined)
|
||||
await statRelease.promise
|
||||
return actual.stat(path, { bigint: true })
|
||||
},
|
||||
async lstat(path: string) {
|
||||
lstatStarted.resolve(undefined)
|
||||
await lstatRelease.promise
|
||||
return actual.lstat(path, { bigint: true })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { LocalFileSystem: IsolatedLocalFileSystem } = await import('../src/index.ts')
|
||||
isolatedCtx = new Context()
|
||||
await isolatedCtx.plugin(IsolatedLocalFileSystem, { cwd: dir })
|
||||
const isolatedFs = isolatedCtx.fs as InstanceType<typeof IsolatedLocalFileSystem>
|
||||
const target = await isolatedFs.resolve('slow.txt')
|
||||
const statController = new AbortController()
|
||||
const lstatController = new AbortController()
|
||||
const pendingStat = isolatedFs.stat(target, statController.signal)
|
||||
const pendingLstat = isolatedFs.lstat('slow.txt', undefined, lstatController.signal)
|
||||
|
||||
await Promise.all([statStarted.promise, lstatStarted.promise])
|
||||
statController.abort()
|
||||
lstatController.abort()
|
||||
const statRejected = expect(pendingStat).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
const lstatRejected = expect(pendingLstat).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
statRelease.resolve(undefined)
|
||||
lstatRelease.resolve(undefined)
|
||||
|
||||
await Promise.all([statRejected, lstatRejected])
|
||||
} finally {
|
||||
statRelease.resolve(undefined)
|
||||
lstatRelease.resolve(undefined)
|
||||
await isolatedCtx?.fiber.dispose()
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readText / streamText', () => {
|
||||
it('reads whole-file text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
@@ -292,9 +397,6 @@ describe('writeText', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const before = await versionOf(target)
|
||||
// Change the byte length so the mtimeMs:size token provably differs (a
|
||||
// same-size same-tick rewrite can collide — the documented version-token
|
||||
// limitation; not what this test is about).
|
||||
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
|
||||
expect(outcome.version).not.toBe(before)
|
||||
expect(outcome.version).toBe(await versionOf(target))
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
@@ -146,6 +147,27 @@ describe('probe', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeNoFollow', () => {
|
||||
it('reports symlinks without following them', async () => {
|
||||
const real = join(dir, 'real.txt')
|
||||
const link = join(dir, 'link.txt')
|
||||
await writeFile(real, 'hi')
|
||||
await symlink(real, link)
|
||||
|
||||
expect((await probeNoFollow(real))?.type).toBe('file')
|
||||
const linkInfo = await probeNoFollow(link)
|
||||
expect(linkInfo?.type).toBe('symlink')
|
||||
expect(typeof linkInfo?.version).toBe('string')
|
||||
expect(linkInfo?.size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns null for a missing path or a file-valued ancestor path segment', async () => {
|
||||
expect(await probeNoFollow(join(dir, 'missing'))).toBeNull()
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
expect(await probeNoFollow(join(dir, 'afile', 'child.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists direct children in stable order without reading content', async () => {
|
||||
const root = join(dir, 'skills')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-fs
|
||||
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
|
||||
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
|
||||
|
||||
@@ -15,12 +15,13 @@ A future sandboxed, virtual, or remote backend implements this interface and the
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
A backend subclasses `FileSystem` and implements seven primitives.
|
||||
A backend subclasses `FileSystem` and implements eight primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default), while `opts.signal` aborts a backend round-trip. Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
|
||||
@@ -41,7 +42,7 @@ This package declares three events (see the generated [events catalog](../../../
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -50,6 +51,6 @@ Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bou
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
|
||||
- **Seven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
|
||||
- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
|
||||
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
|
||||
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteIntent,
|
||||
@@ -29,6 +30,7 @@ export type {
|
||||
FsDirEntry,
|
||||
FsErrorCode,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
@@ -86,10 +88,10 @@ export abstract class FileSystem extends Service {
|
||||
* async even though the local backend only normalizes + realpaths.
|
||||
*
|
||||
* @param path - the path to resolve; relative paths resolve against `opts.cwd`.
|
||||
* @param opts - `cwd` overrides the backend's default base for relative paths.
|
||||
* @param opts - optional cwd override and cancellation signal.
|
||||
* @returns the stable target; the same file yields the same `targetKey`.
|
||||
*/
|
||||
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
|
||||
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
|
||||
|
||||
/**
|
||||
* Return target metadata, or `undefined` when the target does not exist.
|
||||
@@ -99,6 +101,22 @@ export abstract class FileSystem extends Service {
|
||||
*/
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
|
||||
/**
|
||||
* Return path metadata without following the final path component when it is a
|
||||
* symbolic link. This is intentionally path-shaped, not target-shaped:
|
||||
* {@link resolve} follows symlinks to produce the stable identity used by
|
||||
* normal reads/writes, while `lstat` lets a consumer reject the path itself
|
||||
* before that follow happens.
|
||||
*
|
||||
* `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is
|
||||
* absent.
|
||||
* @param path - the path to inspect; relative paths resolve against `opts.cwd`.
|
||||
* @param opts - `cwd` overrides the backend's default base for relative paths.
|
||||
* @param signal - aborts the metadata round-trip.
|
||||
* @returns metadata only, never content; undefined for an absent path.
|
||||
*/
|
||||
abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
|
||||
|
||||
/**
|
||||
* Read the whole regular text file as a single decoded string.
|
||||
* @param target - the resolved target to read.
|
||||
|
||||
@@ -27,16 +27,17 @@ export function FsTargetKey(key: string): FsTargetKey {
|
||||
|
||||
/**
|
||||
* Opaque file-version token — the freshness token a write/edit guards against.
|
||||
* The local backend derives it from mtime+size; a remote backend might use a
|
||||
* revision id. The policy layer records it for stale checks; consumers may
|
||||
* display related metadata but MUST NOT interpret this token.
|
||||
* The local backend derives it from high-resolution stat identity and freshness
|
||||
* fields; a remote backend might use a revision id. The policy layer records it
|
||||
* for stale checks; consumers may display related metadata but MUST NOT
|
||||
* interpret this token.
|
||||
*/
|
||||
export type FsVersion = Branded<'FsVersion'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link FsVersion}. For backend use only — a consumer
|
||||
* never manufactures a version, it receives one from `stat`/write/edit outcomes.
|
||||
* @param v - the backend's raw version string (the local backend derives it from mtime+size).
|
||||
* @param v - the backend's raw version string.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function FsVersion(v: string): FsVersion {
|
||||
@@ -72,6 +73,21 @@ export interface FsInfo {
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about a path without following the final path component when it is a
|
||||
* symbolic link. Unlike {@link FsInfo}, this path-level probe can report
|
||||
* `symlink` so consumers with trust-boundary rules can reject repository-owned
|
||||
* links before resolving a target.
|
||||
*/
|
||||
export interface FsPathInfo {
|
||||
/** Opaque freshness token of the path entry right now. */
|
||||
version: FsVersion
|
||||
/** Whether the path entry is a regular file, directory, symlink, or other. */
|
||||
type: 'file' | 'directory' | 'symlink' | 'other'
|
||||
/** Byte size of the path entry, when the backend can report it. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One direct child returned by {@link FileSystem.listDir}. Listing returns
|
||||
* metadata and resolved targets only; it must not read file contents.
|
||||
|
||||
@@ -13,12 +13,13 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** A minimal in-memory fake implementing the seven provider primitives. */
|
||||
/** A minimal in-memory fake implementing the eight provider primitives. */
|
||||
class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
@@ -30,6 +31,11 @@ class FakeFileSystem extends FileSystem {
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async lstat(path: string): Promise<FsPathInfo | undefined> {
|
||||
const content = this.files.get(path)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
|
||||
@@ -120,6 +126,15 @@ describe('FileSystem provider seam', () => {
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lstat returns path metadata before resolving a target', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'hi')
|
||||
expect(await fs.lstat('a.txt')).toEqual({ version: 'v1', type: 'file', size: 2 })
|
||||
expect(await fs.lstat('missing.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('branded id factories', () => {
|
||||
|
||||
90
packages/fs/tool-fs-search/README.md
Normal file
90
packages/fs/tool-fs-search/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @deepseek-ai/dsh-tool-fs-search
|
||||
|
||||
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a bash executor, then the discovery tools.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
|
||||
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
|
||||
|
||||
## Deployment requirement: co-located bash + filesystem
|
||||
|
||||
Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
|
||||
|
||||
## Config
|
||||
|
||||
All keys are optional; the defaults are the shipped search caps.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
|
||||
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
|
||||
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
|
||||
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
|
||||
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
|
||||
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
|
||||
|
||||
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
|
||||
|
||||
## Two budgets, two artifacts
|
||||
|
||||
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
|
||||
|
||||
## Errors
|
||||
|
||||
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
**What the model sees**: Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
|
||||
|
||||
**Token effect**: Fixed guidance cost per request while the plugin is active.
|
||||
|
||||
#### Glob guidance
|
||||
|
||||
```markdown
|
||||
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
|
||||
```
|
||||
|
||||
#### Grep guidance
|
||||
|
||||
```markdown
|
||||
Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.
|
||||
```
|
||||
|
||||
### Tool schemas
|
||||
|
||||
**What the model sees**: The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible.
|
||||
|
||||
**Token effect**: Fixed schema cost on every request where the tools are visible.
|
||||
|
||||
### Results and spill notices
|
||||
|
||||
**What the model sees**: `glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
|
||||
|
||||
**Token effect**: Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction.
|
||||
|
||||
### Tool errors
|
||||
|
||||
**What the model sees**: Failures are normalized as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers.
|
||||
|
||||
**Token effect**: Only a failing call adds these retained tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
|
||||
- **Ripgrep is a deployment dependency** — a missing or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer.
|
||||
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
|
||||
49
packages/fs/tool-fs-search/package.json
Normal file
49
packages/fs/tool-fs-search/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs-search",
|
||||
"description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-spill": "^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-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
179
packages/fs/tool-fs-search/src/glob.ts
Normal file
179
packages/fs/tool-fs-search/src/glob.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The model-facing `glob` tool: discover files whose paths match a glob
|
||||
* pattern, sorted by modification time. Execution goes through the bash seam
|
||||
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
|
||||
* model-facing schema, argument validation, shell-safe command construction,
|
||||
* result parsing, retention, and formatting; process concerns (defaulting,
|
||||
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/glob
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/**
|
||||
* Default cap on paths retained inline by one `glob` call (the `globMaxResults`
|
||||
* config), matching Claude Code's default `GlobTool` result limit.
|
||||
*/
|
||||
export const GLOB_MAX_RESULTS = 100
|
||||
|
||||
/**
|
||||
* Directory names ripgrep must never descend into for a discovery listing: VCS
|
||||
* metadata stores. `--no-ignore --hidden` would otherwise surface them in every
|
||||
* broad search. Each name is excluded with TWO negated `--glob`s (see
|
||||
* {@link buildGlobCommand}): an any-depth directory glob that matches — and
|
||||
* prunes — the directory during traversal, and a contents glob that still
|
||||
* excludes the internals when the search root itself is at or inside the
|
||||
* directory (an explicit `path` of `.git` or `sub/.git`), where the prune glob
|
||||
* alone never matches.
|
||||
*/
|
||||
export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']
|
||||
|
||||
/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface GlobToolCaps {
|
||||
/** Max paths retained inline; later paths go to the formatted spill file. */
|
||||
maxResults: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** Validated `glob` arguments. */
|
||||
export interface GlobInput {
|
||||
pattern: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank
|
||||
* `pattern`, and a non-blank `path` when given. Throws a plain `Error` (an
|
||||
* ordinary tool argument error) otherwise.
|
||||
*
|
||||
* @param args - the schema-validated `glob` arguments.
|
||||
* @returns the accepted input, unchanged.
|
||||
*/
|
||||
export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInput {
|
||||
if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string')
|
||||
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
||||
return { pattern: args.pattern, ...args.path !== undefined ? { path: args.path } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed `rg --files` command for one `glob` call. Every
|
||||
* model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path})
|
||||
* passes through {@link singleQuote}; the search root rides behind `--` so a
|
||||
* leading-dash path can never be parsed as a flag. `--sort=modified` orders by
|
||||
* modification time, `--no-ignore --hidden` searches ignored and hidden files,
|
||||
* and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
|
||||
*
|
||||
* @param input - the validated arguments.
|
||||
* @returns the complete, shell-safe command string.
|
||||
*/
|
||||
export function buildGlobCommand(input: GlobInput): string {
|
||||
const parts = [
|
||||
'rg --files',
|
||||
`--glob=${singleQuote(input.pattern)}`,
|
||||
'--sort=modified --no-ignore --hidden',
|
||||
// Two negated globs per VCS name: the bare form prunes the directory
|
||||
// during traversal; the /** form still excludes the contents when the
|
||||
// search root is AT or INSIDE the directory (where the bare form,
|
||||
// matched against root-prefixed paths, never fires).
|
||||
...GLOB_VCS_EXCLUDES.flatMap(name => [
|
||||
`--glob=${singleQuote(`!**/${name}`)}`,
|
||||
`--glob=${singleQuote(`!**/${name}/**`)}`,
|
||||
]),
|
||||
]
|
||||
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the model-facing `glob` result: the retained paths, then — when the
|
||||
* result was capped — a footer carrying either the formatted-spill recovery
|
||||
* locator or the could-not-save explanation. The omitted count is a budget fact:
|
||||
* the search itself completed.
|
||||
*
|
||||
* @param retained - the retention outcome over every discovered path.
|
||||
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGlobOutput(retained: RetainedItems<string>, spillRef: SpillRef | undefined): string {
|
||||
const body = retained.items.join('\n')
|
||||
if (!retained.truncated) return body
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern or path to see more.'
|
||||
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call presentation: a search card titled by the pattern (and root).
|
||||
*
|
||||
* @param args - the raw tool arguments; `pattern` and `path` feed the title.
|
||||
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
||||
*/
|
||||
export function presentGlobCall(args: { pattern: string; path?: string }): GenericCallView {
|
||||
const where = args.path !== undefined ? ` in ${args.path}` : ''
|
||||
return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `glob` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
||||
* execution uses its `bash` service.
|
||||
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:glob',
|
||||
order: 103,
|
||||
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'glob',
|
||||
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
|
||||
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
|
||||
+ `Returns the first ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`,
|
||||
parameters: {
|
||||
pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js").' },
|
||||
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
|
||||
},
|
||||
timeoutMs: caps.timeoutMs,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseGlobArgs(args)
|
||||
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
|
||||
if (run.noMatches) return [{ type: 'text', text: 'No files found' }]
|
||||
|
||||
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: caps.maxResults })
|
||||
const all: string[] = []
|
||||
for (const line of run.stdout.split('\n')) {
|
||||
if (line.length === 0) continue
|
||||
const displayPath = toWorkdirRelative(line, run.workdir)
|
||||
all.push(displayPath)
|
||||
retainer.push(displayPath)
|
||||
}
|
||||
const retained = retainer.finish()
|
||||
|
||||
// The complete sorted list is the recovery artifact; save it only when
|
||||
// the inline page omitted paths (an uncapped result needs no spill file).
|
||||
const spillRef = retained.truncated
|
||||
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
|
||||
: undefined
|
||||
return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
|
||||
},
|
||||
presentCall: presentGlobCall,
|
||||
}))
|
||||
}
|
||||
315
packages/fs/tool-fs-search/src/grep.ts
Normal file
315
packages/fs/tool-fs-search/src/grep.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* The model-facing `grep` tool: search file contents with a ripgrep regular
|
||||
* expression. Execution goes through the bash seam (`ctx.bash`) with a fixed
|
||||
* line-oriented `rg --json` command so file path, line number, and line text
|
||||
* parse without colon-splitting ambiguity — this module owns the model-facing
|
||||
* schema, argument validation, shell-safe command construction, `--json`
|
||||
* record parsing, per-line preview retention, match retention, grouping, and
|
||||
* formatting; process concerns stay behind `ctx.bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/grep
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/**
|
||||
* Default cap on flat matches retained inline by one `grep` call (the
|
||||
* `grepMaxMatches` config), matching Claude Code's default `GrepTool`
|
||||
* `head_limit`.
|
||||
*/
|
||||
export const GREP_MAX_MATCHES = 250
|
||||
|
||||
/**
|
||||
* Default cap in bytes on one matched-line preview (the `grepMaxLineBytes`
|
||||
* config); the cut preserves UTF-8 boundaries.
|
||||
*/
|
||||
export const GREP_MAX_LINE_BYTES = 2000
|
||||
|
||||
/** Resolved grep-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface GrepToolCaps {
|
||||
/** Max flat matches retained inline; later matches go to the formatted spill file. */
|
||||
maxMatches: number
|
||||
/** Max bytes retained per matched-line preview. */
|
||||
maxLineBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** Validated `grep` arguments. */
|
||||
export interface GrepInput {
|
||||
pattern: string
|
||||
path?: string
|
||||
include?: string
|
||||
}
|
||||
|
||||
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an `include` that is not ONE positive glob filter: blank strings,
|
||||
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
|
||||
* group is fine — `*.{ts,tsx}` is one glob with alternation, not a list.
|
||||
*/
|
||||
function validateInclude(include: string): void {
|
||||
if (include.trim().length === 0) throw new Error('include must be a non-empty glob when given')
|
||||
if (include.startsWith('!')) throw new Error('include must be a positive glob filter; negated patterns ("!…") are not supported')
|
||||
let braceDepth = 0
|
||||
for (const char of include) {
|
||||
if (char === '{') braceDepth++
|
||||
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1)
|
||||
else if (char === ',' && braceDepth === 0) {
|
||||
throw new Error('include must be one glob, not a comma-separated list (use {a,b} alternation instead)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-EMPTY
|
||||
* `pattern` (whitespace is a legitimate regex), a non-blank `path` when given,
|
||||
* and a single positive `include` glob ({@link GrepInput}). Throws a plain
|
||||
* `Error` (an ordinary tool argument error) otherwise.
|
||||
*
|
||||
* @param args - the schema-validated `grep` arguments.
|
||||
* @returns the accepted input, unchanged.
|
||||
*/
|
||||
export function parseGrepArgs(args: { pattern: string; path?: string; include?: string }): GrepInput {
|
||||
if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string')
|
||||
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
||||
if (args.include !== undefined) validateInclude(args.include)
|
||||
return {
|
||||
pattern: args.pattern,
|
||||
...args.path !== undefined ? { path: args.path } : {},
|
||||
...args.include !== undefined ? { include: args.include } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed line-oriented `rg --json` command for one `grep` call. Every
|
||||
* model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path},
|
||||
* {@link GrepInput.include}) passes through {@link singleQuote}; the pattern
|
||||
* and include ride in `--flag=value` form and the target behind `--`, so a
|
||||
* leading-dash value can never be parsed as a flag.
|
||||
*
|
||||
* @param input - the validated arguments.
|
||||
* @returns the complete, shell-safe command string.
|
||||
*/
|
||||
export function buildGrepCommand(input: GrepInput): string {
|
||||
const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`]
|
||||
if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`)
|
||||
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The uniform malformed-output failure: raw `rg --json` is an internal
|
||||
* transport, so a shape surprise is a search failure, not a partial result.
|
||||
*/
|
||||
function malformedRecord(detail: string, cause?: unknown): SearchError {
|
||||
return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one `rg --json` NDJSON line into a match, `undefined` for the
|
||||
* non-match record types (`begin`/`end`/`context`/`summary`). A line that is
|
||||
* not JSON, or a `match` record missing its path / line number / line content,
|
||||
* throws {@link SearchError} `SEARCH_FAILED`. A match whose line is not valid
|
||||
* UTF-8 (ripgrep sends base64 `bytes` instead of `text`) yields a placeholder
|
||||
* preview rather than failing the whole search.
|
||||
*/
|
||||
function parseRecord(line: string): GrepMatch | undefined {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch (error: unknown) {
|
||||
throw malformedRecord('a line is not JSON', error)
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) throw malformedRecord('a record is not an object')
|
||||
const record = parsed as { type?: unknown; data?: unknown }
|
||||
// Non-match record types (begin/end/context/summary — and any future type)
|
||||
// are transport framing, not results: skipped, not malformed.
|
||||
if (record.type !== 'match') return undefined
|
||||
if (typeof record.data !== 'object' || record.data === null) throw malformedRecord('a match record has no data')
|
||||
const data = record.data as { path?: unknown; line_number?: unknown; lines?: unknown }
|
||||
const pathText = typeof data.path === 'object' && data.path !== null ? (data.path as { text?: unknown }).text : undefined
|
||||
if (typeof pathText !== 'string') throw malformedRecord('a match record has no path text')
|
||||
if (typeof data.line_number !== 'number') throw malformedRecord('a match record has no line number')
|
||||
if (typeof data.lines !== 'object' || data.lines === null) throw malformedRecord('a match record has no line content')
|
||||
const lines = data.lines as { text?: unknown; bytes?: unknown }
|
||||
if (typeof lines.text === 'string') {
|
||||
return { path: pathText, lineNumber: data.line_number, line: lines.text.replace(/\r?\n$/, '') }
|
||||
}
|
||||
if (typeof lines.bytes === 'string') {
|
||||
return { path: pathText, lineNumber: data.line_number, line: '(line is not valid UTF-8)' }
|
||||
}
|
||||
throw malformedRecord('a match record has neither line text nor bytes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse complete `rg --json` stdout into flat matches, in output order (ripgrep
|
||||
* emits one file's matches contiguously). Only `match` records are consumed.
|
||||
*
|
||||
* @param stdout - the complete raw `rg --json` stdout.
|
||||
* @returns the flat matches; empty for output with no match records.
|
||||
*/
|
||||
export function parseGrepMatches(stdout: string): GrepMatch[] {
|
||||
const matches: GrepMatch[] = []
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (line.length === 0) continue
|
||||
const match = parseRecord(line)
|
||||
if (match !== undefined) matches.push(match)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
||||
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
||||
* the searched file for `read`.
|
||||
*
|
||||
* @param line - the matched line text (trailing newline already stripped).
|
||||
* @param maxBytes - the preview budget in bytes.
|
||||
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
||||
*/
|
||||
export function previewLine(line: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(line)
|
||||
const kept = retainer.finish()
|
||||
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
|
||||
}
|
||||
|
||||
/** `match` / `matches` for a count. */
|
||||
function matchNoun(count: number): string {
|
||||
return count === 1 ? 'match' : 'matches'
|
||||
}
|
||||
|
||||
/**
|
||||
* Group flat matches by file (first-seen order) into the model-facing body:
|
||||
* each file's display path, then one `Line N: <text>` row per match.
|
||||
*
|
||||
* @param matches - the flat matches to render.
|
||||
* @returns the grouped body text.
|
||||
*/
|
||||
export function formatGrepMatches(matches: GrepMatch[]): string {
|
||||
const byFile = new Map<string, GrepMatch[]>()
|
||||
for (const match of matches) {
|
||||
const group = byFile.get(match.path)
|
||||
if (group !== undefined) group.push(match)
|
||||
else byFile.set(match.path, [match])
|
||||
}
|
||||
const sections: string[] = []
|
||||
for (const [path, group] of byFile) {
|
||||
sections.push(`${path}\n${group.map(m => `Line ${m.lineNumber}: ${m.line}`).join('\n')}`)
|
||||
}
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the model-facing `grep` result: a found-count header, the retained
|
||||
* matches grouped by file, then — when the result was capped — a footer
|
||||
* carrying either the formatted-spill recovery locator or the could-not-save
|
||||
* explanation. The omitted count is a budget fact: the search itself completed.
|
||||
*
|
||||
* @param retained - the retention outcome over every parsed match.
|
||||
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: SpillRef | undefined): string {
|
||||
const header = retained.truncated
|
||||
? `Found ${retained.kept} of ${retained.seen} matches`
|
||||
: `Found ${retained.seen} ${matchNoun(retained.seen)}`
|
||||
const body = formatGrepMatches(retained.items)
|
||||
if (!retained.truncated) return `${header}\n\n${body}`
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern, path, or include to see more.'
|
||||
return `${header}\n\n${body}\n\n(${recovery})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call presentation: a search card titled by the pattern (and target /
|
||||
* include filter).
|
||||
*
|
||||
* @param args - the raw tool arguments; `pattern`, `path`, and `include` feed the title.
|
||||
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
||||
*/
|
||||
export function presentGrepCall(args: { pattern: string; path?: string; include?: string }): GenericCallView {
|
||||
const where = args.path !== undefined ? ` in ${args.path}` : ''
|
||||
const filter = args.include !== undefined ? ` (${args.include})` : ''
|
||||
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `grep` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
||||
* execution uses its `bash` service.
|
||||
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:grep',
|
||||
order: 104,
|
||||
text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'grep',
|
||||
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
|
||||
+ `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. `
|
||||
+ 'Use read on a matched file for surrounding context.',
|
||||
parameters: {
|
||||
pattern: { type: 'string', required: true, description: 'Regular expression to search for (ripgrep syntax).' },
|
||||
path: { type: 'string', description: 'File or directory to search. Defaults to the session workspace; a relative path resolves against it.' },
|
||||
include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
|
||||
},
|
||||
timeoutMs: caps.timeoutMs,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseGrepArgs(args)
|
||||
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
|
||||
if (run.noMatches) return [{ type: 'text', text: 'No matches found' }]
|
||||
|
||||
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: caps.maxMatches })
|
||||
const all: GrepMatch[] = []
|
||||
for (const raw of parseGrepMatches(run.stdout)) {
|
||||
const match: GrepMatch = {
|
||||
path: toWorkdirRelative(raw.path, run.workdir),
|
||||
lineNumber: raw.lineNumber,
|
||||
line: previewLine(raw.line, caps.maxLineBytes),
|
||||
}
|
||||
all.push(match)
|
||||
retainer.push(match)
|
||||
}
|
||||
const retained = retainer.finish()
|
||||
|
||||
// The spill file stores the FULL formatted match list (same grouped,
|
||||
// per-line-previewed shape the model saw), so read offset/limit pages the
|
||||
// same logical result; save only when the inline page omitted matches.
|
||||
const spillRef = retained.truncated
|
||||
? await trySaveFormattedResult(
|
||||
ctx,
|
||||
exec,
|
||||
'grep-results.txt',
|
||||
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
|
||||
)
|
||||
: undefined
|
||||
return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
|
||||
},
|
||||
presentCall: presentGrepCall,
|
||||
}))
|
||||
}
|
||||
110
packages/fs/tool-fs-search/src/index.ts
Normal file
110
packages/fs/tool-fs-search/src/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
|
||||
* bash executor seam (`ctx.bash`). This single plugin registers both tools.
|
||||
*
|
||||
* ## Bash-backed, not a `ctx.fs` provider method
|
||||
*
|
||||
* Local workspace discovery is a process-backed `rg` workflow, so these tools
|
||||
* execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed
|
||||
* ripgrep command templates — never `ctx.bash.start()`, never a model-visible
|
||||
* background task. The tool layer owns schemas, argument validation, shell
|
||||
* quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result
|
||||
* parsing, retention, formatted-result spill, and timeout declaration; the
|
||||
* bash executor owns request defaulting/capping, subprocess execution,
|
||||
* process-group termination, environment scrubbing, raw output capture, and
|
||||
* backend substitution. The package injects `tools`, `systemPrompt`, and
|
||||
* `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
|
||||
* with `ctx.get()` because formatted-result spill is optional.
|
||||
*
|
||||
* Returned paths are displayed relative to the resolved bash workdir and are
|
||||
* follow-up-readable only in co-located deployments where the bash workdir and
|
||||
* the filesystem `read` root are the same workspace — a documented v1
|
||||
* deployment requirement, not runtime-validated.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
|
||||
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts'
|
||||
export type { GlobInput, GlobToolCaps } from './glob.ts'
|
||||
export {
|
||||
GREP_MAX_LINE_BYTES,
|
||||
GREP_MAX_MATCHES,
|
||||
applyGrepTool,
|
||||
buildGrepCommand,
|
||||
formatGrepMatches,
|
||||
formatGrepOutput,
|
||||
parseGrepArgs,
|
||||
parseGrepMatches,
|
||||
presentGrepCall,
|
||||
previewLine,
|
||||
} from './grep.ts'
|
||||
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
|
||||
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
export type { RipgrepRun, SearchErrorCode } from './search-core.ts'
|
||||
export { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs-search'
|
||||
|
||||
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
|
||||
export const inject = ['tools', 'systemPrompt', 'bash']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */
|
||||
globMaxResults?: number
|
||||
/** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */
|
||||
grepMaxMatches?: number
|
||||
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
|
||||
grepMaxLineBytes?: number
|
||||
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
|
||||
rawOutputMaxBytes?: number
|
||||
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
|
||||
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
|
||||
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
||||
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
|
||||
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-fs-search: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the `glob`/`grep` filesystem discovery tool suite. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
|
||||
assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches)
|
||||
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
|
||||
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
applyGlobTool(ctx, {
|
||||
maxResults: resolved.globMaxResults,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
applyGrepTool(ctx, {
|
||||
maxMatches: resolved.grepMaxMatches,
|
||||
maxLineBytes: resolved.grepMaxLineBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
}
|
||||
262
packages/fs/tool-fs-search/src/search-core.ts
Normal file
262
packages/fs/tool-fs-search/src/search-core.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Shared execution plumbing for the `glob` / `grep` search tools: the
|
||||
* package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that
|
||||
* turns a fixed `rg` command into complete raw stdout, the best-effort
|
||||
* formatted-result spill handoff, and workdir-relative path display.
|
||||
*
|
||||
* Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`
|
||||
* as ordinary foreground tool calls — never `ctx.bash.start()`, never a
|
||||
* model-visible background task. Raw `rg` stdout is an internal transport
|
||||
* detail: the tools request a per-run stdout capture budget from the bash seam,
|
||||
* parse only complete in-memory stdout within `rawOutputMaxBytes`, and never
|
||||
* read executor spill files. The model-facing recovery artifact is the
|
||||
* formatted result saved through `ctx.spillStore.saveText()`
|
||||
* ({@link trySaveFormattedResult}).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/search-core
|
||||
*/
|
||||
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Default cap on the complete raw `rg` stdout the tools will parse (the
|
||||
* `rawOutputMaxBytes` config), matching Claude Code's ripgrep raw buffer.
|
||||
*/
|
||||
export const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
||||
|
||||
/**
|
||||
* Default cooperative tool-call timeout budget in milliseconds (the `timeoutMs`
|
||||
* config), attached to both tool definitions for
|
||||
* `@deepseek-ai/dsh-timeout-policy` to enforce through `exec.signal`.
|
||||
*/
|
||||
export const SEARCH_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for search failures. Package-owned (not
|
||||
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
|
||||
* provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or
|
||||
* glob; `SEARCH_FAILED` — the search could not run or its output could not be
|
||||
* parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`);
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes`
|
||||
* or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool
|
||||
* timeout, caller cancellation, or the bash executor's own timeout cut the
|
||||
* search short.
|
||||
*/
|
||||
export type SearchErrorCode =
|
||||
| 'SEARCH_INVALID_PATTERN'
|
||||
| 'SEARCH_FAILED'
|
||||
| 'SEARCH_RAW_OUTPUT_OVERFLOW'
|
||||
| 'SEARCH_ABORTED'
|
||||
|
||||
/**
|
||||
* Typed search failure. Extends {@link HarnessError} so it carries a stable
|
||||
* {@link SearchErrorCode} and chains `cause`; the tool registry surfaces
|
||||
* `{ name, code }` on `isError` results so retry/permission/UI layers can
|
||||
* branch without parsing messages.
|
||||
*/
|
||||
export class SearchError extends HarnessError {
|
||||
override readonly code: SearchErrorCode
|
||||
|
||||
constructor(message: string, code: SearchErrorCode, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */
|
||||
export interface RipgrepRun {
|
||||
/** Complete raw stdout retained by the bash executor within the requested cap. */
|
||||
stdout: string
|
||||
/** True when ripgrep exited 1: a successful search with zero results. */
|
||||
noMatches: boolean
|
||||
/** The resolved working directory the command ran in (the display-relativization base). */
|
||||
workdir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
|
||||
* the executor dropped bytes (the tool never reads `stderr.spillPath`).
|
||||
*/
|
||||
function stderrExcerpt(stderr: CollectedOutput): string {
|
||||
const text = stderr.text.trim()
|
||||
if (text.length === 0) return ''
|
||||
return stderr.truncated ? `${text} [stderr truncated]` : text
|
||||
}
|
||||
|
||||
/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */
|
||||
function classifyRunFailure(toolName: string, result: BashRunResult): SearchError {
|
||||
const stderr = stderrExcerpt(result.stderr)
|
||||
if (/regex parse error|error parsing glob/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN')
|
||||
}
|
||||
if (result.exitCode === 127 || /command not found/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the COMPLETE raw stdout of a finished run, enforcing
|
||||
* `rawOutputMaxBytes` on the in-memory transport. A truncated result means the
|
||||
* bash backend could not retain complete stdout within the requested budget, so
|
||||
* the tool fails clearly instead of parsing a silently-partial stream.
|
||||
*/
|
||||
function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string {
|
||||
const narrow = 'narrow pattern, path, or include and retry'
|
||||
if (!result.stdout.truncated) {
|
||||
const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8')
|
||||
if (inlineBytes > rawOutputMaxBytes) {
|
||||
throw new SearchError(
|
||||
`${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
|
||||
'SEARCH_RAW_OUTPUT_OVERFLOW',
|
||||
)
|
||||
}
|
||||
return result.stdout.text
|
||||
}
|
||||
throw new SearchError(
|
||||
`${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
|
||||
'SEARCH_RAW_OUTPUT_OVERFLOW',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one fixed `rg` command through the bash seam and return its complete raw
|
||||
* stdout. The bash request workdir is the calling agent's session cwd
|
||||
* (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` /
|
||||
* `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its
|
||||
* configured default. `exec.signal` is forwarded so the cooperative tool
|
||||
* timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the
|
||||
* command; the bash backend's own timeout stays a second safety cap.
|
||||
*
|
||||
* Exit semantics are tool-owned: exit 0 is success with results, exit 1 is
|
||||
* success with zero results (`noMatches`), anything else throws a
|
||||
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
|
||||
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's
|
||||
* infrastructure failures (pre-aborted signal, unusable workdir, missing
|
||||
* shell) — is translated into the same taxonomy: a pre-aborted signal becomes
|
||||
* `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as
|
||||
* `cause`.
|
||||
*
|
||||
* @param ctx - the plugin context; execution uses its `bash` service.
|
||||
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
|
||||
* @param toolName - `glob` or `grep`, used in error messages.
|
||||
* @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`).
|
||||
* @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
|
||||
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
|
||||
*/
|
||||
export async function runRipgrep(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
toolName: string,
|
||||
command: string,
|
||||
rawOutputMaxBytes: number,
|
||||
): Promise<RipgrepRun> {
|
||||
const cwd = exec.agent?.session.header.cwd
|
||||
const spec = ctx.bash.resolve({
|
||||
command,
|
||||
stdoutMaxBytes: rawOutputMaxBytes,
|
||||
...cwd !== undefined ? { workdir: cwd } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await ctx.bash.run(spec)
|
||||
} catch (error: unknown) {
|
||||
// The seam contract: run() REJECTS only for infrastructure failures — a
|
||||
// pre-aborted signal, an unusable workdir, a missing shell. Translate them
|
||||
// so these failures stay machine-routable under the SEARCH_* taxonomy.
|
||||
if (spec.signal?.aborted === true) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error })
|
||||
}
|
||||
throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error })
|
||||
}
|
||||
if (result.aborted) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (result.timedOut) {
|
||||
throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (result.signal !== null || result.exitCode === null) {
|
||||
throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
|
||||
}
|
||||
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
||||
throw classifyRunFailure(toolName, result)
|
||||
}
|
||||
const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
|
||||
return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an `rg` output path to its display form: absolute paths inside the
|
||||
* resolved bash workdir become workdir-relative; everything else (relative
|
||||
* output, paths outside the workdir) passes through unchanged. Display-only —
|
||||
* returned paths are follow-up-readable in co-located bash/filesystem
|
||||
* deployments where both resolve the same workspace (the documented v1
|
||||
* deployment requirement).
|
||||
*
|
||||
* @param path - one path as ripgrep printed it.
|
||||
* @param workdir - the resolved bash workdir the command ran in.
|
||||
* @returns the workdir-relative display path when possible, else `path` unchanged.
|
||||
*/
|
||||
export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
if (!isAbsolute(path)) return path
|
||||
const rel = relative(workdir, path)
|
||||
if (rel.length === 0) return '.'
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`)) return path
|
||||
return rel
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort save of one COMPLETE formatted search result through
|
||||
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
|
||||
* result. `spillStore` is read with `ctx.get()` (not static inject) because
|
||||
* formatted-result spill is optional; the spill owner is the calling agent's
|
||||
* session header id and the source is the tool execution identity. A missing
|
||||
* backend, a call with no session owner, or a `saveText()` rejection logs a
|
||||
* warning and returns `undefined` — the caller keeps the inline result and
|
||||
* reports that the complete result could not be saved; search success never
|
||||
* turns into `isError` because spill storage is unavailable.
|
||||
*
|
||||
* @param ctx - the plugin context; `spillStore` is looked up opportunistically.
|
||||
* @param exec - the tool-execution context; supplies the owning session, tool name, and call id.
|
||||
* @param suggestedName - the backend-sanitized filename hint (e.g. `grep-results.txt`).
|
||||
* @param content - the complete formatted result to persist.
|
||||
* @returns the saved spill reference, or `undefined` when the result could not be saved.
|
||||
*/
|
||||
export async function trySaveFormattedResult(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
suggestedName: string,
|
||||
content: string,
|
||||
): Promise<SpillRef | undefined> {
|
||||
const sessionId = exec.agent?.session.header.id
|
||||
if (sessionId === undefined) {
|
||||
ctx.logger.warn(`tool-fs-search: no session owner for ${exec.name} result; complete result not saved`)
|
||||
return undefined
|
||||
}
|
||||
const spillStore = ctx.get('spillStore')
|
||||
if (!spillStore) {
|
||||
ctx.logger.warn(`tool-fs-search: no ctx.spillStore backend loaded; complete ${exec.name} result not saved`)
|
||||
return undefined
|
||||
}
|
||||
const save: SaveTextSpill = {
|
||||
owner: { sessionId },
|
||||
source: { toolName: exec.name, callId: exec.callId, label: 'result' },
|
||||
suggestedName,
|
||||
content,
|
||||
}
|
||||
try {
|
||||
return await spillStore.saveText(save)
|
||||
} catch (error: unknown) {
|
||||
// Best-effort: a storage failure must never fail the search or hide the
|
||||
// inline result — the footer reports the unsaved remainder instead.
|
||||
ctx.logger.warn(`tool-fs-search: saveText failed for ${exec.name}: ${String(error)}; complete result not saved`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
27
packages/fs/tool-fs-search/src/shell-quote.ts
Normal file
27
packages/fs/tool-fs-search/src/shell-quote.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The one shell-quoting helper both search tools MUST route every
|
||||
* model-controlled value through before it enters an `rg` command string. The
|
||||
* bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this
|
||||
* is the safety boundary that stops a `pattern`, `path`, or `include` from
|
||||
* breaking out of its argument and injecting shell syntax.
|
||||
*
|
||||
* Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or
|
||||
* concatenate an unquoted model value — they call {@link singleQuote}.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/shell-quote
|
||||
*/
|
||||
|
||||
/**
|
||||
* POSIX single-quote a string for safe use as ONE shell word. Wraps the value
|
||||
* in single quotes and rewrites every embedded single quote as `'\''` (close
|
||||
* quote, an escaped literal quote, reopen quote). Inside single quotes the shell
|
||||
* treats every other byte literally — spaces, newlines, `$`, backticks, `;`,
|
||||
* `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result
|
||||
* is a single, injection-safe argument regardless of the input.
|
||||
*
|
||||
* @param value - the raw, possibly model-controlled string to quote.
|
||||
* @returns the value wrapped as one safe single-quoted shell word.
|
||||
*/
|
||||
export function singleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
190
packages/fs/tool-fs-search/tests/integration.spec.ts
Normal file
190
packages/fs/tool-fs-search/tests/integration.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a
|
||||
* REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify
|
||||
* the WORLD — actual files on disk are discovered and grepped, hostile
|
||||
* patterns stay inert in a real shell, and real `rg` stderr classifies into
|
||||
* the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on
|
||||
* PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor
|
||||
* suite (tools.spec.ts) carries the coverage gate.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, utimes, 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 { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown, agentObj?: object) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`it-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agentObj ? { agent: agentObj 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.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
|
||||
await mkdir(join(dir, 'src'), { recursive: true })
|
||||
await mkdir(join(dir, '.git'), { recursive: true })
|
||||
await mkdir(join(dir, 'spaced dir'), { recursive: true })
|
||||
await writeFile(join(dir, 'src', 'alpha.ts'), 'export const alpha = 1\n// TODO: refit alpha\n')
|
||||
await writeFile(join(dir, 'src', 'beta.ts'), 'export const beta = 2\n')
|
||||
await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n')
|
||||
await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n')
|
||||
await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n')
|
||||
await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n')
|
||||
// Deterministic --sort=modified order: alpha oldest, beta newest.
|
||||
await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
|
||||
await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1))
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('glob', () => {
|
||||
it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
|
||||
const result = await call('glob', { pattern: '**/*.ts' })
|
||||
expect(result.isError).toBe(false)
|
||||
const paths = text(result).split('\n')
|
||||
expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts'))
|
||||
expect(paths).toContain('.hidden.ts')
|
||||
expect(paths).toContain("spaced dir/wei'rd \"name\".ts")
|
||||
expect(paths).not.toContain('.git/config.ts')
|
||||
expect(paths).not.toContain('notes.md')
|
||||
})
|
||||
|
||||
it('scopes to a directory search root (path arg)', async () => {
|
||||
const result = await call('glob', { pattern: '*.ts', path: 'src' })
|
||||
expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts'])
|
||||
})
|
||||
|
||||
it('reports zero discoveries as No files found', async () => {
|
||||
expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found')
|
||||
})
|
||||
|
||||
it('excludes VCS internals even when the search root IS the VCS directory', async () => {
|
||||
// The prune glob alone never matches root-prefixed paths when rg is
|
||||
// rooted at .git; the paired contents glob keeps the exclusion airtight.
|
||||
expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found')
|
||||
})
|
||||
|
||||
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
|
||||
const result = await call('glob', { pattern: '[' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('grep', () => {
|
||||
it('greps a directory tree with grouped, line-numbered output', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha' })
|
||||
expect(result.isError).toBe(false)
|
||||
const output = text(result)
|
||||
expect(output).toContain('Found 3 matches')
|
||||
expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha')
|
||||
expect(output).toContain('notes.md\nLine 1: alpha appears here too')
|
||||
})
|
||||
|
||||
it('greps a single FILE target', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' })
|
||||
expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too')
|
||||
})
|
||||
|
||||
it('greps a directory target with an include filter', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' })
|
||||
const output = text(result)
|
||||
expect(output).toContain('alpha.ts')
|
||||
expect(output).not.toContain('notes.md')
|
||||
})
|
||||
|
||||
it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => {
|
||||
const canary = join(dir, 'pwned')
|
||||
const result = await call('grep', { pattern: `$(touch ${canary})` })
|
||||
expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing
|
||||
expect(text(result)).toBe('No matches found')
|
||||
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
|
||||
})
|
||||
|
||||
it('a leading-dash pattern is a pattern, not a flag', async () => {
|
||||
await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n')
|
||||
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' })
|
||||
expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
|
||||
})
|
||||
|
||||
it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => {
|
||||
const result = await call('grep', { pattern: '(unclosed' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
|
||||
})
|
||||
|
||||
it('classifies a missing target as SEARCH_FAILED', async () => {
|
||||
const result = await call('grep', { pattern: 'x', path: 'no-such-dir' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-session cwd', () => {
|
||||
it('resolves the search in the SESSION workspace, not the executor config cwd', async () => {
|
||||
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
|
||||
try {
|
||||
await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
|
||||
const agentObj = { session: { header: { id: 'session-int', cwd: sessionDir } } }
|
||||
const globbed = await call('glob', { pattern: '*.ts' }, agentObj)
|
||||
expect(text(globbed)).toBe('only-here.ts')
|
||||
const grepped = await call('grep', { pattern: 'sessionFile' }, agentObj)
|
||||
expect(text(grepped)).toContain('only-here.ts\nLine 1: const sessionFile = true')
|
||||
} finally {
|
||||
await rm(sessionDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('bash-start infrastructure failures stay in the SEARCH_* taxonomy', () => {
|
||||
it('a pre-aborted exec.signal (real executor rejects before spawn) is SEARCH_ABORTED', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId(`it-${++callCounter}`),
|
||||
name: 'grep',
|
||||
arguments: { pattern: 'x' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
|
||||
})
|
||||
|
||||
it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
|
||||
const gone = join(dir, 'deleted-session-dir')
|
||||
const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('could not start')
|
||||
})
|
||||
})
|
||||
})
|
||||
50
packages/fs/tool-fs-search/tests/load-path.spec.ts
Normal file
50
packages/fs/tool-fs-search/tests/load-path.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Real-load-path guard for @deepseek-ai/dsh-tool-fs-search. `tool-fs-search` is
|
||||
* a NAMESPACE plugin with `inject` — so a stray `export default apply` would
|
||||
* make the cordis Loader's `unwrapExports` (`exports.default ?? exports`)
|
||||
* collapse the module to the bare `apply` function, DROPPING `inject`. The
|
||||
* plugin would then read `ctx.bash` without having injected it and throw
|
||||
* `cannot get property … without inject` the moment it loads (postmortem 0001).
|
||||
*
|
||||
* A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it
|
||||
* bypasses `unwrapExports`. So this test unwraps the module through the REAL
|
||||
* `Loader.prototype.unwrapExports` and mounts the result over a bash executor,
|
||||
* exercising the exact path the Loader uses. Prove the guard bites: add
|
||||
* `export default apply` to `src/index.ts`, watch this go red, revert.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in toolFsSearch).toBe(false)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(toolFsSearch)
|
||||
expect(unwrapped.name).toBe('tool-fs-search')
|
||||
expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash'])
|
||||
expect(typeof unwrapped.Config).toBe('function')
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('boots over ctx.bash through the unwrapped module without an inject error', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
|
||||
// A collapsed export shape (dropped inject) would throw "without inject" here.
|
||||
const fiber = await ctx.plugin(unwrapped)
|
||||
expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep']))
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
59
packages/fs/tool-fs-search/tests/shell-quote.spec.ts
Normal file
59
packages/fs/tool-fs-search/tests/shell-quote.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Unit tests for the shell-quoting safety boundary, plus a REAL round-trip:
|
||||
* every adversarial value, quoted, must survive `bash -c "printf '%s' <quoted>"`
|
||||
* byte-for-byte — proving the quoting is inert in an actual shell, not just
|
||||
* against a mental model of one.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
/** Adversarial values a model could pass as pattern / path / include. */
|
||||
const HOSTILE: readonly string[] = [
|
||||
'plain',
|
||||
'with spaces',
|
||||
"it's got 'quotes'",
|
||||
'"double quoted"',
|
||||
'$(rm -rf /tmp/nope)',
|
||||
'`touch /tmp/nope`',
|
||||
'$HOME and ${PATH}',
|
||||
'semi;colon && chain || pipe | bg &',
|
||||
'newline\nin the middle',
|
||||
'-leading-dash',
|
||||
'--leading-double-dash',
|
||||
'*?[a-z]{x,y}',
|
||||
'!bang',
|
||||
'\\backslash\\',
|
||||
'~tilde',
|
||||
'# not a comment',
|
||||
'>redirect <input 2>&1',
|
||||
]
|
||||
|
||||
describe('singleQuote', () => {
|
||||
it('wraps a plain value in single quotes', () => {
|
||||
expect(singleQuote('abc')).toBe("'abc'")
|
||||
})
|
||||
|
||||
it("rewrites embedded single quotes as '\\''", () => {
|
||||
expect(singleQuote("a'b")).toBe("'a'\\''b'")
|
||||
expect(singleQuote("''")).toBe("''\\'''\\'''")
|
||||
})
|
||||
|
||||
it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))(
|
||||
'round-trips %s through a real bash -c unchanged',
|
||||
(_label, value) => {
|
||||
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe(value)
|
||||
},
|
||||
)
|
||||
|
||||
it('a quoted command substitution does not execute (the world stays untouched)', () => {
|
||||
const canary = `/tmp/dsh-quote-canary-${process.pid}`
|
||||
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' })
|
||||
expect(result.stdout).toBe(`$(touch ${canary})`)
|
||||
// The canary file must NOT exist — the substitution stayed literal.
|
||||
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
|
||||
})
|
||||
})
|
||||
632
packages/fs/tool-fs-search/tests/tools.spec.ts
Normal file
632
packages/fs/tool-fs-search/tests/tools.spec.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* Consumer-surface tests for the search tools over a FAKE bash executor and a
|
||||
* FAKE spill backend, exercised through `ctx.tools.execute()` so nothing
|
||||
* bypasses the tool registry. The fake executor makes every seam outcome
|
||||
* scriptable — truncated stdout with/without a raw spill path, abort/timeout,
|
||||
* signal kills, ripgrep exit codes — so these tests verify schemas, argument
|
||||
* validation, shell-safe command construction, workdir derivation, signal
|
||||
* forwarding, `SEARCH_*` error classification, retention, formatted-result
|
||||
* spill handoff, and the no-background-task invariant. Real-`rg` behavior is
|
||||
* pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
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 { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import {
|
||||
buildGlobCommand,
|
||||
buildGrepCommand,
|
||||
formatGrepMatches,
|
||||
parseGrepMatches,
|
||||
presentGlobCall,
|
||||
presentGrepCall,
|
||||
previewLine,
|
||||
toWorkdirRelative,
|
||||
} from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
/** A successful run result over the given stdout; overrides script the failure shapes. */
|
||||
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 60_000,
|
||||
stdout: { text: stdout, truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A scriptable fake executor: `resolve()` mirrors the real request→spec
|
||||
* defaulting (workdir falls back to `/work`), `run()` returns whatever the
|
||||
* test armed via `handler`, and `start()` throws — the search tools must NEVER
|
||||
* create a background task.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/work',
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.specs.push(spec)
|
||||
return Promise.resolve(this.handler(spec))
|
||||
}
|
||||
override start(): BashProcess {
|
||||
this.startCalls++
|
||||
throw new Error('search tools must never start a background task')
|
||||
}
|
||||
}
|
||||
|
||||
/** A recording spill backend; arm `failWith` to script a storage failure. */
|
||||
class FakeSpill extends SpillStore {
|
||||
saves: SaveTextSpill[] = []
|
||||
failWith?: Error
|
||||
|
||||
override saveText(input: SaveTextSpill): Promise<SpillRef> {
|
||||
if (this.failWith) return Promise.reject(this.failWith)
|
||||
this.saves.push(input)
|
||||
return Promise.resolve({
|
||||
locator: SpillLocator(`/spill/${input.suggestedName}`),
|
||||
bytes: Buffer.byteLength(input.content, 'utf8'),
|
||||
retrievalHint: 'Use the fake retrieval hint.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
config?: ToolFsSearch.Config
|
||||
spill?: boolean
|
||||
}
|
||||
|
||||
async function setup(options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
if (options.spill === true) await ctx.plugin(FakeSpill)
|
||||
const fiber = await ctx.plugin(ToolFsSearch, options.config)
|
||||
const bash = ctx.bash as FakeBash
|
||||
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
|
||||
return { ctx, bash, spill, fiber }
|
||||
}
|
||||
|
||||
/** A stand-in agent whose session header carries the given cwd (and a stable id). */
|
||||
const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } })
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...options.agent ? { agent: options.agent as never } : {},
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
/** One rg --json match record line. */
|
||||
function matchLine(path: string, lineNumber: number, lineText: string): string {
|
||||
return JSON.stringify({ type: 'match', data: { path: { text: path }, lines: { text: lineText }, line_number: lineNumber, absolute_offset: 0, submatches: [] } })
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers glob and grep with their prompt sections', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep'])
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the glob tool')
|
||||
expect(prompt).toContain('Use the grep tool')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.bash exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFsSearch) // no bash executor
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
expect(ctx.tools.schemas()).toHaveLength(2)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name)
|
||||
expect(sections).not.toContain('tool:glob')
|
||||
expect(sections).not.toContain('tool:grep')
|
||||
})
|
||||
|
||||
it('attaches the configured timeoutMs to both tool definitions', async () => {
|
||||
const { ctx } = await setup({ config: { timeoutMs: 5000 } })
|
||||
expect(ctx.tools.get('glob')?.timeoutMs).toBe(5000)
|
||||
expect(ctx.tools.get('grep')?.timeoutMs).toBe(5000)
|
||||
})
|
||||
|
||||
it('defaults the timeout budget to 30 seconds', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000)
|
||||
expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config validation', () => {
|
||||
it.each([
|
||||
['globMaxResults', { globMaxResults: 0 }],
|
||||
['grepMaxMatches', { grepMaxMatches: -1 }],
|
||||
['grepMaxLineBytes', { grepMaxLineBytes: 1.5 }],
|
||||
['rawOutputMaxBytes', { rawOutputMaxBytes: 0 }],
|
||||
['timeoutMs', { timeoutMs: -100 }],
|
||||
] as const)('rejects a non-positive or fractional %s at load', async (name, config) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
|
||||
})
|
||||
})
|
||||
|
||||
describe('command construction (shell-safe)', () => {
|
||||
it('glob: fixed rg --files template with quoted pattern and paired VCS excludes', () => {
|
||||
const command = buildGlobCommand({ pattern: '**/*.ts' })
|
||||
expect(command).toBe(
|
||||
"rg --files --glob='**/*.ts' --sort=modified --no-ignore --hidden "
|
||||
+ "--glob='!**/.git' --glob='!**/.git/**' --glob='!**/.svn' --glob='!**/.svn/**' "
|
||||
+ "--glob='!**/.hg' --glob='!**/.hg/**' --glob='!**/.bzr' --glob='!**/.bzr/**' "
|
||||
+ "--glob='!**/.jj' --glob='!**/.jj/**' --glob='!**/.sl' --glob='!**/.sl/**'",
|
||||
)
|
||||
})
|
||||
|
||||
it('glob: the search root rides behind -- and is quoted', () => {
|
||||
const command = buildGlobCommand({ pattern: '*.md', path: 'docs dir' })
|
||||
expect(command).toContain("-- 'docs dir'")
|
||||
})
|
||||
|
||||
it('grep: fixed rg --json template with the pattern in --regexp= form', () => {
|
||||
expect(buildGrepCommand({ pattern: 'foo.*bar' })).toBe("rg --json --regexp='foo.*bar'")
|
||||
})
|
||||
|
||||
it('grep: include and path are quoted, include in --glob= form, path behind --', () => {
|
||||
const command = buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' })
|
||||
expect(command).toBe("rg --json --regexp='x' --glob='*.{ts,tsx}' -- '-leading-dash'")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a command-substitution pattern', '$(rm -rf /)', "'$(rm -rf /)'"],
|
||||
['a backtick pattern', '`touch pwned`', "'`touch pwned`'"],
|
||||
['a pattern with double quotes and spaces', 'say "hi there"', '\'say "hi there"\''],
|
||||
['a pattern with single quotes', "it's", '\'it\'\\\'\'s\''],
|
||||
['a pattern with newlines', 'a\nb', "'a\nb'"],
|
||||
['a leading-dash pattern', '--flag', "'--flag'"],
|
||||
['glob metacharacters', '*?[a-z]{x,y}', "'*?[a-z]{x,y}'"],
|
||||
])('quotes %s into one inert shell word', (_label, raw, quoted) => {
|
||||
expect(buildGrepCommand({ pattern: raw })).toBe(`rg --json --regexp=${quoted}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workdir derivation and signal forwarding', () => {
|
||||
it('forwards the session cwd as the request workdir', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('a.ts\n')
|
||||
await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
|
||||
expect(bash.requests[0]?.workdir).toBe('/sessions/s1')
|
||||
expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
|
||||
})
|
||||
|
||||
it('omits the request workdir without a session cwd so resolve() defaults apply', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('a.ts\n')
|
||||
await call(ctx, 'glob', { pattern: '*' }, { agent: agent() })
|
||||
expect(bash.requests[0]).not.toHaveProperty('workdir')
|
||||
expect(bash.specs[0]?.workdir).toBe('/work')
|
||||
// A non-agent caller takes the same default path.
|
||||
await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(bash.requests[1]).not.toHaveProperty('workdir')
|
||||
})
|
||||
|
||||
it('forwards exec.signal into the bash spec (the abort reaches the backend)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
bash.handler = spec => runResult('', { aborted: spec.signal?.aborted === true })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
|
||||
expect(bash.specs[0]?.signal).toBe(controller.signal)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
|
||||
expect(text(result)).toContain('aborted')
|
||||
})
|
||||
|
||||
it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' })
|
||||
expect(text(result)).toContain('timed out after 1234ms')
|
||||
})
|
||||
|
||||
it('translates a run() rejection under a pre-aborted signal into SEARCH_ABORTED', async () => {
|
||||
// The seam contract: run() REJECTS for a pre-aborted signal (it never
|
||||
// spawns). The plain rejection must not escape the SEARCH_* taxonomy.
|
||||
const { ctx, bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
bash.handler = () => { throw new Error('aborted before spawn') }
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
|
||||
})
|
||||
|
||||
it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => { throw new Error('spawn bash ENOENT') }
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('could not start')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exit semantics and failure classification', () => {
|
||||
it('exit 1 is a successful empty search', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
const glob = await call(ctx, 'glob', { pattern: '*.nope' })
|
||||
expect(glob.isError).toBe(false)
|
||||
expect(text(glob)).toBe('No files found')
|
||||
const grep = await call(ctx, 'grep', { pattern: 'nope' })
|
||||
expect(grep.isError).toBe(false)
|
||||
expect(text(grep)).toBe('No matches found')
|
||||
})
|
||||
|
||||
it('a regex parse error classifies as SEARCH_INVALID_PATTERN', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } })
|
||||
const result = await call(ctx, 'grep', { pattern: '(' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
|
||||
expect(text(result)).toContain('regex parse error')
|
||||
})
|
||||
|
||||
it('a glob parse error classifies as SEARCH_INVALID_PATTERN', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } })
|
||||
const result = await call(ctx, 'glob', { pattern: '[' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
|
||||
})
|
||||
|
||||
it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('requires ripgrep (rg)')
|
||||
// The same classification holds from either evidence alone: the 127 exit
|
||||
// with silent stderr, or a shell's command-not-found text on another exit.
|
||||
bash.handler = () => runResult('', { exitCode: 127 })
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)')
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found', truncated: false } })
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)')
|
||||
})
|
||||
|
||||
it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('IO error')
|
||||
})
|
||||
|
||||
it('a nonzero exit with EMPTY stderr still reports the exit code', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 3 })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('exit 3')
|
||||
})
|
||||
|
||||
it('truncated stderr gains a truncation note and stderr.spillPath is never read', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', {
|
||||
exitCode: 2,
|
||||
stderr: { text: 'tail of diagnostics', truncated: true, spillPath: '/does/not/exist-and-never-read' },
|
||||
})
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(text(result)).toContain('tail of diagnostics [stderr truncated]')
|
||||
})
|
||||
|
||||
it('a signal kill (not timeout, not abort) is SEARCH_FAILED', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('SIGKILL')
|
||||
})
|
||||
|
||||
it('a null exit with no signal (defensive) is SEARCH_FAILED', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: null, signal: null })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw output acquisition', () => {
|
||||
it('passes rawOutputMaxBytes to bash as the stdout capture budget', async () => {
|
||||
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 1234 } })
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
await call(ctx, 'glob', { pattern: '*.ts' })
|
||||
await call(ctx, 'grep', { pattern: 'needle' })
|
||||
expect(bash.requests.map(request => request.stdoutMaxBytes)).toEqual([1234, 1234])
|
||||
expect(bash.specs.map(spec => spec.stdoutMaxBytes)).toEqual([1234, 1234])
|
||||
})
|
||||
|
||||
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has a raw spill path', async () => {
|
||||
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
|
||||
bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
|
||||
expect(text(result)).toContain('narrow pattern, path, or include')
|
||||
})
|
||||
|
||||
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when UNTRUNCATED inline stdout exceeds the cap', async () => {
|
||||
// An executor retaining more inline than this package's cap (or a
|
||||
// deployment lowering rawOutputMaxBytes below the bash retention) must not
|
||||
// smuggle an over-cap parse through the untruncated path.
|
||||
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
|
||||
bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
|
||||
expect(text(result)).toContain('narrow pattern, path, or include')
|
||||
})
|
||||
|
||||
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('glob results', () => {
|
||||
it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
|
||||
expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts')
|
||||
})
|
||||
|
||||
it('validates arguments (blank pattern, blank path)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'glob', { pattern: ' ' }))).toContain('pattern must be a non-empty string')
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('threads a valid path through to the command as the quoted search root', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('sub/a.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(bash.specs[0]?.command).toContain("-- 'sub'")
|
||||
})
|
||||
|
||||
it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => {
|
||||
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
expect(spill?.saves[0]).toMatchObject({
|
||||
owner: { sessionId: 'session-1' },
|
||||
source: { toolName: 'glob', label: 'result' },
|
||||
suggestedName: 'glob-results.txt',
|
||||
content: 'a.ts\nb.ts\nc.ts\nd.ts',
|
||||
})
|
||||
expect(spill?.saves[0]?.source.callId).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not create a spill file when the result fits inline', async () => {
|
||||
const { ctx, bash, spill } = await setup({ spill: true })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
|
||||
expect(text(result)).toBe('a.ts\nb.ts')
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no spill backend loaded', { fail: false, spill: false, ownerless: false }],
|
||||
['saveText fails', { fail: true, spill: true, ownerless: false }],
|
||||
['no session owner', { fail: false, spill: true, ownerless: true }],
|
||||
])('keeps the inline page and reports the unsaved remainder when %s', async (_label, mode) => {
|
||||
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill })
|
||||
if (mode.fail && spill) spill.failWith = new Error('disk full')
|
||||
bash.handler = () => runResult('a.ts\nb.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, mode.ownerless ? {} : { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false) // spill unavailability never fails the search
|
||||
expect(text(result)).toBe('a.ts\n\n(Showing 1 of 2 paths. The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grep results', () => {
|
||||
it('groups matches by file with line numbers', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult([
|
||||
JSON.stringify({ type: 'begin', data: { path: { text: 'a.ts' } } }),
|
||||
matchLine('a.ts', 3, 'const x = 1\n'),
|
||||
matchLine('a.ts', 9, 'const y = 2\n'),
|
||||
JSON.stringify({ type: 'end', data: { path: { text: 'a.ts' } } }),
|
||||
matchLine('b.ts', 1, 'const z = 3'),
|
||||
JSON.stringify({ type: 'summary', data: {} }),
|
||||
'',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'grep', { pattern: 'const' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3')
|
||||
})
|
||||
|
||||
it('reports a single match in the singular', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`)
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'hit' }))).toBe('Found 1 match\n\na.ts\nLine 1: hit')
|
||||
})
|
||||
|
||||
it('relativizes absolute match paths against the resolved workdir', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') })
|
||||
expect(text(result)).toContain('deep/a.ts\nLine 2: hit')
|
||||
})
|
||||
|
||||
it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => {
|
||||
const { ctx, bash } = await setup({ config: { grepMaxLineBytes: 7 } })
|
||||
// 'héllo wörld' cut at 7 bytes lands mid-'é'? h(1)é(2)l(1)l(1)o(1)=6, space=7 → clean cut at 7.
|
||||
// Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed.
|
||||
bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'a' })
|
||||
expect(text(result)).toContain('Line 1: aéaéa (line truncated)')
|
||||
})
|
||||
|
||||
it('renders a non-UTF-8 line (rg bytes form) as a placeholder instead of failing', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const record = JSON.stringify({ type: 'match', data: { path: { text: 'bin.dat' }, lines: { bytes: 'AAECww==' }, line_number: 4 } })
|
||||
bash.handler = () => runResult(`${record}\n`)
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('Line 4: (line is not valid UTF-8)')
|
||||
})
|
||||
|
||||
it('strips a CRLF terminator from the matched line text', () => {
|
||||
const matches = parseGrepMatches(`${matchLine('a.txt', 1, 'windows line\r\n')}\n`)
|
||||
expect(matches[0]?.line).toBe('windows line')
|
||||
})
|
||||
|
||||
it('caps at grepMaxMatches and spills the full formatted match list', async () => {
|
||||
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true })
|
||||
bash.handler = () => runResult([
|
||||
matchLine('a.ts', 1, 'one'),
|
||||
matchLine('a.ts', 2, 'two'),
|
||||
matchLine('b.ts', 3, 'three'),
|
||||
'',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
|
||||
expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)')
|
||||
expect(spill?.saves[0]).toMatchObject({
|
||||
source: { toolName: 'grep', label: 'result' },
|
||||
suggestedName: 'grep-results.txt',
|
||||
content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the unsaved remainder when capped with no spill backend', async () => {
|
||||
const { ctx, bash } = await setup({ config: { grepMaxMatches: 1 } })
|
||||
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)')
|
||||
})
|
||||
|
||||
it('validates arguments (empty pattern, blank path, bad include)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'grep', { pattern: '' }))).toContain('pattern must be a non-empty string')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', path: ' ' }))).toContain('path must be a non-empty string')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', include: ' ' }))).toContain('include must be a non-empty glob')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', include: '!*.ts' }))).toContain('negated patterns')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', include: '*.ts,*.js' }))).toContain('comma-separated list')
|
||||
})
|
||||
|
||||
it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
const result = await call(ctx, 'grep', { pattern: ' ', include: '*.{ts,tsx}' })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rg --json transport failures (SEARCH_FAILED)', () => {
|
||||
it.each([
|
||||
['a non-JSON line', 'not json at all'],
|
||||
['a non-object record', '42'],
|
||||
['a match record with no data', JSON.stringify({ type: 'match' })],
|
||||
['a match record with no path text', JSON.stringify({ type: 'match', data: { path: {}, lines: { text: 'x' }, line_number: 1 } })],
|
||||
['a match record with a non-object path', JSON.stringify({ type: 'match', data: { path: 'a.ts', lines: { text: 'x' }, line_number: 1 } })],
|
||||
['a match record with no line number', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: { text: 'x' } } })],
|
||||
['a match record with no line content', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 1 } })],
|
||||
['a match record with neither text nor bytes', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: {}, line_number: 1 } })],
|
||||
])('%s fails the search', async (_label, line) => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${line}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the no-background-task invariant', () => {
|
||||
it('never calls ctx.bash.start() across successful and failed searches', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('a.ts\n')
|
||||
await call(ctx, 'glob', { pattern: '*' })
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom', truncated: false } })
|
||||
await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentation', () => {
|
||||
it('glob titles carry the pattern and optional root', () => {
|
||||
expect(presentGlobCall({ pattern: '**/*.ts' })).toMatchObject({ card: 'generic', title: 'Glob **/*.ts', kind: 'search' })
|
||||
expect(presentGlobCall({ pattern: '*.md', path: 'docs' }).title).toBe('Glob *.md in docs')
|
||||
})
|
||||
|
||||
it('grep titles carry the pattern, target, and include filter', () => {
|
||||
expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' })
|
||||
expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => {
|
||||
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts')
|
||||
expect(toWorkdirRelative('/w', '/w')).toBe('.')
|
||||
expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts')
|
||||
expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts')
|
||||
expect(toWorkdirRelative('rel/b.ts', '/w')).toBe('rel/b.ts')
|
||||
// Normalization makes this land OUTSIDE the workdir → original path kept.
|
||||
expect(toWorkdirRelative('/w/../up.ts', '/w')).toBe('/w/../up.ts')
|
||||
})
|
||||
|
||||
it('previewLine keeps a within-budget line untouched', () => {
|
||||
expect(previewLine('short', 100)).toBe('short')
|
||||
})
|
||||
|
||||
it('formatGrepMatches groups by first-seen file order', () => {
|
||||
const grouped = formatGrepMatches([
|
||||
{ path: 'b.ts', lineNumber: 2, line: 'x' },
|
||||
{ path: 'a.ts', lineNumber: 1, line: 'y' },
|
||||
{ path: 'b.ts', lineNumber: 5, line: 'z' },
|
||||
])
|
||||
expect(grouped).toBe('b.ts\nLine 2: x\nLine 5: z\n\na.ts\nLine 1: y')
|
||||
})
|
||||
})
|
||||
20
packages/fs/tool-fs-search/tsconfig.json
Normal file
20
packages/fs/tool-fs-search/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../util/retention" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../bash/bash" },
|
||||
{ "path": "../../spill/spill" }
|
||||
]
|
||||
}
|
||||
@@ -34,7 +34,7 @@ Field names are snake_case to match Claude Code and existing harness tool schema
|
||||
|
||||
## The tool is the executor; policy is an event gate
|
||||
|
||||
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
|
||||
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
|
||||
|
||||
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
|
||||
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
|
||||
@@ -100,6 +100,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No directory-listing, glob, grep, or search tools ship** — a deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md); `ctx.fs.listDir` serves provider code such as skill discovery but still has no model-facing consumer, so models fall back to `bash`.
|
||||
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam.
|
||||
- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`.
|
||||
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)).
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
interface EditInput {
|
||||
@@ -75,8 +75,7 @@ export function applyEditTool(ctx: Context): void {
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseEditArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { buildWindow, formatReadOutput } from './read-render.ts'
|
||||
import type { FileReadOutcome } from './read-render.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
|
||||
export const READ_LIMIT = 2000
|
||||
@@ -86,8 +86,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
|
||||
|
||||
// One stat: type check + size routing + the version recorded as observed.
|
||||
// A concurrent write can only make a later guarded mutation fail stale and require reread.
|
||||
|
||||
@@ -18,3 +18,16 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
return exec.agent?.session.header.cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution options shared by all model-facing filesystem tools.
|
||||
* @param exec - the tool-execution context supplying session cwd and cancellation.
|
||||
* @returns provider resolution options for the current tool call.
|
||||
*/
|
||||
export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; signal?: AbortSignal } {
|
||||
const cwd = sessionCwd(exec)
|
||||
return {
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: only a non-blank
|
||||
@@ -61,8 +61,7 @@ export function applyWriteTool(ctx: Context): void {
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseWriteArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
|
||||
// Single-slot decision: the policy plugin produces createIfAbsent/
|
||||
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
|
||||
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -17,11 +14,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
*/
|
||||
export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona } })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: fsCwd })
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsPathInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
@@ -44,6 +45,11 @@ class FakeFs extends FileSystem {
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async lstat(path: string): Promise<FsPathInfo | undefined> {
|
||||
const content = this.files.get(`key:${path}`)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user