@sema-agent/core 7.41.1 → 7.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/agents/subagent.js +2 -0
  3. package/dist/core/agent-definition.d.ts +1 -1
  4. package/dist/core/allow-entry.d.ts +241 -0
  5. package/dist/core/allow-entry.js +340 -0
  6. package/dist/core/allow-rule-shadow.d.ts +18 -0
  7. package/dist/core/allow-rule-shadow.js +1 -0
  8. package/dist/core/ask-origin.d.ts +5 -5
  9. package/dist/core/ask-origin.js +1 -1
  10. package/dist/core/auto-mode.d.ts +3 -2
  11. package/dist/core/checkpoint-store.d.ts +11 -19
  12. package/dist/core/checkpoint-store.js +1 -1
  13. package/dist/core/decision-receipt.d.ts +4 -2
  14. package/dist/core/decision-receipt.js +1 -1
  15. package/dist/core/engine-notice.d.ts +8 -5
  16. package/dist/core/gate-fold.js +1 -1
  17. package/dist/core/gate-lanes.d.ts +1 -49
  18. package/dist/core/gate-lanes.js +85 -223
  19. package/dist/core/governance-codes.d.ts +3 -3
  20. package/dist/core/governance-codes.js +3 -3
  21. package/dist/core/hooks.d.ts +13 -10
  22. package/dist/core/hooks.js +5 -3
  23. package/dist/core/mode-policy.d.ts +3 -3
  24. package/dist/core/mode-policy.js +2 -2
  25. package/dist/core/permission-rules.d.ts +24 -23
  26. package/dist/core/permission-rules.js +37 -33
  27. package/dist/core/reminder-disclosure.d.ts +53 -2
  28. package/dist/core/reminder-disclosure.js +49 -36
  29. package/dist/core/rule-lane-reads.d.ts +84 -0
  30. package/dist/core/rule-lane-reads.js +164 -0
  31. package/dist/core/runner/contracts.d.ts +4 -2
  32. package/dist/core/runner/gate-exit.d.ts +3 -3
  33. package/dist/core/runner/image.js +4 -3
  34. package/dist/core/runner/inline-image.d.ts +14 -0
  35. package/dist/core/runner/inline-image.js +26 -0
  36. package/dist/core/runner/permission-rule-lanes.d.ts +3 -1
  37. package/dist/core/runner/permission-rule-lanes.js +1 -1
  38. package/dist/core/runner/prepare-ask-lane.d.ts +0 -19
  39. package/dist/core/runner/prepare-ask-lane.js +2 -2
  40. package/dist/core/runner/prepare-inherited-gate.d.ts +2 -0
  41. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  42. package/dist/core/runner/prepare-park-ask.js +3 -3
  43. package/dist/core/runner/prepare-policy-chain.d.ts +12 -0
  44. package/dist/core/runner/prepare-policy-chain.js +10 -9
  45. package/dist/core/runner/prepare-project-context.d.ts +13 -3
  46. package/dist/core/runner/prepare-project-context.js +5 -4
  47. package/dist/core/runner/prepare-task.js +8 -5
  48. package/dist/core/runner/prepare-turn-wiring.d.ts +16 -3
  49. package/dist/core/runner/prepare-turn-wiring.js +19 -18
  50. package/dist/core/runner/run-leg.js +1 -1
  51. package/dist/core/runner/runtask.js +2 -1
  52. package/dist/core/runner-deps.d.ts +11 -0
  53. package/dist/core/skill-grant.d.ts +8 -47
  54. package/dist/core/skill-grant.js +15 -136
  55. package/dist/core/synthetic-read-verdict.d.ts +108 -0
  56. package/dist/core/synthetic-read-verdict.js +111 -0
  57. package/dist/core/task-spec.d.ts +18 -0
  58. package/dist/core/tool-name-readings.d.ts +1 -1
  59. package/dist/core/tool-name-readings.js +1 -1
  60. package/dist/core/tool-policy.d.ts +25 -24
  61. package/dist/core/tool-policy.js +26 -17
  62. package/dist/core/tool-spec.d.ts +7 -0
  63. package/dist/core/trace.d.ts +16 -0
  64. package/dist/index.d.ts +4 -2
  65. package/dist/index.js +3 -1
  66. package/dist/internal/harness-types.d.ts +1 -1
  67. package/dist/tools/fs/dir-entries.d.ts +69 -0
  68. package/dist/tools/fs/dir-entries.js +60 -0
  69. package/dist/tools/fs/fs-pdf.d.ts +7 -11
  70. package/dist/tools/fs/fs-pdf.js +5 -14
  71. package/dist/tools/fs/fs-read.d.ts +173 -2
  72. package/dist/tools/fs/fs-read.js +399 -286
  73. package/dist/tools/fs/image-admission.d.ts +51 -0
  74. package/dist/tools/fs/image-admission.js +36 -0
  75. package/dist/tools/fs/search.js +3 -7
  76. package/package.json +1 -1
  77. package/test/export-surface.snapshot.json +23 -5
  78. package/wire-changes/core@7.42.0.json +1107 -0
