import { resolve } from "node:path"; import type { ExecBackend, ExecBackendResolver, StatResult } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; const DEFAULT_LIMIT = 500; const HARD_CAP = 5000; interface ValidatedArgs { readonly path: string; readonly offset: number; readonly limit: number; } /** Pure: validate and coerce args from the model. */ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { if (args === null || args === undefined || typeof args !== "object") { return { error: "Error: Arguments must be an object." }; } const obj = args as Record; const rawPath = obj.path; if (typeof rawPath !== "string" || rawPath.length === 0) { return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; } let offset = 1; if (obj.offset !== undefined) { const n = Number(obj.offset); if (!Number.isFinite(n) || n < 1) { return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; } offset = Math.floor(n); } let limit = DEFAULT_LIMIT; if (obj.limit !== undefined) { const n = Number(obj.limit); if (!Number.isFinite(n) || n < 1) { return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; } limit = Math.min(Math.floor(n), HARD_CAP); } else { limit = Math.min(limit, HARD_CAP); } return { path: rawPath, offset, limit }; } /** Pure: slice lines from content (1-indexed offset). */ export function sliceLines( content: string, offset: number, limit: number, ): { readonly lines: readonly string[]; readonly totalLines: number } { const allLines = content.split("\n"); const totalLines = allLines.length; const start = offset - 1; // convert to 0-indexed const sliced = allLines.slice(start, start + limit); return { lines: sliced, totalLines }; } /** Pure: render lines into a string with line numbers. */ export function renderLines(lines: readonly string[], offset: number): string { return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); } /** A directory entry with its type. */ export interface DirEntry { readonly name: string; readonly isDirectory: boolean; } /** * Pure: format directory entries into a sorted listing string. * Subdirectories get a trailing `/`. Empty input returns an empty-directory message. */ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: string): string { if (entries.length === 0) { return `(empty directory: ${dirPath})`; } const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); } /** * Factory: create a read_file ToolContract. * * `resolveBackend` is the injected seam: each `execute` resolves an * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set * id → a remote SSH backend in a later wave). The tool programs against the * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic. * * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ export function createReadFileTool(deps: { readonly resolveBackend: ExecBackendResolver; readonly workdir?: string; }): ToolContract { const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; return { name: "read_file", description: "Read the contents of a file or list a directory's contents. " + "For files, returns lines with 1-indexed line numbers. " + "Supports offset/limit for reading specific sections of large files. " + "For directories, returns sorted entries with subdirectories suffixed by /.", parameters: { type: "object", properties: { path: { type: "string", description: "Path to the file, relative to the working directory.", }, offset: { type: "number", description: "1-indexed start line number (default: 1).", default: 1, }, limit: { type: "number", description: "Maximum number of lines to return (default: 500, hard cap: 5000).", default: 500, }, }, required: ["path"], }, concurrencySafe: true, async execute(args: unknown, ctx): Promise { const validated = validateArgs(args); if ("error" in validated) { return { content: validated.error, isError: true }; } const { path: relPath, offset, limit } = validated; const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; if (effectiveBase === undefined) { return { content: "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", isError: true, }; } const resolvedPath = resolve(effectiveBase, relPath); const backend: ExecBackend = deps.resolveBackend(ctx.computerId); // Stat to determine if this is a file or directory. let pathStat: StatResult; try { pathStat = await backend.stat(resolvedPath); } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { return { content: `Error: File "${relPath}" not found.`, isError: true }; } return { content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`, isError: true, }; } // Directory listing branch. backend.readdir already returns // {name, isDirectory}[] entries, so no per-entry collapse is needed. if (pathStat.isDirectory) { let entries: readonly DirEntry[]; try { entries = await backend.readdir(resolvedPath); } catch (err: unknown) { return { content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, isError: true, }; } return { content: formatDirectoryEntries(entries, relPath) }; } // File branch — read the file. let content: string; try { content = await backend.readFile(resolvedPath); } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { return { content: `Error: File "${relPath}" not found.`, isError: true }; } return { content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, isError: true, }; } // Handle empty file. if (content.length === 0) { return { content: `(empty file: ${relPath})` }; } // Apply offset/limit line slicing. const { lines, totalLines } = sliceLines(content, offset, limit); if (offset > totalLines) { return { content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, isError: true, }; } return { content: renderLines(lines, offset) }; }, }; }