From 41d509eff032cbc9df0a38fa490810edc489405a Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 27 May 2026 13:13:11 +0200 Subject: [PATCH] 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. --- .../coding-agent/src/prompts/tools/find.md | 5 +- packages/coding-agent/src/tools/find.ts | 44 +++++++++++++--- packages/coding-agent/test/tools.test.ts | 51 ++++--------------- packages/hashline/CHANGELOG.md | 1 + 4 files changed, 53 insertions(+), 48 deletions(-) diff --git a/packages/coding-agent/src/prompts/tools/find.md b/packages/coding-agent/src/prompts/tools/find.md index 3d0742495..b6b0a0f6e 100644 --- a/packages/coding-agent/src/prompts/tools/find.md +++ b/packages/coding-agent/src/prompts/tools/find.md @@ -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 -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 `# /` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB. # 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 diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index a76795ca8..85f6c2484 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -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; -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(); + 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 { 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 { 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); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index f0e02af6e..22c549db2 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -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/"]); }); }); }); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 5907384ae..a801ca73d 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -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