@@ -1,22 +1,408 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { sha256, resolveKey, violationText, violationDetails, binaryMagicFormat, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
3
+ import { sha256, resolveKey, violationText, violationDetails, binaryMagicFormat, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, } from "./safety.js";
4
4
  import { decodeTextBytes } from "./encoding.js";
5
5
  import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
6
6
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js";
7
7
  import { PDF_MAX_PAGES_PER_READ, pdfMagicMatches } from "./pdf.js";
8
- import { MAX_READ_BYTES, SLICED_READ_MAX_BYTES, MAX_IMAGE_READ_BYTES, MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES, NO_DOWNSAMPLER_IMAGE_CAP_HINT, resolveAutoDownsampler, MAX_READ_OUTPUT_CHARS, FILE_PATH_PARAMS, countLines, seededFileUnchangedReminder, enoentMessage, } from "./fs-shared.js";
9
- import { readPdfFile, pdfResultToToolReturn } from "./fs-pdf.js";
8
+ import { MAX_READ_BYTES, SLICED_READ_MAX_BYTES, NO_DOWNSAMPLER_IMAGE_CAP_HINT, resolveAutoDownsampler, MAX_READ_OUTPUT_CHARS, FILE_PATH_PARAMS, countLines, seededFileUnchangedReminder, enoentMessage, } from "./fs-shared.js";
9
+ import { readPdfFile } from "./fs-pdf.js";
10
10
  import { openSystemReminder } from "../../core/reminder-mint.js";
11
- import { discloseReminderShaped } from "../../core/reminder-disclosure.js";
11
+ import { planReminderDisclosure, commitReminderDisclosureEffects, renderReminderDisclosureTrailer, } from "../../core/reminder-disclosure.js";
12
12
  import { toolFace } from "../../core/tool-catalog-entries.js";
13
+ import { admitImageBytes, imageReadCap } from "./image-admission.js";
14
+ export const READ_OUTCOMES = Object.freeze(["attached", "partial", "unchanged", "not_attached"]);
15
+ const NO_EFFECTS = () => ({ disclosure: [] });
16
+ export function createModelFileReader(opts) {
17
+ const { env, state, rootCanonical, cwdRef, additionalRoots, imageDownsampler, pdfCapabilities, bgOutputReadExemption, readDeny, readFace, reminderMark } = opts;
18
+ const windows = opts.disclosureWindows ?? new Map();
19
+ const refuse = (envelope, kind) => ({
20
+ outcome: "not_attached",
21
+ ...(kind !== undefined ? { kind } : {}),
22
+ data: [],
23
+ notes: [],
24
+ refusal: envelope,
25
+ effects: NO_EFFECTS(),
26
+ });
27
+ const disclosureOf = (segments, outlet, key) => {
28
+ const plan = planReminderDisclosure({ segments, mark: reminderMark, outlet, defuseExactMark: false, throttle: { key, windows } });
29
+ const verdict = plan.verdict;
30
+ return { ...(verdict.reminder !== undefined || verdict.envelopeTags !== undefined ? { note: { kind: "disclosure", verdict } } : {}), effects: plan.effects };
31
+ };
32
+ const readForModel = async (args, ctx) => {
33
+ const { offset, limit, pages } = (args ?? {});
34
+ const path = fileArgPath(args);
35
+ if (path === undefined)
36
+ return refuse(errorResult(`Error (Read): file_path is required.`));
37
+ const unchanged = (note, kind, reason, startLine, endLine, totalLines) => ({
38
+ outcome: "unchanged",
39
+ kind,
40
+ data: [],
41
+ notes: [note],
42
+ details: { type: "file_unchanged", reason, file: { filePath: path, startLine, endLine, totalLines } },
43
+ effects: NO_EFFECTS(),
44
+ });
45
+ const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx), readDeny, readFace);
46
+ if (!r.ok)
47
+ return refuse(errorResult(violationText("Read", r.violation), violationDetails(r.violation)));
48
+ const isNb = isNotebookPath(r.key);
49
+ const imageMime = imageMimeForRead(r.key);
50
+ if (imageMime !== undefined) {
51
+ const meta = await env.fileInfo(r.key, ctx.signal);
52
+ if (!meta.ok) {
53
+ const ex = await env.exists(r.key, ctx.signal);
54
+ if (ex.ok && !ex.value)
55
+ return refuse(errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal, readDeny)}`), "image");
56
+ return refuse(errorResult(`Error (Read): cannot stat image "${path}" to verify its size before reading: ${meta.error.message}`), "image");
57
+ }
58
+ if (meta.value.kind === "directory") {
59
+ return refuse(errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`), "image");
60
+ }
61
+ const downsampler = imageDownsampler === false ? undefined : (imageDownsampler ?? (await resolveAutoDownsampler()));
62
+ const readCap = imageReadCap(downsampler);
63
+ const tooLargeToRead = (bytes) => `Error (Read): image "${path}" is too large to read (${bytes} bytes > ${readCap}-byte cap).` +
64
+ (downsampler ? "" : ` ${NO_DOWNSAMPLER_IMAGE_CAP_HINT}`);
65
+ if (meta.value.size > readCap)
66
+ return refuse(errorResult(tooLargeToRead(meta.value.size)), "image");
67
+ const bin = await env.readBinaryFile(r.key, ctx.signal);
68
+ if (!bin.ok)
69
+ return refuse(errorResult(`Error (Read): cannot read image "${path}": ${bin.error.message}`), "image");
70
+ const admitted = await admitImageBytes(bin.value, imageMime, downsampler);
71
+ if (!admitted.ok) {
72
+ switch (admitted.reason) {
73
+ case "raw_too_large":
74
+ return refuse(errorResult(tooLargeToRead(bin.value.byteLength)), "image");
75
+ case "magic_mismatch":
76
+ case "mime_unsupported":
77
+ return refuse(errorResult(`Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named).`), "image");
78
+ case "base64_too_large":
79
+ return refuse(errorResult(`Error (Read): image "${path}" is too large to send (${bin.value.byteLength} bytes; base64 ${admitted.base64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit` +
80
+ (downsampler
81
+ ? "; downsampling could not bring it under the limit)."
82
+ : `; raw images over ${IMAGE_TARGET_RAW_SIZE} bytes always exceed it — shrink it first, e.g. with an image tool via bash).`)), "image");
83
+ }
84
+ }
85
+ const { block, dimensions, originalSize, resized } = admitted;
86
+ return {
87
+ outcome: "attached",
88
+ kind: "image",
89
+ data: [block],
90
+ notes: resized && dimensions !== undefined ? [{ kind: "image_resized", dimensions }] : [],
91
+ details: {
92
+ type: "image",
93
+ file: { ...(block.data.length <= 48_000 ? { base64: block.data } : {}), type: block.mimeType, originalSize, ...(dimensions ? { dimensions } : {}) },
94
+ },
95
+ effects: NO_EFFECTS(),
96
+ };
97
+ }
98
+ const pdfOf = async (preRead) => {
99
+ const effects = [];
100
+ const out = await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, preRead, pdfCapabilities, readDeny, {
101
+ mark: reminderMark,
102
+ windows,
103
+ key: r.key,
104
+ effects,
105
+ });
106
+ if (typeof out === "string")
107
+ return refuse(errorResult(out), "pdf");
108
+ const details = out.details;
109
+ const outcome = details?.fallback?.level === 3 ? "not_attached" : details?.file?.timedOut === true ? "partial" : "attached";
110
+ return { outcome, kind: "pdf", data: out.content, notes: [], details: out.details, effects: { disclosure: effects } };
111
+ };
112
+ if (r.key.toLowerCase().endsWith(".pdf"))
113
+ return pdfOf(undefined);
114
+ const binaryExt = hasBinaryExtension(r.key);
115
+ const binaryExtRefusal = () => refuse(errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`), "text");
116
+ const info = await env.fileInfo(r.key, ctx.signal);
117
+ if (info.ok) {
118
+ if (info.value.kind === "directory") {
119
+ return refuse(errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`), "text");
120
+ }
121
+ if (info.value.size > SLICED_READ_MAX_BYTES) {
122
+ if (binaryExt)
123
+ return binaryExtRefusal();
124
+ return refuse(errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
125
+ `(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
126
+ `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`), "text");
127
+ }
128
+ if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) {
129
+ if (binaryExt)
130
+ return binaryExtRefusal();
131
+ return refuse(errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`), "text");
132
+ }
133
+ }
134
+ else {
135
+ const ex = await env.exists(r.key, ctx.signal);
136
+ if (ex.ok && !ex.value)
137
+ return refuse(errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal, readDeny)}`), "text");
138
+ return refuse(errorResult(`Error (Read): cannot determine the type of "${path.slice(0, 300)}" (stat failed: ${info.error.message.slice(0, 200)}); refusing to open it — if it is a FIFO/socket/device, reading it would hang. Retry if this was transient, or inspect it with Bash (e.g. \`file\`, \`ls -l\`).`), "text");
139
+ }
140
+ const readBin = await env.readBinaryFile(r.key, ctx.signal);
141
+ if (!readBin.ok)
142
+ return refuse(errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`), "text");
143
+ const readSize = readBin.value.byteLength;
144
+ if (readSize > SLICED_READ_MAX_BYTES) {
145
+ if (binaryExt)
146
+ return binaryExtRefusal();
147
+ return refuse(errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
148
+ `(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
149
+ `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`), "text");
150
+ }
151
+ if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) {
152
+ if (binaryExt)
153
+ return binaryExtRefusal();
154
+ return refuse(errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`), "text");
155
+ }
156
+ if (pdfMagicMatches(readBin.value))
157
+ return pdfOf(readBin.value);
158
+ const magicFormat = binaryMagicFormat(readBin.value);
159
+ if (magicFormat !== undefined) {
160
+ return refuse(errorResult(`Error (Read): "${path}" appears to be a binary file (${magicFormat}, identified by its file signature — whatever its extension says); ` +
161
+ `this tool reads UTF-8 and BOM-marked UTF-16LE text only. Inspect or convert it with bash instead.`), "text");
162
+ }
163
+ const decoded = decodeTextBytes(readBin.value);
164
+ if (decoded.malformed) {
165
+ return refuse(errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`), "text");
166
+ }
167
+ const content = decoded.text;
168
+ if (isBinaryContent(content.slice(0, 8192))) {
169
+ return refuse(errorResult(`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` +
170
+ `If it is UTF-16 without a BOM or a legacy encoding, convert it first (e.g. \`iconv -f UTF-16LE -t UTF-8\`) or inspect/transform it with bash.`), "text");
171
+ }
172
+ const nbParsed = isNb ? parseNotebookCells(content, path) : undefined;
173
+ const nbForced = nbParsed !== undefined && nbParsed.ok;
174
+ const effOffset = nbForced ? undefined : offset;
175
+ const effLimit = nbForced ? undefined : limit;
176
+ const lines = content.length === 0 ? [] : content.split("\n");
177
+ const total = lines.length;
178
+ const start = Math.max(1, Math.floor(effOffset ?? 1));
179
+ const max = effLimit !== undefined ? Math.max(1, Math.floor(effLimit)) : Number.MAX_SAFE_INTEGER;
180
+ if (total > 0 && start > total) {
181
+ return { outcome: "not_attached", kind: "text", data: [], notes: [{ kind: "past_eof", offset: start, totalLines: total }], effects: NO_EFFECTS() };
182
+ }
183
+ let slice = lines.slice(start - 1, start - 1 + max);
184
+ let end = start - 1 + slice.length;
185
+ if (end === total - 1 && lines[total - 1] === "") {
186
+ slice = lines.slice(start - 1, total);
187
+ end = total;
188
+ }
189
+ const hash = sha256(content);
190
+ const notes = [];
191
+ if (nbParsed !== undefined) {
192
+ if (nbParsed.ok) {
193
+ const parsed = nbParsed;
194
+ const rendered = renderNotebookCells(parsed.cells, NOTEBOOK_IMAGE_BASE64_BUDGET);
195
+ if (rendered.textChars > MAX_READ_OUTPUT_CHARS) {
196
+ return refuse(errorResult(`Error (Read): notebook "${path}" projects to ~${Math.round(rendered.textChars / 4)} tokens of cell content, over the 25000-token limit ` +
197
+ `(offset/limit do not apply to notebooks — the whole notebook is always projected). Inspect portions with Bash + jq instead, e.g.\n` +
198
+ ` cat "${path}" | jq '.cells[:20]' # First 20 cells\n` +
199
+ ` cat "${path}" | jq '.cells | length' # Count total cells\n` +
200
+ ` cat "${path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`), "notebook");
201
+ }
202
+ const prevNb = state.get(r.key);
203
+ if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) {
204
+ return unchanged({ kind: "already_in_context", key: r.key }, "notebook", "already-in-context", 1, total, total);
205
+ }
206
+ if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
207
+ return unchanged({ kind: "unchanged", path, start: 1, end: total, total }, "notebook", "unchanged-since-last-read", 1, total, total);
208
+ }
209
+ const observation = { key: r.key, entry: { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total } } };
210
+ if (rendered.blocks.length === 0)
211
+ notes.push({ kind: "notebook_no_cells" });
212
+ const disclosure = disclosureOf(rendered.blocks.filter((b) => b.type === "text").map((b) => b.text), "notebook", r.key);
213
+ if (disclosure.note !== undefined)
214
+ notes.push(disclosure.note);
215
+ return {
216
+ outcome: "attached",
217
+ kind: "notebook",
218
+ data: rendered.blocks,
219
+ notes,
220
+ details: { type: "notebook", file: { filePath: path, cells: parsed.cells.map(stripNotebookImageData) } },
221
+ observation,
222
+ effects: { disclosure: [disclosure.effects] },
223
+ };
224
+ }
225
+ notes.push({ kind: "notebook_fallback" });
226
+ }
227
+ const renderBody = (sl) => sl.map((l, i) => `${start + i}\t${l}`).join("\n");
228
+ let body = renderBody(slice);
229
+ let partial;
230
+ if (body.length > MAX_READ_OUTPUT_CHARS) {
231
+ const defaultFullRead = start <= 1 && effLimit === undefined;
232
+ if (!defaultFullRead) {
233
+ return refuse(errorResult(`Error (Read): the requested range of "${path}" is ~${Math.round(body.length / 4)} tokens, over the 25000-token limit. Narrow it with offset/limit, or use grep to search the file.`), "text");
234
+ }
235
+ const approxTokens = Math.round(body.length / 4);
236
+ let n = Math.max(1, Math.min(slice.length, Math.floor(slice.length * (MAX_READ_OUTPUT_CHARS / body.length) * 0.95)));
237
+ let paged = slice.slice(0, n);
238
+ let pagedBody = renderBody(paged);
239
+ for (let attempt = 0; attempt < 8 && pagedBody.length > MAX_READ_OUTPUT_CHARS && n > 1; attempt++) {
240
+ n = Math.max(1, Math.floor(n * 0.7));
241
+ paged = slice.slice(0, n);
242
+ pagedBody = renderBody(paged);
243
+ }
244
+ if (pagedBody.length <= MAX_READ_OUTPUT_CHARS) {
245
+ slice = paged;
246
+ end = start - 1 + slice.length;
247
+ body = pagedBody;
248
+ partial = { kind: "partial_page", end, total, approxTokens };
249
+ }
250
+ else {
251
+ let cut = MAX_READ_OUTPUT_CHARS - 200;
252
+ const cc = slice[0].charCodeAt(cut - 1);
253
+ if (cc >= 0xd800 && cc <= 0xdbff)
254
+ cut -= 1;
255
+ slice = [slice[0].slice(0, cut)];
256
+ end = start;
257
+ body = renderBody(slice);
258
+ partial = { kind: "partial_chars", cut, totalChars: content.length, approxTokens };
259
+ }
260
+ }
261
+ const truncated = start > 1 || end < total || partial !== undefined;
262
+ const prev = state.get(r.key);
263
+ if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
264
+ return unchanged({ kind: "already_in_context", key: r.key }, "text", "already-in-context", 1, total, total);
265
+ }
266
+ if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
267
+ return unchanged({ kind: "unchanged", path, start, end, total }, "text", "unchanged-since-last-read", start, end, total);
268
+ }
269
+ const observation = {
270
+ key: r.key,
271
+ entry: { hash, totalLines: countLines(content), truncated, ...(partial !== undefined ? { isPartialView: true } : {}), view: { start, end } },
272
+ };
273
+ if (total === 0)
274
+ return { outcome: "attached", kind: "text", data: [], notes: [...notes, { kind: "empty_file" }], observation, effects: NO_EFFECTS() };
275
+ if (partial !== undefined)
276
+ notes.push(partial);
277
+ else if (truncated)
278
+ notes.push({ kind: "range", path, start, end, total });
279
+ const disclosure = disclosureOf([body], "read", r.key);
280
+ if (disclosure.note !== undefined)
281
+ notes.push(disclosure.note);
282
+ return {
283
+ outcome: partial !== undefined ? "partial" : "attached",
284
+ kind: "text",
285
+ data: [{ type: "text", text: body }],
286
+ notes,
287
+ details: {
288
+ type: "text",
289
+ file: {
290
+ filePath: path,
291
+ content: slice.join("\n"),
292
+ numLines: slice.length,
293
+ startLine: start,
294
+ totalLines: total,
295
+ ...(partial !== undefined ? { truncatedByTokenCap: true } : {}),
296
+ },
297
+ },
298
+ observation,
299
+ effects: { disclosure: [disclosure.effects] },
300
+ };
301
+ };
302
+ const committed = new WeakSet();
303
+ return {
304
+ readForModel,
305
+ commit(result, at) {
306
+ if (committed.has(result))
307
+ throw new Error("ModelFileReader.commit: this read result was already committed — a delivery commits its read once");
308
+ committed.add(result);
309
+ if (result.observation !== undefined) {
310
+ state.set(result.observation.key, {
311
+ ...result.observation.entry,
312
+ lastReadAt: at?.now ?? Date.now(),
313
+ transmittedByToolCallId: at?.transmittedByToolCallId,
314
+ });
315
+ }
316
+ for (const e of result.effects.disclosure)
317
+ commitReminderDisclosureEffects(e, { counts: opts.reminderDisclosureCounts, windows });
318
+ },
319
+ };
320
+ }
321
+ export function renderReadNoteForTool(note, mark) {
322
+ switch (note.kind) {
323
+ case "empty_file":
324
+ return `${openSystemReminder(mark)}Warning: the file exists but the contents are empty.</system-reminder>`;
325
+ case "past_eof":
326
+ return `${openSystemReminder(mark)}Warning: the file exists but is shorter than the provided offset (${note.offset}). The file has ${note.totalLines} lines.</system-reminder>`;
327
+ case "already_in_context":
328
+ return seededFileUnchangedReminder(note.key, mark);
329
+ case "unchanged":
330
+ return `[${note.path}: unchanged since you last read it (lines ${note.start}-${note.end} of ${note.total}); content omitted to save context]`;
331
+ case "range":
332
+ return `[${note.path}: lines ${note.start}-${note.end} of ${note.total}${note.end < note.total ? " — use offset to see more" : ""}]\n`;
333
+ case "partial_page":
334
+ return (`[Truncated: PARTIAL view — showing lines 1-${note.end} of ${note.total} total (~${note.approxTokens} tokens, cap 25000). ` +
335
+ `Call Read with offset=${note.end + 1} limit=${note.end} for the next page, or Grep to find a specific section. ` +
336
+ `Do NOT answer from this page alone if the answer may be further in the file.]\n`);
337
+ case "partial_chars":
338
+ return (`[Truncated: PARTIAL view — showing the first ${note.cut} of ${note.totalChars} characters (~${note.approxTokens} tokens, cap 25000); ` +
339
+ `this file has very long lines and cannot be paginated by line. Use Grep to find a specific section, or Read with offset/limit to page through it. ` +
340
+ `Do NOT answer from this excerpt alone if the answer may be elsewhere in the file.]\n`);
341
+ case "notebook_fallback":
342
+ return "[not a parseable notebook — showing raw file text]\n";
343
+ case "notebook_no_cells":
344
+ return "[notebook has 0 cells]";
345
+ case "image_resized": {
346
+ const d = note.dimensions;
347
+ return `[Image: original ${d.originalWidth}x${d.originalHeight}, displayed at ${d.displayWidth}x${d.displayHeight}. Multiply coordinates by ${(d.originalWidth / d.displayWidth).toFixed(2)} to map to original image.]`;
348
+ }
349
+ case "disclosure":
350
+ return renderReminderDisclosureTrailer(note.verdict, mark) ?? "";
351
+ }
352
+ }
353
+ export function renderReadToolResult(r, mark) {
354
+ if (r.refusal !== undefined)
355
+ return r.refusal;
356
+ const noteOf = (kind) => r.notes.find((n) => n.kind === kind);
357
+ const lead = r.notes.find((n) => n.kind === "past_eof" || n.kind === "empty_file");
358
+ if (lead !== undefined)
359
+ return renderReadNoteForTool(lead, mark);
360
+ if (r.outcome === "unchanged")
361
+ return { content: renderReadNoteForTool(r.notes[0], mark), ...(r.details !== undefined ? { details: r.details } : {}) };
362
+ const disclosure = noteOf("disclosure");
363
+ const trailer = disclosure !== undefined ? renderReadNoteForTool(disclosure, mark) : undefined;
364
+ switch (r.kind) {
365
+ case "pdf":
366
+ return { content: r.data, details: r.details };
367
+ case "image": {
368
+ const resized = noteOf("image_resized");
369
+ return { content: [...r.data, ...(resized !== undefined ? [{ type: "text", text: renderReadNoteForTool(resized, mark) }] : [])], details: r.details };
370
+ }
371
+ case "notebook": {
372
+ const noCells = noteOf("notebook_no_cells");
373
+ return {
374
+ content: [
375
+ ...(noCells !== undefined ? [{ type: "text", text: renderReadNoteForTool(noCells, mark) }] : r.data),
376
+ ...(trailer !== undefined ? [{ type: "text", text: trailer }] : []),
377
+ ],
378
+ details: r.details,
379
+ };
380
+ }
381
+ default: {
382
+ const fallback = noteOf("notebook_fallback");
383
+ const header = noteOf("partial_page") ?? noteOf("partial_chars") ?? noteOf("range");
384
+ const body = r.data[0]?.text ?? "";
385
+ return {
386
+ content: `${fallback !== undefined ? renderReadNoteForTool(fallback, mark) : ""}${header !== undefined ? renderReadNoteForTool(header, mark) : ""}${body}${trailer !== undefined ? `\n${trailer}` : ""}`,
387
+ details: r.details,
388
+ };
389
+ }
390
+ }
391
+ }
13
392
  export function createReadFileTool(env, state, rootCanonical, cwdRef, additionalRoots, imageDownsampler, pdfCapabilities, bgOutputReadExemption, readDeny, readFace, reminderMark, reminderDisclosureCounts) {
14
- const reminderDisclosureWindows = new Map();
15
- const pdfReminderDisclosure = (key) => ({
16
- mark: reminderMark,
17
- windows: reminderDisclosureWindows,
18
- key,
19
- counts: reminderDisclosureCounts,
393
+ const reader = createModelFileReader({
394
+ env,
395
+ state,
396
+ rootCanonical,
397
+ ...(cwdRef !== undefined ? { cwdRef } : {}),
398
+ ...(additionalRoots !== undefined ? { additionalRoots } : {}),
399
+ imageDownsampler,
400
+ ...(pdfCapabilities !== undefined ? { pdfCapabilities } : {}),
401
+ ...(bgOutputReadExemption !== undefined ? { bgOutputReadExemption } : {}),
402
+ ...(readDeny !== undefined ? { readDeny } : {}),
403
+ ...(readFace !== undefined ? { readFace } : {}),
404
+ ...(reminderMark !== undefined ? { reminderMark } : {}),
405
+ ...(reminderDisclosureCounts !== undefined ? { reminderDisclosureCounts } : {}),
20
406
  });
21
407
  const pathBoundLine = readFace === "open"
22
408
  ? "- `file_path` may be relative (resolved against the tracked working directory) or absolute. Reads are not confined to the workspace roots; a small sensitive-path deny list applies.\n"
@@ -56,282 +442,9 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
56
442
  pages: Type.Optional(Type.String({ description: `Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Maximum ${PDF_MAX_PAGES_PER_READ} pages per request.` })),
57
443
  }),
58
444
  execute: async (args, ctx) => {
59
- const { offset, limit, pages } = args;
60
- const path = fileArgPath(args);
61
- if (path === undefined)
62
- return errorResult(`Error (Read): file_path is required.`);
63
- const fileUnchangedResult = (text, reason, startLine, endLine, totalLines) => ({
64
- content: text,
65
- details: { type: "file_unchanged", reason, file: { filePath: path, startLine, endLine, totalLines } },
66
- });
67
- const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx), readDeny, readFace);
68
- if (!r.ok)
69
- return errorResult(violationText("Read", r.violation), violationDetails(r.violation));
70
- const isNb = isNotebookPath(r.key);
71
- const imageMime = imageMimeForRead(r.key);
72
- if (imageMime !== undefined) {
73
- const meta = await env.fileInfo(r.key, ctx.signal);
74
- if (!meta.ok) {
75
- const ex = await env.exists(r.key, ctx.signal);
76
- if (ex.ok && !ex.value)
77
- return errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal, readDeny)}`);
78
- return errorResult(`Error (Read): cannot stat image "${path}" to verify its size before reading: ${meta.error.message}`);
79
- }
80
- if (meta.value.kind === "directory") {
81
- return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
82
- }
83
- const downsampler = imageDownsampler === false ? undefined : (imageDownsampler ?? (await resolveAutoDownsampler()));
84
- const readCap = downsampler ? MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES : MAX_IMAGE_READ_BYTES;
85
- const tooLargeToRead = (bytes) => `Error (Read): image "${path}" is too large to read (${bytes} bytes > ${readCap}-byte cap).` +
86
- (downsampler ? "" : ` ${NO_DOWNSAMPLER_IMAGE_CAP_HINT}`);
87
- if (meta.value.size > readCap) {
88
- return errorResult(tooLargeToRead(meta.value.size));
89
- }
90
- const bin = await env.readBinaryFile(r.key, ctx.signal);
91
- if (!bin.ok)
92
- return errorResult(`Error (Read): cannot read image "${path}": ${bin.error.message}`);
93
- if (bin.value.byteLength > readCap) {
94
- return errorResult(tooLargeToRead(bin.value.byteLength));
95
- }
96
- if (!imageMagicMatches(bin.value, imageMime)) {
97
- return errorResult(`Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named).`);
98
- }
99
- let base64;
100
- let mime = imageMime;
101
- let dimensions;
102
- if (downsampler) {
103
- const out = await downsampler(Buffer.from(bin.value), imageMime).catch(() => undefined);
104
- if (out) {
105
- base64 = out.base64;
106
- mime = out.mimeType;
107
- dimensions = out.dimensions;
108
- }
109
- }
110
- base64 ??= Buffer.from(bin.value).toString("base64");
111
- if (base64.length > MCP_IMAGE_MAX_BASE64) {
112
- return errorResult(`Error (Read): image "${path}" is too large to send (${bin.value.byteLength} bytes; base64 ${base64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit` +
113
- (downsampler
114
- ? "; downsampling could not bring it under the limit)."
115
- : `; raw images over ${IMAGE_TARGET_RAW_SIZE} bytes always exceed it — shrink it first, e.g. with an image tool via bash).`));
116
- }
117
- const wasResized = dimensions !== undefined && dimensions.displayWidth > 0 && dimensions.displayHeight > 0 && (dimensions.originalWidth !== dimensions.displayWidth || dimensions.originalHeight !== dimensions.displayHeight);
118
- const content = [{ type: "image", data: base64, mimeType: mime }];
119
- if (wasResized && dimensions) {
120
- const scale = dimensions.originalWidth / dimensions.displayWidth;
121
- content.push({
122
- type: "text",
123
- text: `[Image: original ${dimensions.originalWidth}x${dimensions.originalHeight}, displayed at ${dimensions.displayWidth}x${dimensions.displayHeight}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`,
124
- });
125
- }
126
- return {
127
- content,
128
- details: {
129
- type: "image",
130
- file: { ...(base64.length <= 48_000 ? { base64 } : {}), type: mime, originalSize: bin.value.byteLength, ...(dimensions ? { dimensions } : {}) },
131
- },
132
- };
133
- }
134
- if (r.key.toLowerCase().endsWith(".pdf")) {
135
- return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities, readDeny, pdfReminderDisclosure(r.key)));
136
- }
137
- const binaryExt = hasBinaryExtension(r.key);
138
- const binaryExtRefusal = () => errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
139
- const info = await env.fileInfo(r.key, ctx.signal);
140
- if (info.ok) {
141
- if (info.value.kind === "directory") {
142
- return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
143
- }
144
- if (info.value.size > SLICED_READ_MAX_BYTES) {
145
- if (binaryExt)
146
- return binaryExtRefusal();
147
- return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
148
- `(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
149
- `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
150
- }
151
- if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) {
152
- if (binaryExt)
153
- return binaryExtRefusal();
154
- return errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
155
- }
156
- }
157
- else {
158
- const ex = await env.exists(r.key, ctx.signal);
159
- if (ex.ok && !ex.value)
160
- return errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal, readDeny)}`);
161
- return errorResult(`Error (Read): cannot determine the type of "${path.slice(0, 300)}" (stat failed: ${info.error.message.slice(0, 200)}); refusing to open it — if it is a FIFO/socket/device, reading it would hang. Retry if this was transient, or inspect it with Bash (e.g. \`file\`, \`ls -l\`).`);
162
- }
163
- const readBin = await env.readBinaryFile(r.key, ctx.signal);
164
- if (!readBin.ok)
165
- return errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`);
166
- const readSize = readBin.value.byteLength;
167
- if (readSize > SLICED_READ_MAX_BYTES) {
168
- if (binaryExt)
169
- return binaryExtRefusal();
170
- return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
171
- `(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
172
- `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
173
- }
174
- if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) {
175
- if (binaryExt)
176
- return binaryExtRefusal();
177
- return errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
178
- }
179
- if (pdfMagicMatches(readBin.value)) {
180
- return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, readBin.value, pdfCapabilities, readDeny, pdfReminderDisclosure(r.key)));
181
- }
182
- const magicFormat = binaryMagicFormat(readBin.value);
183
- if (magicFormat !== undefined) {
184
- return errorResult(`Error (Read): "${path}" appears to be a binary file (${magicFormat}, identified by its file signature — whatever its extension says); ` +
185
- `this tool reads UTF-8 and BOM-marked UTF-16LE text only. Inspect or convert it with bash instead.`);
186
- }
187
- const decoded = decodeTextBytes(readBin.value);
188
- if (decoded.malformed) {
189
- return errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`);
190
- }
191
- const content = decoded.text;
192
- if (isBinaryContent(content.slice(0, 8192))) {
193
- return errorResult(`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` +
194
- `If it is UTF-16 without a BOM or a legacy encoding, convert it first (e.g. \`iconv -f UTF-16LE -t UTF-8\`) or inspect/transform it with bash.`);
195
- }
196
- const nbParsed = isNb ? parseNotebookCells(content, path) : undefined;
197
- const nbForced = nbParsed !== undefined && nbParsed.ok;
198
- const effOffset = nbForced ? undefined : offset;
199
- const effLimit = nbForced ? undefined : limit;
200
- const lines = content.length === 0 ? [] : content.split("\n");
201
- const total = lines.length;
202
- const start = Math.max(1, Math.floor(effOffset ?? 1));
203
- const max = effLimit !== undefined ? Math.max(1, Math.floor(effLimit)) : Number.MAX_SAFE_INTEGER;
204
- if (total > 0 && start > total) {
205
- return `${openSystemReminder(reminderMark)}Warning: the file exists but is shorter than the provided offset (${start}). The file has ${total} lines.</system-reminder>`;
206
- }
207
- let slice = lines.slice(start - 1, start - 1 + max);
208
- let end = start - 1 + slice.length;
209
- if (end === total - 1 && lines[total - 1] === "") {
210
- slice = lines.slice(start - 1, total);
211
- end = total;
212
- }
213
- const hash = sha256(content);
214
- let nbFallbackPrefix = "";
215
- if (nbParsed !== undefined) {
216
- if (nbParsed.ok) {
217
- const parsed = nbParsed;
218
- const rendered = renderNotebookCells(parsed.cells, NOTEBOOK_IMAGE_BASE64_BUDGET);
219
- if (rendered.textChars > MAX_READ_OUTPUT_CHARS) {
220
- return errorResult(`Error (Read): notebook "${path}" projects to ~${Math.round(rendered.textChars / 4)} tokens of cell content, over the 25000-token limit ` +
221
- `(offset/limit do not apply to notebooks — the whole notebook is always projected). Inspect portions with Bash + jq instead, e.g.\n` +
222
- ` cat "${path}" | jq '.cells[:20]' # First 20 cells\n` +
223
- ` cat "${path}" | jq '.cells | length' # Count total cells\n` +
224
- ` cat "${path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`);
225
- }
226
- const prevNb = state.get(r.key);
227
- if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) {
228
- return fileUnchangedResult(seededFileUnchangedReminder(r.key, reminderMark), "already-in-context", 1, total, total);
229
- }
230
- if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
231
- return fileUnchangedResult(`[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`, "unchanged-since-last-read", 1, total, total);
232
- }
233
- state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now(), transmittedByToolCallId: ctx.toolCallId });
234
- const bodyBlocks = rendered.blocks.length > 0 ? rendered.blocks : [{ type: "text", text: "[notebook has 0 cells]" }];
235
- const nbDisclosure = discloseReminderShaped({
236
- segments: rendered.blocks.filter((b) => b.type === "text").map((b) => b.text),
237
- mark: reminderMark,
238
- outlet: "notebook",
239
- defuseExactMark: false,
240
- throttle: { key: r.key, windows: reminderDisclosureWindows },
241
- counts: reminderDisclosureCounts,
242
- });
243
- return {
244
- content: [
245
- ...bodyBlocks,
246
- ...(nbDisclosure.trailer !== undefined ? [{ type: "text", text: nbDisclosure.trailer }] : []),
247
- ],
248
- details: { type: "notebook", file: { filePath: path, cells: parsed.cells.map(stripNotebookImageData) } },
249
- };
250
- }
251
- nbFallbackPrefix = "[not a parseable notebook — showing raw file text]\n";
252
- }
253
- const renderBody = (sl) => sl.map((l, i) => `${start + i}\t${l}`).join("\n");
254
- let body = renderBody(slice);
255
- let pageMarker;
256
- if (body.length > MAX_READ_OUTPUT_CHARS) {
257
- const defaultFullRead = start <= 1 && effLimit === undefined;
258
- if (!defaultFullRead) {
259
- return errorResult(`Error (Read): the requested range of "${path}" is ~${Math.round(body.length / 4)} tokens, over the 25000-token limit. Narrow it with offset/limit, or use grep to search the file.`);
260
- }
261
- const approxTokens = Math.round(body.length / 4);
262
- let n = Math.max(1, Math.min(slice.length, Math.floor(slice.length * (MAX_READ_OUTPUT_CHARS / body.length) * 0.95)));
263
- let paged = slice.slice(0, n);
264
- let pagedBody = renderBody(paged);
265
- for (let attempt = 0; attempt < 8 && pagedBody.length > MAX_READ_OUTPUT_CHARS && n > 1; attempt++) {
266
- n = Math.max(1, Math.floor(n * 0.7));
267
- paged = slice.slice(0, n);
268
- pagedBody = renderBody(paged);
269
- }
270
- if (pagedBody.length <= MAX_READ_OUTPUT_CHARS) {
271
- slice = paged;
272
- end = start - 1 + slice.length;
273
- body = pagedBody;
274
- pageMarker =
275
- `[Truncated: PARTIAL view — showing lines 1-${end} of ${total} total (~${approxTokens} tokens, cap 25000). ` +
276
- `Call Read with offset=${end + 1} limit=${end} for the next page, or Grep to find a specific section. ` +
277
- `Do NOT answer from this page alone if the answer may be further in the file.]\n`;
278
- }
279
- else {
280
- let cut = MAX_READ_OUTPUT_CHARS - 200;
281
- const cc = slice[0].charCodeAt(cut - 1);
282
- if (cc >= 0xd800 && cc <= 0xdbff)
283
- cut -= 1;
284
- slice = [slice[0].slice(0, cut)];
285
- end = start;
286
- body = renderBody(slice);
287
- pageMarker =
288
- `[Truncated: PARTIAL view — showing the first ${cut} of ${content.length} characters (~${approxTokens} tokens, cap 25000); ` +
289
- `this file has very long lines and cannot be paginated by line. Use Grep to find a specific section, or Read with offset/limit to page through it. ` +
290
- `Do NOT answer from this excerpt alone if the answer may be elsewhere in the file.]\n`;
291
- }
292
- }
293
- const truncated = start > 1 || end < total || pageMarker !== undefined;
294
- const prev = state.get(r.key);
295
- if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
296
- return fileUnchangedResult(seededFileUnchangedReminder(r.key, reminderMark), "already-in-context", 1, total, total);
297
- }
298
- if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
299
- return fileUnchangedResult(`[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`, "unchanged-since-last-read", start, end, total);
300
- }
301
- state.set(r.key, {
302
- hash,
303
- totalLines: countLines(content),
304
- truncated,
305
- ...(pageMarker !== undefined ? { isPartialView: true } : {}),
306
- view: { start, end },
307
- lastReadAt: Date.now(),
308
- transmittedByToolCallId: ctx.toolCallId,
309
- });
310
- if (total === 0)
311
- return `${openSystemReminder(reminderMark)}Warning: the file exists but the contents are empty.</system-reminder>`;
312
- const header = pageMarker ?? (truncated ? `[${path}: lines ${start}-${end} of ${total}${end < total ? " — use offset to see more" : ""}]\n` : "");
313
- const disclosure = discloseReminderShaped({
314
- segments: [body],
315
- mark: reminderMark,
316
- outlet: "read",
317
- defuseExactMark: false,
318
- throttle: { key: r.key, windows: reminderDisclosureWindows },
319
- counts: reminderDisclosureCounts,
320
- });
321
- return {
322
- content: `${nbFallbackPrefix}${header}${body}${disclosure.trailer !== undefined ? `\n${disclosure.trailer}` : ""}`,
323
- details: {
324
- type: "text",
325
- file: {
326
- filePath: path,
327
- content: slice.join("\n"),
328
- numLines: slice.length,
329
- startLine: start,
330
- totalLines: total,
331
- ...(pageMarker !== undefined ? { truncatedByTokenCap: true } : {}),
332
- },
333
- },
334
- };
445
+ const result = await reader.readForModel(args, ctx);
446
+ reader.commit(result, { transmittedByToolCallId: ctx.toolCallId });
447
+ return renderReadToolResult(result, reminderMark);
335
448
  },
336
449
  });
337
450
  }