2026-06-22 10:48:41 +08:00
/**
* The model-facing `read` tool: inspect a UTF-8 text file and return
2026-06-28 13:49:02 +08:00
* line-numbered content with pagination guidance. The tool is the executor — it
* stats and reads through `ctx.fs` directly, builds the line window
2026-06-29 10:34:08 +08:00
* ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed`
2026-07-02 03:12:38 +08:00
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
2026-06-29 10:34:08 +08:00
* 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.
2026-06-22 10:48:41 +08:00
*
2026-06-28 17:43:29 +08:00
* @module @deepseek-ai/dsh-tool-fs/src/read
2026-06-22 10:48:41 +08:00
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-03 02:04:03 +08:00
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
2026-06-22 10:48:41 +08:00
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
2026-06-28 13:49:02 +08:00
import { FsError } from '@deepseek-ai/dsh-fs'
import type { } from '@deepseek-ai/dsh-fs'
2026-06-22 10:48:41 +08:00
import type { } from '@deepseek-ai/dsh-system-prompt'
2026-06-29 10:34:08 +08:00
import { buildWindow , formatReadOutput } from './read-render.ts'
import type { FileReadOutcome } from './read-render.ts'
2026-07-02 18:29:29 +08:00
import { sessionCwd } from './session-cwd.ts'
2026-06-22 10:48:41 +08:00
2026-07-04 17:37:23 +08:00
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
2026-06-22 10:48:41 +08:00
export const READ_LIMIT = 2000
2026-07-04 17:37:23 +08:00
/**
* Default streaming threshold (the `readStreamMinSize` config): files at or
* above this size stream; smaller files read whole into memory.
*/
2026-06-28 13:49:02 +08:00
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
2026-07-04 17:37:23 +08:00
/** 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
}
2026-06-22 10:48:41 +08:00
/** 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
}
2026-07-06 22:09:30 +08:00
/**
* Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap.
* @param args - the schema-validated raw tool arguments; `offset`/`limit` must be positive integers when given.
* @param maxLimit - the configured line cap: both the default `limit` and the largest one accepted.
* @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`.
*/
2026-07-04 17:37:23 +08:00
export function parseReadArgs ( args : { file_path : string ; offset? : number ; limit? : number } , maxLimit : number ) : ReadInput {
2026-06-22 10:48:41 +08:00
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' )
2026-07-04 17:37:23 +08:00
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 } ` )
2026-06-22 10:48:41 +08:00
return { filePath : args.file_path , offset , limit }
}
2026-07-06 22:09:30 +08:00
/**
* Register the `read` tool and its system-prompt guidance.
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
* @param caps - the deployment's resolved read caps (plugin config after defaulting).
*/
2026-07-04 17:37:23 +08:00
export function applyReadTool ( ctx : Context , caps : ReadToolCaps ) : void {
2026-06-22 10:48:41 +08:00
ctx . systemPrompt . section ( {
name : 'tool:read' ,
order : 100 ,
2026-07-05 01:54:46 +08:00
text : 'Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.' ,
2026-06-22 10:48:41 +08:00
} )
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.' } ,
2026-07-04 17:37:23 +08:00
limit : { type : 'number' , description : ` Maximum number of lines to return. Defaults to ${ caps . limit } . ` } ,
2026-06-22 10:48:41 +08:00
} ,
async execute ( args , exec ) : Promise < ContentBlock [ ] > {
2026-07-04 17:37:23 +08:00
const input = parseReadArgs ( args , caps . limit )
2026-07-02 18:29:29 +08:00
const cwd = sessionCwd ( exec )
const target = await ctx . fs . resolve ( input . filePath , cwd !== undefined ? { cwd } : undefined )
2026-06-28 13:49:02 +08:00
// 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.
2026-07-04 17:37:23 +08:00
const chunks = info . size === undefined || info . size >= caps . streamMinSize
2026-06-28 13:49:02 +08:00
? await ctx . fs . streamText ( target , exec . signal )
: [ await ctx . fs . readText ( target , exec . signal ) ]
2026-07-04 17:37:23 +08:00
const window = await buildWindow (
chunks ,
{ offset : input.offset , limit : input.limit , maxLineLength : caps.maxLineLength , maxBytes : caps.maxBytes } ,
target . displayPath ,
)
2026-06-28 13:49:02 +08:00
const outcome : FileReadOutcome = {
offset : input.offset ,
lines : window.lines ,
totalLines : window.totalLines ,
. . . window . truncatedByBytes ? { truncatedByBytes : true } : { } ,
}
2026-06-29 10:34:08 +08:00
// 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 )
2026-06-22 10:48:41 +08:00
return [ { type : 'text' , text : formatReadOutput ( target . displayPath , outcome ) } ]
} ,
2026-07-03 02:04:03 +08:00
// 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
2026-07-04 17:37:23 +08:00
// 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).
2026-07-03 02:04:03 +08:00
presentCall ( args ) : GenericCallView {
const { offset , limit } = args
const window = limit !== undefined && limit > 0
? ` ( ${ offset ? ? 1 } - ${ ( offset ? ? 1 ) + limit - 1 } ) `
: offset !== undefined ? ` (from line ${ offset } ) ` : ''
2026-07-02 19:36:17 +08:00
return {
2026-07-03 02:04:03 +08:00
card : 'generic' ,
title : ` Read ${ args . file_path } ${ window } ` ,
2026-07-02 19:36:17 +08:00
kind : 'read' ,
2026-07-03 02:04:03 +08:00
locations : [ { path : args.file_path , line : offset ? ? 1 } ] ,
2026-07-02 19:36:17 +08:00
}
} ,
2026-06-22 10:48:41 +08:00
} ) )
}