basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import fs from 'node:fs/promises';8import path from 'node:path';9import type { ToolInvocation, ToolResult } from './tools.js';10import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';11import {12 makeRelative,13 shortenPath,14 unescapePath,15 isSubpaths,16 isSubpath,17} from '../utils/paths.js';18import type { Config } from '../config/config.js';19import type { PermissionDecision } from '../permissions/types.js';20import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';21import { ToolErrorType } from './tool-error.js';22import { ToolDisplayNames, ToolNames } from './tool-names.js';23import { createDebugLogger } from '../utils/debugLogger.js';24import { Storage } from '../config/storage.js';25import { getMemoryBaseDir } from '../memory/paths.js';26 27const debugLogger = createDebugLogger('LS');28 29const MAX_ENTRY_COUNT = 100;30 31/**32 * Parameters for the LS tool33 */34export interface LSToolParams {35 /**36 * The absolute path to the directory to list37 */38 path: string;39 40 /**41 * Array of glob patterns to ignore (optional)42 */43 ignore?: string[];44 45 /**46 * Whether to respect .gitignore and Qwen/agent ignore patterns (optional, defaults to true)47 */48 file_filtering_options?: {49 respect_git_ignore?: boolean;50 respect_qwen_ignore?: boolean;51 };52}53 54/**55 * File entry returned by LS tool56 */57export interface FileEntry {58 /**59 * Name of the file or directory60 */61 name: string;62 63 /**64 * Absolute path to the file or directory65 */66 path: string;67 68 /**69 * Whether this entry is a directory70 */71 isDirectory: boolean;72 73 /**74 * Size of the file in bytes (0 for directories)75 */76 size: number;77 78 /**79 * Last modified timestamp80 */81 modifiedTime: Date;82}83 84class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {85 constructor(86 private readonly config: Config,87 params: LSToolParams,88 ) {89 super(params);90 }91 92 /**93 * Checks if a filename matches any of the ignore patterns94 * @param filename Filename to check95 * @param patterns Array of glob patterns to check against96 * @returns True if the filename should be ignored97 */98 private shouldIgnore(filename: string, patterns?: string[]): boolean {99 if (!patterns || patterns.length === 0) {100 return false;101 }102 for (const pattern of patterns) {103 // Convert glob pattern to RegExp104 const regexPattern = pattern105 .replace(/[.+^${}()|[\]\\]/g, '\\$&')106 .replace(/\*/g, '.*')107 .replace(/\?/g, '.');108 const regex = new RegExp(`^${regexPattern}$`);109 if (regex.test(filename)) {110 return true;111 }112 }113 return false;114 }115 116 /**117 * Gets a description of the file reading operation118 * @returns A string describing the file being read119 */120 getDescription(): string {121 const relativePath = makeRelative(122 this.params.path,123 this.config.getTargetDir(),124 );125 return shortenPath(relativePath);126 }127 128 /**129 * Returns 'ask' for paths outside the workspace/userSkills directories,130 * so that external directory listings require user confirmation.131 */132 override async getDefaultPermission(): Promise<PermissionDecision> {133 const dirPath = path.resolve(this.params.path);134 const workspaceContext = this.config.getWorkspaceContext();135 const userSkillsDirs = this.config.storage.getUserSkillsDirs();136 const userExtensionsDir = Storage.getUserExtensionsDir();137 138 if (139 workspaceContext.isPathWithinWorkspace(dirPath) ||140 isSubpaths(userSkillsDirs, dirPath) ||141 isSubpath(userExtensionsDir, dirPath) ||142 isSubpath(getMemoryBaseDir(), dirPath)143 ) {144 return 'allow';145 }146 return 'ask';147 }148 149 // Helper for consistent error formatting150 private errorResult(151 llmContent: string,152 returnDisplay: string,153 type: ToolErrorType,154 ): ToolResult {155 return {156 llmContent,157 // Keep returnDisplay simpler in core logic158 returnDisplay: `Error: ${returnDisplay}`,159 error: {160 message: llmContent,161 type,162 },163 };164 }165 166 /**167 * Executes the LS operation with the given parameters168 * @returns Result of the LS operation169 */170 async execute(_signal: AbortSignal): Promise<ToolResult> {171 try {172 const stats = await fs.stat(this.params.path);173 if (!stats) {174 // fs.statSync throws on non-existence, so this check might be redundant175 // but keeping for clarity. Error message adjusted.176 return this.errorResult(177 `Error: Directory not found or inaccessible: ${this.params.path}`,178 `Directory not found or inaccessible.`,179 ToolErrorType.FILE_NOT_FOUND,180 );181 }182 if (!stats.isDirectory()) {183 return this.errorResult(184 `Error: Path is not a directory: ${this.params.path}`,185 `Path is not a directory.`,186 ToolErrorType.PATH_IS_NOT_A_DIRECTORY,187 );188 }189 190 const files = await fs.readdir(this.params.path);191 if (files.length === 0) {192 // Changed error message to be more neutral for LLM193 return {194 llmContent: `Directory ${this.params.path} is empty.`,195 returnDisplay: `Directory is empty.`,196 };197 }198 199 const relativePaths = files.map((file) =>200 path.relative(201 this.config.getTargetDir(),202 path.join(this.params.path, file),203 ),204 );205 206 const fileDiscovery = this.config.getFileService();207 const { filteredPaths, gitIgnoredCount, qwenIgnoredCount } =208 fileDiscovery.filterFilesWithReport(relativePaths, {209 respectGitIgnore:210 this.params.file_filtering_options?.respect_git_ignore ??211 this.config.getFileFilteringOptions().respectGitIgnore ??212 DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,213 respectQwenIgnore:214 this.params.file_filtering_options?.respect_qwen_ignore ??215 this.config.getFileFilteringOptions().respectQwenIgnore ??216 DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore,217 });218 219 const entries = [];220 for (const relativePath of filteredPaths) {221 const fullPath = path.resolve(this.config.getTargetDir(), relativePath);222 223 if (this.shouldIgnore(path.basename(fullPath), this.params.ignore)) {224 continue;225 }226 227 try {228 const stats = await fs.stat(fullPath);229 const isDir = stats.isDirectory();230 entries.push({231 name: path.basename(fullPath),232 path: fullPath,233 isDirectory: isDir,234 size: isDir ? 0 : stats.size,235 modifiedTime: stats.mtime,236 });237 } catch (error) {238 // Log error internally but don't fail the whole listing239 debugLogger.warn(`Error accessing ${fullPath}: ${error}`);240 }241 }242 243 // Sort entries (directories first, then alphabetically)244 entries.sort((a, b) => {245 if (a.isDirectory && !b.isDirectory) return -1;246 if (!a.isDirectory && b.isDirectory) return 1;247 return a.name.localeCompare(b.name);248 });249 250 const totalEntryCount = entries.length;251 const entryLimit = Math.min(252 MAX_ENTRY_COUNT,253 this.config.getTruncateToolOutputLines(),254 );255 const truncated = totalEntryCount > entryLimit;256 257 const entriesToShow = truncated ? entries.slice(0, entryLimit) : entries;258 259 const directoryContent = entriesToShow260 .map((entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`)261 .join('\n');262 263 let resultMessage = `Listed ${totalEntryCount} item(s) in ${this.params.path}:\n---\n${directoryContent}`;264 265 if (truncated) {266 const omittedEntries = totalEntryCount - entryLimit;267 const entryTerm = omittedEntries === 1 ? 'item' : 'items';268 resultMessage += `\n---\n[${omittedEntries} ${entryTerm} truncated] ...`;269 }270 271 const ignoredMessages = [];272 if (gitIgnoredCount > 0) {273 ignoredMessages.push(`${gitIgnoredCount} git-ignored`);274 }275 if (qwenIgnoredCount > 0) {276 ignoredMessages.push(`${qwenIgnoredCount} qwen-ignored`);277 }278 if (ignoredMessages.length > 0) {279 resultMessage += `\n\n(${ignoredMessages.join(', ')})`;280 }281 282 let displayMessage = `Listed ${totalEntryCount} item(s)`;283 if (ignoredMessages.length > 0) {284 displayMessage += ` (${ignoredMessages.join(', ')})`;285 }286 if (truncated) {287 displayMessage += ' (truncated)';288 }289 290 return {291 llmContent: resultMessage,292 returnDisplay: displayMessage,293 };294 } catch (error) {295 const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;296 return this.errorResult(297 errorMsg,298 'Failed to list directory.',299 ToolErrorType.LS_EXECUTION_ERROR,300 );301 }302 }303}304 305/**306 * Implementation of the LS tool logic307 */308export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {309 static readonly Name = ToolNames.LS;310 311 constructor(private config: Config) {312 super(313 LSTool.Name,314 ToolDisplayNames.LS,315 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',316 Kind.Search,317 {318 properties: {319 path: {320 description:321 'The absolute path to the directory to list (must be absolute, not relative)',322 type: 'string',323 },324 ignore: {325 description: 'List of glob patterns to ignore',326 items: {327 type: 'string',328 },329 type: 'array',330 },331 file_filtering_options: {332 description:333 'Optional: Whether to respect ignore patterns from .gitignore, .qwenignore, and configured custom Qwen ignore files',334 type: 'object',335 properties: {336 respect_git_ignore: {337 description:338 'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',339 type: 'boolean',340 },341 respect_qwen_ignore: {342 description:343 'Optional: Whether to respect .qwenignore and configured custom Qwen ignore file patterns when listing files. Defaults to true.',344 type: 'boolean',345 },346 },347 },348 },349 required: ['path'],350 type: 'object',351 },352 );353 }354 355 /**356 * Validates the parameters for the tool357 * @param params Parameters to validate358 * @returns An error message string if invalid, null otherwise359 */360 protected override validateToolParamValues(361 params: LSToolParams,362 ): string | null {363 params.path = unescapePath(params.path.trim());364 365 if (!path.isAbsolute(params.path)) {366 return `Path must be absolute: ${params.path}`;367 }368 369 return null;370 }371 372 protected createInvocation(373 params: LSToolParams,374 ): ToolInvocation<LSToolParams, ToolResult> {375 return new LSToolInvocation(this.config, params);376 }377}378 