feat(find): grouped output by directory and clamped limit to 200

- Changed output format to group results under `# /` headers to reduce token usage for shared path prefixes.
- Clamped the `limit` parameter to 1-200 (default 200) instead of the previous 1000.
- Updated tests to assert against raw file lists instead of parsed text output.
This commit is contained in:
can1357
2026-05-27 13:13:11 +02:00
parent 4acfc50220
commit 41d509eff0
4 changed files with 53 additions and 48 deletions
@@ -5,17 +5,18 @@ Finds files and directories using fast pattern matching that works with any code
- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
- `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
- `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
- `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit
- `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly
- You SHOULD perform multiple searches in parallel when potentially useful
</instruction>
<output>
Matching file and directory paths sorted by modification time (most recent first). Directories are suffixed with `/`. Truncated at 1000 entries or 50KB (configurable via `limit`).
Matching file and directory paths sorted by modification time (most recent first), grouped by directory to reduce token usage. Each group starts with `# <dir>/` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB.
</output>
<examples>
# Find files
`{"paths": ["src/**/*.ts"], "limit": 1000}`
`{"paths": ["src/**/*.ts"]}`
# Multiple targets — separate array elements
`{"paths": ["src/**/*.ts", "test/**/*.ts"]}`
# Find gitignored files like .env
+38 -6
View File
@@ -39,14 +39,15 @@ const findSchema = z
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(1000).describe("max results").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 = 1000;
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;
@@ -78,6 +79,37 @@ export function validateFindPathInputs(paths: readonly string[]): void {
}
}
/**
* 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;
@@ -195,11 +227,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
if (searchPath === "/") {
throw new ToolError("Searching from root directory '/' is not allowed");
}
const rawLimit = limit ?? DEFAULT_LIMIT;
const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
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;
@@ -241,7 +273,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
const listLimit = applyListLimit(files, { limit: effectiveLimit });
const limited = listLimit.items;
const limitMeta = listLimit.meta;
const baseOutput = limited.join("\n");
const baseOutput = formatFindGroupedOutput(limited);
const trailingNotes: string[] = [];
if (notice) trailingNotes.push(notice);
if (missingPathsNote) trailingNotes.push(missingPathsNote);
+11 -40
View File
@@ -1683,13 +1683,9 @@ function b() {
hidden: true,
});
const outputLines = getTextOutput(result)
.split("\n")
.map(line => line.trim())
.filter(Boolean);
expect(outputLines).toContain("visible.txt");
expect(outputLines).toContain(".secret/hidden.txt");
const files = (result.details?.files ?? []).slice().sort();
expect(files).toContain("visible.txt");
expect(files).toContain(".secret/hidden.txt");
});
it("should respect .gitignore", async () => {
@@ -1727,12 +1723,7 @@ function b() {
paths: [`${testDir}/**/auth-actions.spec.ts`],
});
const outputLines = getTextOutput(result)
.split("\n")
.map(line => line.trim())
.filter(Boolean);
expect(outputLines).toEqual(["z/auth-actions.spec.ts", "a/auth-actions.spec.ts"]);
expect(result.details?.files).toEqual(["z/auth-actions.spec.ts", "a/auth-actions.spec.ts"]);
});
it("should render nested glob results relative to the session cwd", async () => {
@@ -1744,12 +1735,7 @@ function b() {
paths: ["apps/daemon/src/**/daemon-telemetry.ts"],
});
const outputLines = getTextOutput(result)
.split("\n")
.map(line => line.trim())
.filter(Boolean);
expect(outputLines).toEqual(["apps/daemon/src/telemetry/daemon-telemetry.ts"]);
expect(result.details?.files).toEqual(["apps/daemon/src/telemetry/daemon-telemetry.ts"]);
});
it("should not double-prefix multi-pattern results under a shared base", async () => {
@@ -1764,13 +1750,8 @@ function b() {
paths: ["apps/daemon/src/**/*.ts", "apps/client/src/**/*.ts"],
});
const outputLines = getTextOutput(result)
.split("\n")
.map(line => line.trim())
.filter(Boolean)
.sort();
expect(outputLines).toEqual(["apps/client/src/client.ts", "apps/daemon/src/daemon.ts"]);
const files = (result.details?.files ?? []).slice().sort();
expect(files).toEqual(["apps/client/src/client.ts", "apps/daemon/src/daemon.ts"]);
});
it("should not disable gitignore after an ignored broad hidden-file search finds no matches", async () => {
@@ -1803,13 +1784,8 @@ function b() {
paths: [`${testDir}/pkg/**/*`],
});
const outputLines = getTextOutput(result)
.split("\n")
.map(line => line.trim())
.filter(Boolean)
.sort();
expect(outputLines).toEqual(["pkg/file.txt", "pkg/nested/", "pkg/nested/deep.txt"]);
const files = (result.details?.files ?? []).slice().sort();
expect(files).toEqual(["pkg/file.txt", "pkg/nested/", "pkg/nested/deep.txt"]);
});
it("should match a directory by glob and emit it with trailing slash", async () => {
@@ -1821,13 +1797,8 @@ function b() {
paths: [`${testDir}/**/tests`],
});
const outputLines = getTextOutput(result)
.split("\n")
.map(line => line.trim())
.filter(Boolean)
.sort();
expect(outputLines).toEqual(["alpha/tests/", "beta/tests/"]);
const files = (result.details?.files ?? []).slice().sort();
expect(files).toEqual(["alpha/tests/", "beta/tests/"]);
});
});
});
+1
View File
@@ -13,6 +13,7 @@
### Removed
- Removed the `A-B!` / `A!` deletion operator. Use `A-B:` with the desired payload (or empty payload to blank the range) instead.
All notable changes to this package will be documented in this file.
## [15.5.4] - 2026-05-27