Files
oh-my-pi/packages/coding-agent/src/tools/find.ts
T
roboomp d3477372a2 fix(natives): bounded sorted glob scans
Kept uncached sort-by-mtime glob traversal bounded to maxResults and emitted onMatch callbacks only for returned matches so broad find scans cannot grow parent memory independently of the limit.

Fixes #1761
2026-06-03 06:15:26 +00:00

572 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import * as natives from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import findDescription from "../prompts/tools/find.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output";
import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import {
expandDelimitedPathEntries,
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseFindPattern,
partitionExistingPaths,
resolveExplicitFindPatterns,
resolveToCwd,
} from "./path-utils";
import {
createCachedComponent,
formatCount,
formatEmptyMessage,
formatErrorMessage,
PREVIEW_LIMITS,
} from "./render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
const findSchema = z
.object({
paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
hidden: z.boolean().default(true).describe("include hidden files").optional(),
gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(),
timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
})
.strict();
export type FindToolInput = z.infer<typeof findSchema>;
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
const DEFAULT_GLOB_TIMEOUT_MS = 5000;
const MIN_GLOB_TIMEOUT_MS = 500;
const MAX_GLOB_TIMEOUT_MS = 60_000;
/**
* Group find matches by their directory so the model doesn't pay repeated
* tokens for shared path prefixes. Preserves the input order: groups appear in
* the order their first member was emitted (mtime-desc for native glob), and
* within a group entries keep their relative order.
*/
export function formatFindGroupedOutput(paths: readonly string[]): string {
if (paths.length === 0) return "";
const groups = new Map<string, string[]>();
for (const entry of paths) {
const hasTrailingSlash = entry.endsWith("/");
const trimmed = hasTrailingSlash ? entry.slice(0, -1) : entry;
const slash = trimmed.lastIndexOf("/");
const dir = slash === -1 ? "" : trimmed.slice(0, slash);
const base = slash === -1 ? trimmed : trimmed.slice(slash + 1);
const label = hasTrailingSlash ? `${base}/` : base;
const list = groups.get(dir);
if (list) list.push(label);
else groups.set(dir, [label]);
}
const sections: string[] = [];
for (const [dir, entries] of groups) {
if (dir === "") {
sections.push(entries.join("\n"));
} else {
sections.push(`# ${dir}/\n${entries.join("\n")}`);
}
}
return sections.join("\n\n");
}
export interface FindToolDetails {
truncation?: TruncationResult;
resultLimitReached?: number;
meta?: OutputMeta;
// Fields for TUI rendering
scopePath?: string;
fileCount?: number;
files?: string[];
truncated?: boolean;
error?: string;
/** Working directory at search time. Used by the renderer to resolve relative
* file paths to absolute paths for OSC 8 hyperlinks. */
cwd?: string;
/** User-supplied paths whose base directory was missing on disk. The tool
* skipped these and continued with the surviving entries; surfaced as a
* non-fatal warning in the renderer and in the model-facing text. */
missingPaths?: string[];
}
/**
* Pluggable operations for the find tool.
* Override these to delegate file search to remote systems (e.g., SSH).
*/
export interface FindOperations {
/** Check if path exists */
exists: (absolutePath: string) => Promise<boolean> | boolean;
/** Optional stat for distinguishing files vs directories. */
stat?: (
absolutePath: string,
) => Promise<{ isFile(): boolean; isDirectory(): boolean }> | { isFile(): boolean; isDirectory(): boolean };
/** Find files matching glob pattern. Returns relative paths. */
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
}
export interface FindToolOptions {
/** Custom operations for find. Default: local filesystem + rg */
operations?: FindOperations;
}
export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
readonly name = "find";
readonly approval = "read" as const;
readonly summary = "Find files and directories matching a glob pattern";
readonly loadMode = "discoverable";
readonly label = "Find";
readonly description: string;
readonly parameters = findSchema;
readonly strict = true;
readonly #customOps?: FindOperations;
constructor(
private readonly session: ToolSession,
options?: FindToolOptions,
) {
this.#customOps = options?.operations;
this.description = prompt.render(findDescription);
}
async execute(
_toolCallId: string,
params: z.infer<typeof findSchema>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<FindToolDetails>> {
const { paths, limit, hidden, gitignore, timeout } = params;
return untilAborted(signal, async () => {
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
const rawPatternInputs = this.#customOps
? paths
: await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
const internalRouter = InternalUrlRouter.instance();
const normalizedPatterns: string[] = [];
for (const rawPattern of rawPatterns) {
if (!internalRouter.canHandle(rawPattern)) {
normalizedPatterns.push(rawPattern);
continue;
}
if (hasGlobPathChars(rawPattern)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
}
const resource = await internalRouter.resolve(rawPattern, {
cwd: this.session.cwd,
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
if (!resource.sourcePath) {
throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
}
normalizedPatterns.push(resource.sourcePath);
}
if (normalizedPatterns.some(pattern => pattern.length === 0)) {
throw new ToolError("`paths` must contain non-empty globs or paths");
}
// Tolerate missing entries in a multi-path call: skip ones whose base
// directory is gone, and only error if every entry is missing. Single
// missing path keeps the original ENOENT semantics — the user explicitly
// asked about that one path, so silent empty results would be misleading.
let missingPaths: string[] = [];
let effectivePatterns = normalizedPatterns;
if (normalizedPatterns.length > 1 && !this.#customOps) {
const partition = await partitionExistingPaths(normalizedPatterns, this.session.cwd, parseFindPattern);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePatterns = partition.valid;
missingPaths = partition.missing;
}
const multiPattern = await resolveExplicitFindPatterns(effectivePatterns, this.session.cwd);
const parsedPattern = multiPattern ? null : parseFindPattern(effectivePatterns[0] ?? ".");
const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
const scopePath = multiPattern?.scopePath ?? formatScopePath(searchPath);
if (searchPath === "/") {
throw new ToolError("Searching from root directory '/' is not allowed");
}
const requestedLimit = limit ?? DEFAULT_LIMIT;
if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
throw new ToolError("Limit must be a positive number");
}
const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
const includeHidden = hidden ?? true;
const useGitignore = gitignore ?? true;
const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
const timeoutMs = Math.min(MAX_GLOB_TIMEOUT_MS, Math.max(MIN_GLOB_TIMEOUT_MS, requestedTimeoutMs));
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(searchPath, matchPath);
return formatPathRelativeToCwd(absolutePath, this.session.cwd, {
trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash,
});
};
const missingPathsNote =
missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;
const buildResult = (
files: string[],
opts?: { notice?: string; forceTruncated?: boolean },
): AgentToolResult<FindToolDetails> => {
const notice = opts?.notice;
const forceTruncated = opts?.forceTruncated ?? false;
if (files.length === 0) {
const details: FindToolDetails = {
scopePath,
fileCount: 0,
files: [],
truncated: forceTruncated,
cwd: this.session.cwd,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
const parts = ["No files found matching pattern"];
if (notice) parts.push(notice);
if (missingPathsNote) parts.push(missingPathsNote);
return toolResult(details).text(parts.join("\n")).done();
}
const listLimit = applyListLimit(files, { limit: effectiveLimit });
const limited = listLimit.items;
const limitMeta = listLimit.meta;
const baseOutput = formatFindGroupedOutput(limited);
const trailingNotes: string[] = [];
if (notice) trailingNotes.push(notice);
if (missingPathsNote) trailingNotes.push(missingPathsNote);
const rawOutput = trailingNotes.length > 0 ? `${baseOutput}\n\n${trailingNotes.join("\n")}` : baseOutput;
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
const details: FindToolDetails = {
scopePath,
fileCount: limited.length,
files: limited,
truncated: Boolean(forceTruncated || limitMeta.resultLimit || truncation.truncated),
resultLimitReached: limitMeta.resultLimit?.reached,
truncation: truncation.truncated ? truncation : undefined,
cwd: this.session.cwd,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
const resultBuilder = toolResult(details)
.text(truncation.content)
.limits({ resultLimit: limitMeta.resultLimit?.reached });
if (truncation.truncated) {
resultBuilder.truncation(truncation, { direction: "head" });
}
return resultBuilder.done();
};
if (this.#customOps?.glob) {
if (!(await this.#customOps.exists(searchPath))) {
throw new ToolError(`Path not found: ${scopePath}`);
}
if (!hasGlob && this.#customOps.stat) {
const stat = await this.#customOps.stat(searchPath);
if (stat.isFile()) {
return buildResult([scopePath]);
}
}
const results = await this.#customOps.glob(globPattern, searchPath, {
ignore: ["**/node_modules/**", "**/.git/**"],
limit: effectiveLimit,
});
const relativized = results.map(p => formatMatchPath(p));
return buildResult(relativized);
}
let searchStat: fs.Stats;
try {
searchStat = await fs.promises.stat(searchPath);
} catch (err) {
if (isEnoent(err)) {
throw new ToolError(`Path not found: ${scopePath}`);
}
throw err;
}
if (!hasGlob && searchStat.isFile()) {
return buildResult([scopePath]);
}
if (!searchStat.isDirectory()) {
throw new ToolError(`Path is not a directory: ${searchPath}`);
}
let matches: natives.GlobMatch[];
const onUpdateMatches: string[] = [];
const onUpdateMtimes: number[] = [];
const updateIntervalMs = 200;
let lastUpdate = 0;
const emitUpdate = () => {
if (!onUpdate) return;
const now = Date.now();
if (now - lastUpdate < updateIntervalMs) return;
lastUpdate = now;
const details: FindToolDetails = {
scopePath,
fileCount: onUpdateMatches.length,
files: onUpdateMatches.slice(),
truncated: false,
};
onUpdate({
content: [{ type: "text", text: onUpdateMatches.join("\n") }],
details,
});
};
const onMatch = (err: Error | null, match: natives.GlobMatch | null) => {
if (err || combinedSignal.aborted || !match?.path) return;
const relativePath = formatMatchPath(match.path, match.fileType);
onUpdateMatches.push(relativePath);
onUpdateMtimes.push(match.mtime ?? 0);
emitUpdate();
};
const doGlob = async (useGitignore: boolean) =>
untilAborted(combinedSignal, () =>
natives.glob(
{
pattern: globPattern,
path: searchPath,
hidden: includeHidden,
maxResults: effectiveLimit,
sortByMtime: true,
gitignore: useGitignore,
signal: combinedSignal,
},
onMatch,
),
);
let timedOut = false;
try {
const result = await doGlob(useGitignore);
// Native glob returns a bounded mtime-ranked set; keep the JS sort for
// deterministic ordering across cached and uncached native paths.
result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
matches = result.matches;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (timeoutSignal.aborted && !signal?.aborted) {
timedOut = true;
matches = [];
} else {
throw new ToolAbortError();
}
} else {
throw error;
}
}
if (timedOut) {
// Drain the partial matches accumulated during streaming and return them
// instead of throwing — empty results after a multi-second wait force the
// caller to retry blind, which is the worst possible outcome.
const seen = new Set<string>();
const partial: Array<{ p: string; m: number }> = [];
for (let i = 0; i < onUpdateMatches.length; i++) {
const entry = onUpdateMatches[i];
if (seen.has(entry)) continue;
seen.add(entry);
partial.push({ p: entry, m: onUpdateMtimes[i] ?? 0 });
}
partial.sort((a, b) => b.m - a.m);
const sortedPaths = partial.map(e => e.p);
const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — increase timeout or narrow pattern`;
return buildResult(sortedPaths, { notice, forceTruncated: true });
}
const relativized: string[] = [];
for (const match of matches) {
throwIfAborted(signal);
if (!match.path) {
continue;
}
relativized.push(formatMatchPath(match.path, match.fileType));
}
return buildResult(relativized);
});
}
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface FindRenderArgs {
paths?: string | string[];
limit?: number;
}
function formatFindRenderPaths(paths: FindRenderArgs["paths"]): string | undefined {
return Array.isArray(paths) ? paths.join(", ") : paths;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
export const findToolRenderer = {
inline: true,
renderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
const text = renderStatusLine(
{ icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },
uiTheme,
);
return new Text(text, 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean },
options: RenderResultOptions,
uiTheme: Theme,
args?: FindRenderArgs,
): Component {
const details = result.details;
if (result.isError || details?.error) {
const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
}
const hasDetailedData = details?.fileCount !== undefined;
const textContent = result.content?.find(c => c.type === "text")?.text;
if (!hasDetailedData) {
if (
!textContent ||
textContent.includes("No files matching") ||
textContent.includes("No files found") ||
textContent.trim() === ""
) {
return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
}
const lines = textContent.split("\n").filter(l => l.trim());
const header = renderStatusLine(
{
icon: "success",
title: "Find",
description: formatFindRenderPaths(args?.paths),
meta: [formatCount("file", lines.length)],
},
uiTheme,
);
return createCachedComponent(
() => options.expanded,
width => {
const listLines = renderTreeList(
{
items: lines,
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
itemType: "file",
renderItem: line => uiTheme.fg("accent", line),
},
uiTheme,
);
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
}
const fileCount = details?.fileCount ?? 0;
const truncation = details?.truncation ?? details?.meta?.truncation;
const limits = details?.meta?.limits;
const truncated = Boolean(details?.truncated || truncation || details?.resultLimitReached || limits?.resultLimit);
const files = details?.files ?? [];
const missingPaths = details?.missingPaths ?? [];
const missingNote =
missingPaths.length > 0 ? uiTheme.fg("warning", `skipped missing: ${missingPaths.join(", ")}`) : undefined;
if (fileCount === 0) {
const header = renderStatusLine(
{ icon: "warning", title: "Find", description: formatFindRenderPaths(args?.paths), meta: ["0 files"] },
uiTheme,
);
const lines = [header, formatEmptyMessage("No files found", uiTheme)];
if (missingNote) lines.push(missingNote);
return new Text(lines.join("\n"), 0, 0);
}
const meta: string[] = [formatCount("file", fileCount)];
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
const header = renderStatusLine(
{
icon: truncated ? "warning" : "success",
title: "Find",
description: formatFindRenderPaths(args?.paths),
meta,
},
uiTheme,
);
const truncationReasons: string[] = [];
if (details?.resultLimitReached) truncationReasons.push(`limit ${details.resultLimitReached} results`);
if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
const artifactId = truncation && "artifactId" in truncation ? truncation.artifactId : undefined;
if (artifactId) truncationReasons.push(formatFullOutputReference(artifactId));
const extraLines: string[] = [];
if (truncationReasons.length > 0) {
extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`));
}
if (missingNote) extraLines.push(missingNote);
return createCachedComponent(
() => options.expanded,
width => {
const cwd = details?.cwd;
const fileLines = renderFileList(
{
files: files.map(entry => ({
path: entry,
isDirectory: entry.endsWith("/"),
absPath: cwd && !entry.endsWith("/") ? path.resolve(cwd, entry) : undefined,
})),
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
hyperlinkFn: fileHyperlink,
},
uiTheme,
);
return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
mergeCallAndResult: true,
};