feat(agent): add glob tool for file pattern matching (#18)
Add a glob tool that allows agents to find files matching glob patterns.
Features:
- Supports standard glob patterns (e.g., **/*.ts, src/**/*.{js,jsx})
- Results sorted by modification time (most recent first)
- Configurable result limit and ignore patterns
- Default ignores for node_modules, .git, dist, etc.
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5931e8f84e
commit
95e6ae439d
4 changed files with 141 additions and 2 deletions
|
|
@ -32,13 +32,14 @@
|
|||
"@mariozechner/pi-agent-core": "^0.50.3",
|
||||
"@mariozechner/pi-ai": "^0.50.3",
|
||||
"@mariozechner/pi-coding-agent": "^0.50.3",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"@nestjs/common": "^11.1.12",
|
||||
"@nestjs/core": "^11.1.12",
|
||||
"@nestjs/platform-express": "^11.1.12",
|
||||
"@nestjs/platform-socket.io": "^11.1.12",
|
||||
"@nestjs/serve-static": "^5.0.4",
|
||||
"@nestjs/websockets": "^11.1.12",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"fast-glob": "^3.3.3",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"pino": "^10.3.0",
|
||||
"pino-http": "^11.0.0",
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -38,6 +38,9 @@ importers:
|
|||
'@sinclair/typebox':
|
||||
specifier: ^0.34.41
|
||||
version: 0.34.48
|
||||
fast-glob:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
nestjs-pino:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.0)(rxjs@7.8.2)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createCodingTools } from "@mariozechner/pi-coding-agent";
|
|||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { createExecTool } from "./tools/exec.js";
|
||||
import { createProcessTool } from "./tools/process.js";
|
||||
import { createGlobTool } from "./tools/glob.js";
|
||||
|
||||
export function resolveModel(options: AgentOptions) {
|
||||
if (options.provider && options.model) {
|
||||
|
|
@ -21,5 +22,6 @@ export function resolveTools(options: AgentOptions): AgentTool<any>[] {
|
|||
const baseTools = createCodingTools(cwd).filter((tool) => tool.name !== "bash") as AgentTool<any>[];
|
||||
const execTool = createExecTool(cwd);
|
||||
const processTool = createProcessTool(cwd);
|
||||
return [...baseTools, execTool as AgentTool<any>, processTool as AgentTool<any>];
|
||||
const globTool = createGlobTool(cwd);
|
||||
return [...baseTools, execTool as AgentTool<any>, processTool as AgentTool<any>, globTool as AgentTool<any>];
|
||||
}
|
||||
|
|
|
|||
133
src/agent/tools/glob.ts
Normal file
133
src/agent/tools/glob.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import fg from "fast-glob";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
const GlobSchema = Type.Object({
|
||||
pattern: Type.String({
|
||||
description: "Glob pattern to match files (e.g., '**/*.ts', 'src/**/*.{js,jsx}').",
|
||||
}),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
description: "Directory to search in. Defaults to current working directory.",
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Maximum number of results to return. Defaults to 100.",
|
||||
minimum: 1,
|
||||
maximum: 1000,
|
||||
}),
|
||||
),
|
||||
ignore: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Patterns to exclude from results (e.g., ['node_modules/**', '*.test.ts']).",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type GlobArgs = {
|
||||
pattern: string;
|
||||
cwd?: string;
|
||||
limit?: number;
|
||||
ignore?: string[];
|
||||
};
|
||||
|
||||
export type GlobResult = {
|
||||
files: string[];
|
||||
count: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const DEFAULT_IGNORE = [
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/.next/**",
|
||||
"**/coverage/**",
|
||||
];
|
||||
|
||||
export function createGlobTool(defaultCwd?: string): AgentTool<typeof GlobSchema, GlobResult> {
|
||||
return {
|
||||
name: "glob",
|
||||
label: "Glob",
|
||||
description:
|
||||
"Find files matching a glob pattern. Returns file paths sorted by modification time (most recent first). " +
|
||||
"Use this to discover files in the codebase before reading them. " +
|
||||
"Examples: '**/*.ts' for all TypeScript files, 'src/**/*.{js,jsx}' for JS files in src.",
|
||||
parameters: GlobSchema,
|
||||
execute: async (_toolCallId, args, _signal) => {
|
||||
const { pattern, cwd, limit, ignore } = args as GlobArgs;
|
||||
|
||||
if (!pattern || pattern.trim() === "") {
|
||||
throw new Error("Pattern must not be empty");
|
||||
}
|
||||
|
||||
const searchDir = cwd || defaultCwd || process.cwd();
|
||||
const maxResults = Math.min(limit || DEFAULT_LIMIT, 1000);
|
||||
const ignorePatterns = ignore || DEFAULT_IGNORE;
|
||||
|
||||
// Verify the search directory exists
|
||||
try {
|
||||
const stat = await fs.stat(searchDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${searchDir}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new Error(`Directory not found: ${searchDir}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Run glob search
|
||||
const files = await fg(pattern, {
|
||||
cwd: searchDir,
|
||||
ignore: ignorePatterns,
|
||||
onlyFiles: true,
|
||||
followSymbolicLinks: true,
|
||||
dot: true, // Include dotfiles
|
||||
absolute: false, // Return relative paths
|
||||
suppressErrors: true, // Don't throw on permission errors
|
||||
});
|
||||
|
||||
// Get file stats for sorting by modification time
|
||||
const filesWithStats = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fullPath = path.join(searchDir, file);
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
return { file, mtime: stat.mtimeMs };
|
||||
} catch {
|
||||
// If we can't stat the file, use 0 as mtime
|
||||
return { file, mtime: 0 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
// Apply limit
|
||||
const truncated = filesWithStats.length > maxResults;
|
||||
const limitedFiles = filesWithStats.slice(0, maxResults).map((f) => f.file);
|
||||
|
||||
const resultText =
|
||||
limitedFiles.length > 0
|
||||
? limitedFiles.join("\n") + (truncated ? `\n... (${filesWithStats.length - maxResults} more files)` : "")
|
||||
: "No files found matching the pattern.";
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: resultText }],
|
||||
details: {
|
||||
files: limitedFiles,
|
||||
count: limitedFiles.length,
|
||||
truncated,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue