2026-06-22 10:48:41 +08:00
/**
2026-07-13 23:27:00 +08:00
* Model-facing UTF-8 read. It performs one provider stat for type, routing, and observed version,
* streams large or size-unknown files, renders a bounded window, then emits the observation.
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-30 17:03:05 +08:00
import type { GenericCallView , ReadResultView , ToolResult } from '@deepseek-ai/dsh-tools'
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-07-30 17:03:05 +08:00
import { buildWindow , formatReadOutput , langFromPath , readMetaFromMeta } from './read-render.ts'
2026-07-14 19:50:25 +08:00
import { sessionResolveOptions } 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
} ,
2026-07-21 03:08:35 +08:00
output : {
schema : {
type : 'object' ,
additionalProperties : false ,
properties : {
path : { type : 'string' , required : true } ,
offset : { type : 'integer' , required : true } ,
lines : {
type : 'array' ,
required : true ,
items : {
type : 'object' ,
additionalProperties : false ,
properties : {
number : { type : 'integer' , required : true } ,
text : { type : 'string' , required : true } ,
} ,
} ,
} ,
totalLines : { type : 'integer' , required : true } ,
} ,
} ,
render : ( args , value ) = > {
const input = parseReadArgs ( args , caps . limit )
const endLine = value . lines . at ( - 1 ) ? . number ? ? Math . max ( 0 , value . offset - 1 )
const truncatedByBytes = value . lines . length < input . limit && endLine < value . totalLines
return [ {
type : 'text' ,
text : formatReadOutput ( value . path , {
offset : value.offset ,
lines : value.lines ,
totalLines : value.totalLines ,
. . . truncatedByBytes ? { truncatedByBytes : true } : { } ,
} ) ,
} ]
} ,
2026-07-30 17:03:05 +08:00
// Project the structured window into persisted `meta` so a UI's read card
// survives replay: the raw canonical output object is not on the wire, only
// the model-facing text, from which the line/lang data cannot be recovered.
presentationMeta : ( _args , value ) = > {
const lang = langFromPath ( value . path )
return {
path : value.path ,
2026-07-30 22:00:35 +08:00
offset : value.offset ,
2026-07-30 17:03:05 +08:00
lines : value.lines.map ( ( { number , text } ) = > ( { number , text } ) ) ,
totalLines : value.totalLines ,
. . . lang === undefined ? { } : { lang } ,
}
} ,
2026-07-21 03:08:35 +08:00
} ,
2026-07-18 14:59:26 +08:00
// Observation races fail closed because guarded mutations re-check the version in-lock.
2026-07-13 11:02:21 +08:00
isConcurrencySafe : ( ) = > true ,
2026-07-21 03:08:35 +08:00
async execute ( args , exec ) {
2026-07-04 17:37:23 +08:00
const input = parseReadArgs ( args , caps . limit )
2026-07-21 20:49:14 +08:00
const target = await ctx . fs . resolve ( input . filePath , sessionResolveOptions ( exec , input . filePath ) )
2026-06-28 13:49:02 +08:00
// One stat: type check + size routing + the version recorded as observed.
2026-07-13 23:27:00 +08:00
// A concurrent write can only make a later guarded mutation fail stale and require reread.
2026-06-28 13:49:02 +08:00
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
2026-07-21 03:08:35 +08:00
const outcome = {
path : target.displayPath ,
2026-06-28 13:49:02 +08:00
offset : input.offset ,
lines : window.lines ,
totalLines : window.totalLines ,
}
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-07-21 03:08:35 +08:00
return outcome
2026-06-22 10:48:41 +08:00
} ,
2026-07-30 17:03:05 +08:00
// Result-time display: a `read` card carrying the structured line window a
// capable UI renders as a line-numbered, syntax-highlighted view. The
// structured data is narrowed from the persisted `meta` (replay-safe); the
// envelope-stripped model-facing text rides along as `content` so a UI without
// the read capability still shows the file text. A malformed or absent meta,
// or a result whose text is not the read envelope, declines to `undefined`
// (the generic fallback), never throwing on replay of obsolete logged output.
presentResult ( _args , result : ToolResult ) : ReadResultView | undefined {
2026-07-27 17:53:18 +08:00
if ( result . isError ) return undefined
2026-07-30 17:03:05 +08:00
const meta = readMetaFromMeta ( result . meta )
if ( meta === undefined ) return undefined
2026-07-27 17:53:18 +08:00
const only = result . content . length === 1 ? result . content [ 0 ] : undefined
const text = only ? . type === 'text' ? only.text : undefined
if ( text === undefined ) return undefined
// Group 1 always captures (possibly empty) when the envelope matches.
const body = / ^ < p a t h > [ ^ \ n ] * < \ / p a t h > \ n < t y p e > f i l e < \ / t y p e > \ n < c o n t e n t > \ n ( [ \ s \ S ] * ) \ n < \ / c o n t e n t > $ / u . e x e c ( t e x t ) ? . [ 1 ]
if ( body === undefined ) return undefined
2026-07-30 17:03:05 +08:00
return {
card : 'read' ,
path : meta.path ,
2026-07-30 22:00:35 +08:00
offset : meta.offset ,
2026-07-30 17:03:05 +08:00
lines : meta.lines ,
totalLines : meta.totalLines ,
. . . meta . lang === undefined ? { } : { lang : meta.lang } ,
content : [ { type : 'text' , text : body } ] ,
}
2026-07-27 17:53:18 +08:00
} ,
2026-07-12 03:36:43 +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
2026-07-13 23:27:00 +08:00
// read's offset (defaulting to 1). The window reflects raw args, so an omitted limit keeps
// the title bare instead of smuggling config into this pure presenter.
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
} ) )
}