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:
Jiayuan 2026-01-30 04:33:01 +08:00 committed by GitHub
parent 5931e8f84e
commit 95e6ae439d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 141 additions and 2 deletions

View file

@ -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
View file

@ -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)

View file

@ -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
View 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,
},
};
},
};
}