summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools/read-file-slice.ts
blob: 0f83bcbe99fb9d09ccfc362e5ef28cff0b5b4921 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { readFile } from "node:fs/promises";
import { z } from "zod";
import type { ToolDefinition } from "../types/index.js";
import { canonicalize } from "./path-utils.js";
import { SPILL_ROOT } from "./truncate.js";

const DEFAULT_LENGTH = 5000;

export function createReadFileSliceTool(workingDirectory: string): ToolDefinition {
	return {
		name: "read_file_slice",
		description:
			"Read a character-range slice of a single line in a file. Use this when read_file returns a truncated line marker like '[line N truncated, total X chars; use read_file_slice ...]', or when you need precise byte-ish access into a minified file (huge JSON, base64 blob, etc.). For normal line-oriented reading use read_file with offset/limit instead.",
		parameters: z.object({
			path: z.string().describe("Path to the file, relative to the working directory."),
			line: z.number().int().min(1).describe("1-indexed line number to slice into."),
			charOffset: z
				.number()
				.int()
				.min(0)
				.optional()
				.describe("0-indexed character offset within the line. Default: 0 (start of line)."),
			charLength: z
				.number()
				.int()
				.min(1)
				.optional()
				.describe(
					`Max characters to return from the slice. Default: ${DEFAULT_LENGTH}. The universal tool-output truncator will still spill if the result is huge.`,
				),
		}),
		execute: async (args: Record<string, unknown>): Promise<string> => {
			const filePath = args.path as string;
			const lineNumber = args.line as number;
			const charOffset =
				typeof args.charOffset === "number" ? Math.max(0, Math.floor(args.charOffset)) : 0;
			const charLength =
				typeof args.charLength === "number"
					? Math.max(1, Math.floor(args.charLength))
					: DEFAULT_LENGTH;

			// Canonicalize all three so symlink-in-workdir escapes are detected.
			// See `canonicalize` in ./path-utils.ts for the resolution semantics.
			const absolutePath = await canonicalize(workingDirectory, filePath);
			const absoluteWorkDir = await canonicalize(workingDirectory);
			const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
			const isUnderWorkdir =
				absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
			const isSpillFile =
				absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);

			if (!isUnderWorkdir && !isSpillFile) {
				return `Error: Path "${filePath}" is outside the working directory.`;
			}

			let raw: string;
			try {
				raw = await readFile(absolutePath, "utf8");
			} catch (err) {
				const code = (err as NodeJS.ErrnoException).code;
				if (code === "ENOENT") {
					return `Error: File "${filePath}" not found.`;
				}
				return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
			}

			const lines = raw.split("\n");
			const trailingNewline = raw.endsWith("\n");
			const totalLines = trailingNewline ? lines.length - 1 : lines.length;

			if (lineNumber > totalLines) {
				return `Error: line ${lineNumber} exceeds file length (${totalLines} lines).`;
			}

			const line = lines[lineNumber - 1] ?? "";
			const lineLength = line.length;

			if (charOffset >= lineLength) {
				return `Error: charOffset ${charOffset} exceeds line length (${lineLength} chars).`;
			}

			const sliceEnd = Math.min(charOffset + charLength, lineLength);
			const slice = line.slice(charOffset, sliceEnd);
			const remaining = lineLength - sliceEnd;

			const header = `[file: ${filePath} — line ${lineNumber}, chars ${charOffset}-${sliceEnd} of ${lineLength}${remaining > 0 ? ` (${remaining.toLocaleString()} chars remain after this slice)` : ""}]`;
			return `${header}\n${slice}`;
		},
	};
}