The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
144 lines
6.6 KiB
TypeScript
144 lines
6.6 KiB
TypeScript
/**
|
|
* The model-facing `read` tool: inspect a UTF-8 text file and return
|
|
* line-numbered content with pagination guidance. The tool is the executor — it
|
|
* stats and reads through `ctx.fs` directly, builds the line window
|
|
* ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed`
|
|
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
|
|
* no policy plugin the emit is simply unheard. This module owns the
|
|
* model-facing schema, argument validation, and the read I/O; the rendering
|
|
* (windowing + formatting) lives in `read-render.ts` and the
|
|
* freshness/observation policy is not its concern.
|
|
*
|
|
* @module @deepseek-ai/dsh-tool-fs/src/read
|
|
*/
|
|
|
|
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 { FsError } from '@deepseek-ai/dsh-fs'
|
|
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'
|
|
|
|
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
|
|
export const READ_LIMIT = 2000
|
|
|
|
/**
|
|
* 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
|
|
offset: number
|
|
limit: number
|
|
}
|
|
|
|
function parsePositiveInteger(value: number, name: string): number {
|
|
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
|
throw new Error(`${name} must be a positive integer`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
/** Validate value constraints the schema DSL can't express. `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 ? 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, caps: ReadToolCaps): void {
|
|
ctx.systemPrompt.section({
|
|
name: 'tool:read',
|
|
order: 100,
|
|
text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
|
|
})
|
|
|
|
ctx.tools.register(defineTool({
|
|
name: 'read',
|
|
description: 'Read a UTF-8 text file and return line-numbered content.',
|
|
parameters: {
|
|
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
|
|
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
|
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
|
|
},
|
|
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)
|
|
|
|
// One stat: type check + size routing + the version recorded as observed.
|
|
// A writer racing between this stat and the read can at worst make a LATER
|
|
// guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText
|
|
// re-checks the version in its lock).
|
|
const info = await ctx.fs.stat(target, exec.signal)
|
|
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
|
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
|
|
|
// 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 >= 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, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes },
|
|
target.displayPath,
|
|
)
|
|
|
|
const outcome: FileReadOutcome = {
|
|
offset: input.offset,
|
|
limit: input.limit,
|
|
lines: window.lines,
|
|
totalLines: window.totalLines,
|
|
version: info.version,
|
|
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
|
}
|
|
// Record the observed version (a no-op when no policy plugin listens). The
|
|
// read already succeeded; an fs/observed listener is contractually a
|
|
// synchronous, side-effect-only recorder.
|
|
ctx.emit('fs/observed', target, info.version, exec)
|
|
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
|
},
|
|
// Pure display: a generic card titled by the file with the read window
|
|
// 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/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
|
|
? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})`
|
|
: offset !== undefined ? ` (from line ${offset})` : ''
|
|
return {
|
|
card: 'generic',
|
|
title: `Read ${args.file_path}${window}`,
|
|
kind: 'read',
|
|
locations: [{ path: args.file_path, line: offset ?? 1 }],
|
|
}
|
|
},
|
|
}))
|
|
}
|