basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import path from 'node:path';8import fs from 'node:fs/promises';9import type { Stats } from 'node:fs';10import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js';11import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js';12import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';13import { ToolNames, ToolDisplayNames } from './tool-names.js';14 15import type { PartUnion } from '@google/genai';16import type { PermissionDecision } from '../permissions/types.js';17import {18 processSingleFileContent,19 getSpecificMimeType,20 isCacheableReadResult,21} from '../utils/fileUtils.js';22import { parsePDFPageRange, PDF_MAX_PAGES_PER_READ } from '../utils/pdf.js';23import type { Config } from '../config/config.js';24import { FileOperation } from '../telemetry/metrics.js';25import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';26import { logFileOperation } from '../telemetry/loggers.js';27import { FileOperationEvent } from '../telemetry/types.js';28import { isSubpaths } from '../utils/paths.js';29import { Storage } from '../config/storage.js';30import { isAnyAutoMemPath } from '../memory/paths.js';31import { memoryFreshnessNote } from '../memory/memoryAge.js';32import { createDebugLogger } from '../utils/debugLogger.js';33 34const debugLogger = createDebugLogger('READ_FILE_CACHE');35 36/**37 * Parameters for the ReadFile tool38 */39export interface ReadFileToolParams {40 /**41 * The absolute path to the file to read42 */43 file_path: string;44 45 /**46 * The line number to start reading from (optional)47 */48 offset?: number;49 50 /**51 * The number of lines to read (optional)52 */53 limit?: number;54 55 /**56 * For PDF files, the page range to extract as text (e.g. "1-5", "3", "10-20").57 * Pages are 1-indexed. Open-ended ranges like "3-" are not supported.58 */59 pages?: string;60}61 62class ReadFileToolInvocation extends BaseToolInvocation<63 ReadFileToolParams,64 ToolResult65> {66 constructor(67 private config: Config,68 params: ReadFileToolParams,69 ) {70 super(params);71 }72 73 getDescription(): string {74 const relativePath = makeRelative(75 this.params.file_path,76 this.config.getTargetDir(),77 );78 const shortPath = shortenPath(relativePath);79 80 if (this.params.pages) {81 return `${shortPath} (pages ${this.params.pages})`;82 }83 84 const { offset, limit } = this.params;85 if (offset !== undefined && limit !== undefined) {86 return `${shortPath} (lines ${offset + 1}-${offset + limit})`;87 } else if (offset !== undefined) {88 return `${shortPath} (from line ${offset + 1})`;89 } else if (limit !== undefined) {90 return `${shortPath} (first ${limit} lines)`;91 }92 93 return shortPath;94 }95 96 override toolLocations(): ToolLocation[] {97 return [{ path: this.params.file_path, line: this.params.offset }];98 }99 100 /**101 * Returns 'ask' for paths outside the workspace/qwen-managed temp/userSkills102 * directories, so that external file reads require user confirmation.103 */104 override async getDefaultPermission(): Promise<PermissionDecision> {105 const filePath = path.resolve(this.params.file_path);106 const workspaceContext = this.config.getWorkspaceContext();107 108 // SYNC: Keep these base roots and the auto-memory check below aligned with109 // AcpAgent.buildAcpLocalReadRoots' mirrored ReadFileTool group. ACP may110 // append fallback-only roots after that group.111 const allowedRoots = [112 this.config.storage.getProjectTempDir(),113 // Background subagent transcripts live under <projectDir>/subagents/ and114 // are advertised to the model as polling targets via read_file.115 path.join(this.config.storage.getProjectDir(), 'subagents'),116 Storage.getGlobalTempDir(),117 ...this.config.storage.getUserSkillsDirs(),118 Storage.getUserExtensionsDir(),119 ];120 121 if (122 workspaceContext.isPathWithinWorkspace(filePath) ||123 isSubpaths(allowedRoots, filePath) ||124 // isAnyAutoMemPath narrows to the managed auto-memory roots125 // (per-project + user-level under ~/.qwen/memories/) — never the126 // broad getMemoryBaseDir() — to avoid exposing sensitive ~/.qwen127 // files such as settings.json or OAuth credentials.128 isAnyAutoMemPath(filePath, this.config.getTargetDir())129 ) {130 return 'allow';131 }132 return 'ask';133 }134 135 async execute(signal: AbortSignal): Promise<ToolResult> {136 const absPath = path.resolve(this.params.file_path);137 const projectRoot = this.config.getTargetDir();138 // Auto-memory files (AGENTS.md and friends under the auto-memory139 // root) get a per-read freshness `<system-reminder>` prepended in140 // the slow path — the signal that tells the model to treat the141 // contents as a point-in-time snapshot. Returning the142 // file_unchanged placeholder would skip that prepend, silently143 // dropping the staleness warning for the rest of the session.144 // These files are small; re-emit them on every read.145 const isAutoMem = isAnyAutoMemPath(absPath, projectRoot);146 // The cache can be disabled at the Config level (escape hatch for147 // sessions where the "model has already seen the prior tool result"148 // assumption breaks down — e.g. after context compaction or149 // transcript transformation). When disabled we bypass both the150 // fast-path lookup and the post-read record so behaviour matches151 // the pre-cache implementation byte-for-byte.152 //153 // Auto-memory files are *recorded* in the cache (so prior-read154 // enforcement on Edit / WriteFile recognises them as read) but155 // never serve the file_unchanged placeholder — those files own a156 // per-read freshness `<system-reminder>` that must be re-emitted157 // on every read.158 const cacheEnabled = !this.config.getFileReadCacheDisabled();159 const useFastPath = cacheEnabled && !isAutoMem;160 const cache = this.config.getFileReadCache();161 // A request-level "full" Read asks for the whole file: no offset,162 // no limit, no PDF page range. The cache entry is only marked as163 // full later if the produced content was not truncated.164 const isFullRead =165 this.params.offset === undefined &&166 this.params.limit === undefined &&167 this.params.pages === undefined;168 169 // Stat up front so we can consult the cache before doing any heavy170 // work. processSingleFileContent re-stats anyway; the extra syscall171 // here is microseconds. If stat fails we fall through to the normal172 // pipeline so its error handling stays the single source of truth.173 let stats: Stats | undefined;174 try {175 stats = await fs.stat(absPath);176 } catch (err) {177 debugLogger.debug('stat-failed', {178 path: absPath,179 code: (err as NodeJS.ErrnoException).code,180 });181 }182 183 if (useFastPath && stats && isFullRead) {184 const status = cache.check(stats);185 if (186 status.state === 'fresh' &&187 status.entry.lastReadAt !== undefined &&188 status.entry.lastReadWasFull &&189 status.entry.lastReadCacheable &&190 // Only quote-back if that read is still in history (issue191 // #4239: idle microcompaction flips this off when it blanks it).192 status.entry.readResidentInHistory &&193 (status.entry.lastWriteAt === undefined ||194 status.entry.lastReadAt > status.entry.lastWriteAt)195 ) {196 debugLogger.debug('hit', { path: absPath });197 return this.unchangedResult(absPath);198 }199 debugLogger.debug('miss', { path: absPath, state: status.state });200 }201 202 const result = await processSingleFileContent(203 this.params.file_path,204 this.config,205 {206 offset: this.params.offset,207 limit: this.params.limit,208 pages: this.params.pages,209 signal,210 },211 );212 213 if (result.error) {214 return {215 llmContent: result.llmContent,216 returnDisplay: result.returnDisplay || 'Error reading file',217 error: {218 message: result.error,219 type: result.errorType,220 },221 };222 }223 224 // Record a cache entry so that subsequent identical Reads can hit225 // the file_unchanged fast-path, and so prior-read enforcement on226 // Edit / WriteFile can recognise the read.227 //228 // Two independent flags are recorded:229 //230 // - `cacheable` — whether the content is plain text (not binary /231 // image / audio / video / PDF / notebook). This is the flag232 // `priorReadEnforcement.ts` consults to decide whether the233 // model has seen a payload that Edit / WriteFile can mutate as234 // text. It must NOT include "was the read truncated", because235 // a truncated text read still produced text — bundling those236 // two concerns is what produced the issue #3964 regression237 // where a partial Read of a regular `.kt` / `.cpp` / `.py`238 // file caused the next Edit to be rejected with the239 // misleading "binary / image / audio / video / PDF / notebook240 // payload" error.241 //242 // - `full` — whether the model has seen every byte of the243 // current file. This now gates ONLY the file_unchanged244 // fast-path; PR #4002 removed WriteFile's `requireFullRead`245 // (the truncate-tool-output limit made "fully read" an246 // impossible precondition on files past the limit, deadlocking247 // issue #3945). A "full" Read at the request level (no248 // offset / limit / pages) only counts as full at the cache249 // level if the produced content was not truncated, otherwise250 // the model only saw the head and a follow-up `file_unchanged`251 // placeholder would falsely imply "you've already seen252 // everything". NotebookEdit also requires this flag so a253 // truncated notebook render does not authorize structured writes254 // against unseen cells.255 //256 // The stat we record is the one taken inside `processSingleFileContent`257 // and surfaced via `result.stats`. The internal stat happens258 // immediately before the actual content read, so the fingerprint259 // it captures is the one closest to the bytes the model received.260 // Falling back to a post-read re-stat would describe a possibly-261 // mutated file rather than the file the read returned: a write262 // landing between the read and the post-stat would let the cache263 // record fingerprint Y for content the model only saw at X, and264 // a follow-up Edit would pass enforcement (`fresh + full +265 // cacheable @ Y`) against bytes the model never legitimately saw.266 //267 // Race residue: the internal-stat-to-actual-read window is still268 // a few microseconds wide. Closing it completely needs a content269 // hash on the read pipeline (deferred follow-up — see Risk270 // section in the PR description).271 if (cacheEnabled && (result.stats ?? stats)) {272 const cacheable = isCacheableReadResult(result);273 const recordStats: Stats = result.stats ?? stats!;274 cache.recordRead(absPath, recordStats, {275 full: isFullRead && !result.isTruncated,276 cacheable,277 });278 }279 280 let llmContent: PartUnion;281 if (282 result.isTruncated &&283 result.linesShown &&284 result.originalLineCount !== undefined285 ) {286 const [start, end] = result.linesShown!;287 const total = result.originalLineCount!;288 const totalLabel =289 result.originalLineCountExact === false ? `at least ${total}` : total;290 llmContent = `Showing lines ${start}-${end} of ${totalLabel} total lines.\n\n---\n\n${result.llmContent}`;291 } else {292 llmContent = result.llmContent || '';293 }294 295 // For memory files, prepend a per-file staleness caveat so the model knows296 // the content is a point-in-time snapshot and may be stale.297 if (typeof llmContent === 'string' && isAutoMem) {298 // Reuse the stat from above when we have it; only re-stat as a299 // fallback so memory-file behavior survives a stat failure earlier300 // (which would leave `stats` undefined).301 try {302 const memStat = stats ?? (await fs.stat(absPath));303 const note = memoryFreshnessNote(memStat.mtimeMs);304 if (note) {305 llmContent = note + llmContent;306 }307 } catch {308 // Best-effort — if stat fails, omit the note silently.309 }310 }311 312 const lines =313 typeof result.llmContent === 'string'314 ? result.llmContent.split('\n').length315 : undefined;316 const mimetype = getSpecificMimeType(this.params.file_path);317 const programming_language = getProgrammingLanguage({318 file_path: this.params.file_path,319 });320 logFileOperation(321 this.config,322 new FileOperationEvent(323 ReadFileTool.Name,324 FileOperation.READ,325 lines,326 mimetype,327 path.extname(this.params.file_path),328 programming_language,329 ),330 );331 332 return {333 llmContent,334 returnDisplay: result.returnDisplay || '',335 };336 }337 338 /**339 * Build the placeholder ToolResult returned when the cache indicates340 * the file has not changed since the model last fully read it. The341 * placeholder is intentionally explicit about its assumptions so the342 * model can decide whether to trust it:343 *344 * 1. The full content was emitted *earlier in this conversation*.345 * If the conversation has been compacted, summarised, or the346 * model is a subagent receiving a transformed transcript, the347 * prior content may no longer be retrievable — the model should348 * re-read with explicit offset/limit in that case.349 * 2. External mutations the cache cannot observe (shell writes via350 * run_shell_command, MCP tool writes, other processes touching351 * the file) will not appear here as `stale`. If the model352 * suspects drift, it should re-read with explicit offset/limit.353 *354 * No `logFileOperation` is emitted on this path: the file_unchanged355 * fast-path bypasses the read pipeline entirely, and the existing356 * `FileOperationEvent` schema has no representation for "served from357 * cache". A dedicated cache-hit metric can be added when telemetry358 * needs visibility into the fast-path's effectiveness.359 */360 private unchangedResult(absPath: string): ToolResult {361 const relativePath = shortenPath(362 makeRelative(absPath, this.config.getTargetDir()),363 );364 const llmContent =365 `[File ${relativePath} unchanged since last read in this session — ` +366 `the full content was provided earlier in this conversation. ` +367 `If you cannot retrieve that prior content (e.g. after context ` +368 `compaction) or you suspect the file was modified outside the read/edit ` +369 `tools (shell command, MCP tool, another process), re-read with ` +370 `explicit offset/limit to fetch current content.]`;371 return {372 llmContent,373 returnDisplay: `Unchanged: ${relativePath}`,374 };375 }376}377 378/**379 * Implementation of the ReadFile tool logic380 */381export class ReadFileTool extends BaseDeclarativeTool<382 ReadFileToolParams,383 ToolResult384> {385 static readonly Name: string = ToolNames.READ_FILE;386 387 // Self-managed: ReadFile controls its own size via line-based paging388 // (offset/limit, default 2000 lines), so it is exempt from the scheduler's389 // char-based truncation. Oversized reads are bounded by the per-message390 // batch budget instead.391 override get maxOutputChars(): number {392 return Number.POSITIVE_INFINITY;393 }394 395 constructor(private config: Config) {396 super(397 ReadFileTool.Name,398 ToolDisplayNames.READ_FILE,399 `Reads and returns the content of a specified file. The file_path argument MUST be an absolute path. Always construct it by combining the project root with the file's relative path (e.g. project root '/path/to/project/' + relative 'foo/bar.txt' = '/path/to/project/foo/bar.txt'). If the user provides a relative path, resolve it against the project root first. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), PDF files, and Jupyter notebooks (.ipynb). For text files, it can read specific line ranges. For PDF files, use the 'pages' parameter to extract specific page ranges as text (e.g. '1-5'). Max ${PDF_MAX_PAGES_PER_READ} pages per request. Large PDFs cannot be read all at once when the model does not support native PDF input; retry with narrower page ranges if the tool reports a PDF is too large. This tool can read Jupyter notebooks (.ipynb) and returns structured cell content with outputs.`,400 Kind.Read,401 {402 properties: {403 file_path: {404 description:405 "The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported. You must provide an absolute path.",406 type: 'string',407 },408 offset: {409 description:410 "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",411 type: 'integer',412 },413 limit: {414 description:415 "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",416 type: 'integer',417 },418 pages: {419 description:420 `Optional: For PDF files, the page range to extract as text (e.g., '1-5', '3', '10-20'). Pages are 1-indexed. Max ${PDF_MAX_PAGES_PER_READ} pages per request. Open-ended ranges like '3-' are not supported. Use this for large PDFs or when the model does not support native PDF input.`,421 type: 'string',422 },423 },424 required: ['file_path'],425 type: 'object',426 },427 );428 }429 430 protected override validateToolParamValues(431 params: ReadFileToolParams,432 ): string | null {433 // Normalize shell-escaped paths (e.g. "my\ file.txt" → "my file.txt")434 // that may reach the LLM via at-completion or manual typing.435 const filePath = unescapePath(params.file_path.trim());436 params.file_path = filePath;437 438 if (!filePath) {439 return "The 'file_path' parameter must be non-empty.";440 }441 442 if (!path.isAbsolute(filePath)) {443 return `File path must be absolute, but was relative: ${filePath}. You must provide an absolute path.`;444 }445 446 if (447 params.offset !== undefined &&448 (!Number.isInteger(params.offset) || params.offset < 0)449 ) {450 return 'Offset must be a non-negative integer';451 }452 if (453 params.limit !== undefined &&454 (!Number.isInteger(params.limit) || params.limit <= 0)455 ) {456 return 'Limit must be a positive integer';457 }458 459 if (params.pages !== undefined) {460 const pages = params.pages.trim();461 params.pages = pages.length > 0 ? pages : undefined;462 }463 464 const ext = path.extname(filePath).toLowerCase();465 if (466 (params.offset !== undefined || params.limit !== undefined) &&467 ext === '.ipynb'468 ) {469 return 'offset and limit are not supported for Jupyter notebook (.ipynb) files. Notebooks are always read in full with structured cell output.';470 }471 472 if (params.pages !== undefined && ext === '.ipynb') {473 return 'pages is not supported for Jupyter notebook (.ipynb) files. Notebooks are always read in full with structured cell output.';474 }475 476 if (params.pages) {477 const parsed = parsePDFPageRange(params.pages);478 if (!parsed) {479 return `Invalid pages parameter: '${params.pages}'. Use formats like '5' or '1-10'.`;480 }481 if (parsed.lastPage === Infinity) {482 return `Open-ended page ranges (e.g. '3-') are not supported; specify an explicit end page within the ${PDF_MAX_PAGES_PER_READ}-page limit (e.g. '3-22').`;483 }484 const maxPages = PDF_MAX_PAGES_PER_READ;485 if (parsed.lastPage - parsed.firstPage + 1 > maxPages) {486 return `Pages range exceeds maximum of ${maxPages} pages per request.`;487 }488 }489 490 const fileService = this.config.getFileService();491 if (fileService.shouldQwenIgnoreFile(params.file_path)) {492 return `File path '${filePath}' is ignored by ${fileService.getQwenIgnoreFileDisplayForPath(params.file_path)} pattern(s).`;493 }494 495 return null;496 }497 498 protected createInvocation(499 params: ReadFileToolParams,500 ): ToolInvocation<ReadFileToolParams, ToolResult> {501 return new ReadFileToolInvocation(this.config, params);502 }503}504 