CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
read-file.ts504 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace