Merge remote-tracking branch 'origin/master' into simpl-d-fs-write-only
# Conflicts: # docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md # packages/fs/fs-local/src/fsio.ts
This commit is contained in:
@@ -102,6 +102,8 @@ declare module 'cordis' {
|
||||
* chain. The slot is first-wins: the first non-`next()` decider (registration
|
||||
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
|
||||
* not layering. `actor` is the opaque tool-execution context, never read here.
|
||||
* @param target - the resolved target about to be written.
|
||||
* @param actor - the opaque tool-execution context the decider keys off.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
|
||||
@@ -114,6 +116,8 @@ declare module 'cordis' {
|
||||
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
|
||||
* or has not observed the target. Does NOT call `next()`: one decision,
|
||||
* first-wins (see {@link Events.'fs/write-intent'}).
|
||||
* @param target - the resolved target about to be edited.
|
||||
* @param actor - the opaque tool-execution context the decider keys off.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
@@ -126,6 +130,9 @@ declare module 'cordis' {
|
||||
* await listener promises — async or fallible audit/telemetry does not
|
||||
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
|
||||
* tool-execution context.
|
||||
* @param target - the target that was read/written/edited.
|
||||
* @param version - the version the actor now holds as its observation.
|
||||
* @param actor - the observing tool-execution context; undefined records nothing useful.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
@@ -180,13 +187,26 @@ export abstract class FileSystem extends Service {
|
||||
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
|
||||
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
|
||||
* defaults a bash `workdir` to the session cwd.
|
||||
* @param path - the path to resolve; relative paths resolve against `opts.cwd`.
|
||||
* @param opts - `cwd` overrides the backend's default base for relative paths.
|
||||
* @returns the stable target; the same file yields the same `targetKey`.
|
||||
*/
|
||||
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
|
||||
|
||||
/** Return target metadata, or `undefined` when the target does not exist. */
|
||||
/**
|
||||
* Return target metadata, or `undefined` when the target does not exist.
|
||||
* @param target - the resolved target to stat.
|
||||
* @param signal - aborts the metadata round-trip.
|
||||
* @returns metadata only, never content; undefined for an absent target.
|
||||
*/
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
|
||||
/** Read the whole regular text file as a single decoded string. */
|
||||
/**
|
||||
* Read the whole regular text file as a single decoded string.
|
||||
* @param target - the resolved target to read.
|
||||
* @param signal - aborts the read.
|
||||
* @returns the full decoded UTF-8 content.
|
||||
*/
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
|
||||
/**
|
||||
@@ -194,12 +214,18 @@ export abstract class FileSystem extends Service {
|
||||
* semantics as {@link readText}, for large files). The backend owns
|
||||
* cross-chunk UTF-8 decoding and binary rejection so the policy layer never
|
||||
* touches raw bytes.
|
||||
* @param target - the resolved target to read.
|
||||
* @param signal - aborts the stream, including between chunks.
|
||||
* @returns the chunk iterable, decoded and validated like {@link readText}.
|
||||
*/
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
|
||||
/**
|
||||
* List direct children of a directory in stable name order. Returns resolved
|
||||
* child targets plus cheap metadata only; never reads file contents.
|
||||
* @param target - the resolved directory target.
|
||||
* @param signal - aborts the listing.
|
||||
* @returns one entry per direct child, in stable name order.
|
||||
*/
|
||||
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
|
||||
|
||||
@@ -208,6 +234,11 @@ export abstract class FileSystem extends Service {
|
||||
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
|
||||
* unconditional create-or-overwrite (the bare provider — no version guard, no
|
||||
* read-first requirement). Atomic either way.
|
||||
* @param target - the resolved target to write.
|
||||
* @param content - the full new file content.
|
||||
* @param expected - the write intent guarding the write; omit for unconditional.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @returns the outcome, including the version the write produced.
|
||||
*/
|
||||
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
|
||||
@@ -217,6 +248,11 @@ export abstract class FileSystem extends Service {
|
||||
* matching; OMITTING it edits the current content unconditionally (no version
|
||||
* guard). Either way applies the replacement and writes atomically — one
|
||||
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
|
||||
* @param target - the resolved target to edit.
|
||||
* @param edit - the literal search/replace request.
|
||||
* @param expected - the version guard; omit for an unconditional edit.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @returns the outcome, including the version the edit produced.
|
||||
*/
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
}
|
||||
|
||||
@@ -11,11 +11,22 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
|
||||
|
||||
## Config
|
||||
|
||||
All keys are optional; the defaults are the shipped read caps.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). |
|
||||
| `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). |
|
||||
| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. |
|
||||
| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. |
|
||||
|
||||
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. |
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. |
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"diff": "^9.0.0"
|
||||
"diff": "^9.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
|
||||
@@ -23,11 +23,14 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { applyReadTool } from './read.ts'
|
||||
import z from 'schemastery'
|
||||
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
|
||||
|
||||
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
|
||||
export type { ReadToolCaps } from './read.ts'
|
||||
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
|
||||
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
|
||||
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts'
|
||||
@@ -41,9 +44,49 @@ export const name = 'tool-fs'
|
||||
/** Services required by the filesystem tool suite. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
readLimit?: number
|
||||
/** Maximum characters returned for a single line before truncation. */
|
||||
readMaxLineLength?: number
|
||||
/** Maximum bytes returned for the selected lines of one `read` call. */
|
||||
readMaxBytes?: number
|
||||
/** Files at or above this size stream instead of loading whole into memory. */
|
||||
readStreamMinSize?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
readLimit: z.number().default(READ_LIMIT),
|
||||
readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
|
||||
readMaxBytes: z.number().default(READ_MAX_BYTES),
|
||||
readStreamMinSize: z.number().default(STREAM_MIN_SIZE),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-fs: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
|
||||
export function apply(ctx: Context): void {
|
||||
applyReadTool(ctx)
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('readLimit', resolved.readLimit)
|
||||
assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength)
|
||||
assertPositiveInteger('readMaxBytes', resolved.readMaxBytes)
|
||||
assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize)
|
||||
applyReadTool(ctx, {
|
||||
limit: resolved.readLimit,
|
||||
maxLineLength: resolved.readMaxLineLength,
|
||||
maxBytes: resolved.readMaxBytes,
|
||||
streamMinSize: resolved.readStreamMinSize,
|
||||
})
|
||||
applyWriteTool(ctx)
|
||||
applyEditTool(ctx)
|
||||
}
|
||||
|
||||
@@ -18,21 +18,22 @@
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Maximum characters returned for a single line. */
|
||||
/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */
|
||||
export const READ_MAX_BYTES = 50 * 1024
|
||||
|
||||
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
|
||||
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
|
||||
|
||||
/** Resolved read window. The consumer applies its defaults/caps before calling. */
|
||||
export interface ReadWindow {
|
||||
/** 1-based first line to return. */
|
||||
offset: number
|
||||
/** Maximum number of lines to return. */
|
||||
limit: number
|
||||
/** Maximum characters returned for a single line; overflow is truncated with a suffix. */
|
||||
maxLineLength: number
|
||||
/** Maximum bytes of selected output; overflow stops the scan and marks `truncatedByBytes`. */
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
/** One line returned from a text file. */
|
||||
@@ -77,8 +78,8 @@ function newAccumulator(): WindowAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
|
||||
function truncateLine(line: string, maxLineLength: number): string {
|
||||
return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line
|
||||
}
|
||||
|
||||
function lineByteSize(line: string, currentLineCount: number): number {
|
||||
@@ -89,9 +90,9 @@ function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindo
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine)
|
||||
const text = truncateLine(rawLine, request.maxLineLength)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
|
||||
if (acc.outputBytes + bytes > request.maxBytes) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
@@ -116,7 +117,7 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
|
||||
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
|
||||
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
|
||||
* path serves both. Scans for newlines with a capped line buffer (a newline-free
|
||||
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
|
||||
* giant line is truncated, never buffered past `request.maxLineLength`),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
*/
|
||||
export async function buildWindow(
|
||||
@@ -125,12 +126,14 @@ export async function buildWindow(
|
||||
displayPath: string,
|
||||
): Promise<WindowResult> {
|
||||
const acc = newAccumulator()
|
||||
// One char past the truncation point is enough to prove a line overflows.
|
||||
const lineBufferCap = request.maxLineLength + 1
|
||||
let lineBuffer = ''
|
||||
|
||||
function appendToLineBuffer(segment: string): void {
|
||||
if (lineBuffer.length >= LINE_BUFFER_CAP) return
|
||||
if (lineBuffer.length >= lineBufferCap) return
|
||||
lineBuffer += segment
|
||||
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
|
||||
if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap)
|
||||
}
|
||||
|
||||
function flushLine(): void {
|
||||
|
||||
@@ -23,12 +23,27 @@ import { buildWindow, formatReadOutput } from './read-render.ts'
|
||||
import type { FileReadOutcome } from './read-render.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
|
||||
export const READ_LIMIT = 2000
|
||||
|
||||
/** Files at or above this size stream; smaller files read whole into memory. */
|
||||
/**
|
||||
* Default streaming threshold (the `readStreamMinSize` config): files at or
|
||||
* above this size stream; smaller files read whole into memory.
|
||||
*/
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** Resolved read-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface ReadToolCaps {
|
||||
/** Default and maximum number of lines returned by one call. */
|
||||
limit: number
|
||||
/** Maximum characters returned for a single line. */
|
||||
maxLineLength: number
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
maxBytes: number
|
||||
/** Files at or above this size stream; smaller files read whole into memory. */
|
||||
streamMinSize: number
|
||||
}
|
||||
|
||||
/** Validated `read` arguments after defaulting. */
|
||||
interface ReadInput {
|
||||
filePath: string
|
||||
@@ -43,17 +58,17 @@ function parsePositiveInteger(value: number, name: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput {
|
||||
/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
|
||||
const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit')
|
||||
if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`)
|
||||
const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit')
|
||||
if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`)
|
||||
return { filePath: args.file_path, offset, limit }
|
||||
}
|
||||
|
||||
/** Register the `read` tool and its system-prompt guidance. */
|
||||
export function applyReadTool(ctx: Context): void {
|
||||
export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
order: 100,
|
||||
@@ -66,10 +81,10 @@ export function applyReadTool(ctx: Context): void {
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
|
||||
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args)
|
||||
const input = parseReadArgs(args, caps.limit)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
|
||||
@@ -83,10 +98,14 @@ export function applyReadTool(ctx: Context): void {
|
||||
|
||||
// Stream when the file is large OR size is unknown, so a size-less backend
|
||||
// never buffers an arbitrarily large file.
|
||||
const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE
|
||||
const chunks = info.size === undefined || info.size >= caps.streamMinSize
|
||||
? await ctx.fs.streamText(target, exec.signal)
|
||||
: [await ctx.fs.readText(target, exec.signal)]
|
||||
const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath)
|
||||
const window = await buildWindow(
|
||||
chunks,
|
||||
{ offset: input.offset, limit: input.limit, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes },
|
||||
target.displayPath,
|
||||
)
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
offset: input.offset,
|
||||
@@ -104,7 +123,8 @@ export function applyReadTool(ctx: Context): void {
|
||||
// appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along
|
||||
// location whose line is the read's offset (defaulting to 1). The window is
|
||||
// derived from the RAW args (offset/limit as the model passed them), NOT the
|
||||
// tool's defaulted 1/READ_LIMIT, so an unbounded read shows a bare title.
|
||||
// tool's defaulted 1/configured limit, so an unbounded read shows a bare
|
||||
// title (and the presenter stays a pure function of args, config-free).
|
||||
presentCall(args): GenericCallView {
|
||||
const { offset, limit } = args
|
||||
const window = limit !== undefined && limit > 0
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
|
||||
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
const READ_ALL: ReadWindow = { offset: 1, limit: 2000 }
|
||||
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
|
||||
const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS }
|
||||
|
||||
/** Yield `text` as one chunk (whole-file read shape). */
|
||||
async function* whole(text: string): AsyncIterable<string> {
|
||||
@@ -34,7 +35,7 @@ describe('buildWindow', () => {
|
||||
})
|
||||
|
||||
it('applies offset/limit', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f')
|
||||
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f')
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.totalLines).toBe(4)
|
||||
})
|
||||
@@ -62,7 +63,7 @@ describe('buildWindow', () => {
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
@@ -76,9 +77,22 @@ describe('buildWindow', () => {
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('caps are per-request (the plugin config reaches the window)', () => {
|
||||
it('truncates lines at a custom maxLineLength and names it in the suffix', async () => {
|
||||
const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f')
|
||||
expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)')
|
||||
})
|
||||
|
||||
it('caps output at a custom maxBytes', async () => {
|
||||
const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunked input (streamed read shape)', () => {
|
||||
it('windows identically when text arrives in small chunks', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f')
|
||||
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f')
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
@@ -495,3 +495,71 @@ describe('result-time contextual diff (meta + presentResult)', () => {
|
||||
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('read caps are plugin config', () => {
|
||||
async function setupWith(config: ToolFs.Config) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs, config)
|
||||
return { ctx, fs: ctx.fs as FakeFs }
|
||||
}
|
||||
|
||||
it('a configured readLimit is both the default and the cap, and the schema names it', async () => {
|
||||
const { ctx, fs } = await setupWith({ readLimit: 2 })
|
||||
fs.files.set('key:a.txt', 'one\ntwo\nthree\nfour')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('(Showing lines 1-2 of 4. Use offset=3 to continue.)')
|
||||
const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 })
|
||||
expect(overCap.isError).toBe(true)
|
||||
expect(text(overCap)).toContain('less than or equal to 2')
|
||||
const readSchema = ctx.tools.schemas().find(s => s.name === 'read')
|
||||
expect(JSON.stringify(readSchema)).toContain('Defaults to 2.')
|
||||
})
|
||||
|
||||
it('a configured readMaxLineLength truncates lines at the configured length', async () => {
|
||||
const { ctx, fs } = await setupWith({ readMaxLineLength: 4 })
|
||||
fs.files.set('key:a.txt', 'abcdefgh')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('1: abcd... (line truncated to 4 chars)')
|
||||
})
|
||||
|
||||
it('a configured readMaxBytes caps the window at the configured bytes', async () => {
|
||||
const { ctx, fs } = await setupWith({ readMaxBytes: 9 })
|
||||
fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('Output capped.')
|
||||
expect(text(result)).not.toContain('cccc')
|
||||
})
|
||||
|
||||
it('a configured readStreamMinSize routes smaller files to the streaming path', async () => {
|
||||
const { ctx, fs } = await setupWith({ readStreamMinSize: 5 })
|
||||
fs.files.set('key:a.txt', 'alpha\nbeta')
|
||||
const readSpy = vi.spyOn(fs, 'readText')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
expect(readSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['readLimit', { readLimit: 0 }],
|
||||
['readLimit', { readLimit: 2.5 }],
|
||||
['readMaxLineLength', { readMaxLineLength: -1 }],
|
||||
['readMaxBytes', { readMaxBytes: Number.NaN }],
|
||||
['readStreamMinSize', { readStreamMinSize: 0 }],
|
||||
] 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(FakeFs)
|
||||
await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive integer`))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
expect('default' in ToolFs).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
|
||||
Reference in New Issue
Block a user