diff --git a/.changeset/glob-pagination.md b/.changeset/glob-pagination.md new file mode 100644 index 00000000000..d67f3026a2e --- /dev/null +++ b/.changeset/glob-pagination.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow file searches to retrieve matches beyond the first 100 results. diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index 646a0de41cc..36111c39303 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -64,6 +64,10 @@ const PAGINATION_TOTAL = /^Results truncated to \d+ lines \(total: (\d+)/m; const INCOMPLETE = /^(?:\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at \d+ matches|Only the first \d+ matches)/m; +const GLOB_PAGE = /^Showing matches (\d+)–(\d+) of (\d+)( collected matches \(partial result set\))?\.$/m; +const GLOB_CONTINUATION = /^(?:Continue with the same search arguments and offset=\d+\.|(?:To retrieve all collected matches in one search|To remove the match-count limit), omit offset and use head_limit=0\.|Character limit reached; only complete paths are returned\.)$/; +const GLOB_EMPTY = /^(?:No more matches at offset=\d+ in the (?:current|collected partial) result set \(\d+ matches\)\.|No matches collected; search incomplete\.)$/m; + // `path:line:text`; context lines use `-` separators and are not matches. const CONTENT_MATCH = /^(.+?):(\d+):/; const COUNT_LINE = /^(.+):(\d+)$/; @@ -177,7 +181,13 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr } export function parseGlobOutput(output: string): GlobStats { - return { entries: resultLines(output), partial: INCOMPLETE.test(output) }; + const page = GLOB_PAGE.exec(output); + const entries = resultLines(output).filter((line) => + !GLOB_PAGE.test(line) && !GLOB_CONTINUATION.test(line) && !GLOB_EMPTY.test(line), + ); + const partial = INCOMPLETE.test(output) || + (page !== null && (Number(page[2]) < Number(page[3]) || page[4] !== undefined)); + return { entries, partial }; } // Every match was a file the tool excludes as sensitive: the search did find @@ -195,5 +205,8 @@ export function searchNoticeOnly(toolCall: ToolCallBlockData, output: string): b toolCall.name === 'Glob' ? parseGlobOutput(output).entries.length === 0 : parseGrepOutput(toolCall, output).entries.length === 0; - return noRows && (INCOMPLETE.test(output) || SENSITIVE_ONLY.test(output)); + return noRows && ( + INCOMPLETE.test(output) || SENSITIVE_ONLY.test(output) || + (toolCall.name === 'Glob' && GLOB_EMPTY.test(output)) + ); } diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index da5b51f2f31..849ce82b7e2 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -139,6 +139,36 @@ describe('chip registry', () => { expect(chipFor('Glob', { pattern: '**/*.ts' }, result('a.ts\nb.ts'))).toBe('2 files'); }); + it.each([ + 'To retrieve all collected matches in one search, omit offset and use head_limit=0.', + 'To remove the match-count limit, omit offset and use head_limit=0.', + ])('counts only paths on a Glob page with continuation notice: %s', (notice) => { + const output = [ + 'Showing matches 1–100 of 347.', + 'Continue with the same search arguments and offset=100.', + notice, + ...Array.from({ length: 100 }, (_, i) => `file-${String(i)}.ts`), + ].join('\n'); + expect(chipFor('Glob', {}, result(output))).toBe('100+ files'); + }); + + it.each([ + 'No more matches at offset=347 in the current result set (347 matches).', + 'No matches collected; search incomplete.', + ])('does not count an empty Glob page as a file: %s', (output) => { + expect(chipFor('Glob', {}, result(output))).toBe(''); + }); + + it('distinguishes the last Glob page from incomplete search results', () => { + expect(chipFor('Glob', {}, result('Showing matches 3–4 of 4.\nc.ts\nd.ts'))).toBe('2 files'); + expect(chipFor('Glob', {}, result('Showing matches 3–4 of 4 collected matches (partial result set).\nc.ts\nd.ts'))).toBe('2+ files'); + }); + + it('keeps notice-like file names and leaves Grep interpretation unchanged', () => { + expect(chipFor('Glob', {}, result('Showing matches.ts\nContinue with.txt\nNo more matches.ts'))).toBe('3 files'); + expect(chipFor('Grep', {}, result('Showing matches 1–2 of 3.'))).toBe('1 file'); + }); + it('FetchURL chip shows size and is non-empty', () => { const out = chipFor('FetchURL', { url: 'https://example.com' }, result('hello world')); expect(out).toMatch(/\d+\s*B/); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 106afe05e4e..81b488c1945 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -226,6 +226,21 @@ describe('tool-result registry', () => { expect(out).toContain('+1 more'); }); + it('keeps Glob pagination notices out of the collapsed path samples', () => { + const output = 'Showing matches 1–2 of 4.\nCharacter limit reached; only complete paths are returned.\nContinue with the same search arguments and offset=2.\na.ts\nb.ts'; + const renderer = pickResultRenderer('Glob'); + expect(strip(joinRender(renderer(call('Glob'), result(output), ctx)))).toBe(' a.ts, b.ts'); + expect(strip(joinRender(renderer(call('Glob'), result(output), expandedCtx)))).toContain('Character limit reached'); + }); + + it.each([ + 'No more matches at offset=347 in the current result set (347 matches).', + 'No matches collected; search incomplete.', + ])('shows a Glob empty-page notice as the outcome: %s', (output) => { + const renderer = pickResultRenderer('Glob'); + expect(strip(joinRender(renderer(call('Glob'), result(output), ctx), 160))).toBe(` ${output}`); + }); + it('FetchURL renders no body when collapsed', () => { const renderer = pickResultRenderer('FetchURL'); const out = joinRender( diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 95f74d7b2fc..df56d7cb15a 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -29,7 +29,9 @@ Tail reads return the newest complete lines in the requested range first. If no **`Grep`** invokes ripgrep to search file contents, supporting regular expressions (`pattern`), a search path (`path`), file type filtering (`type`, e.g., `ts`, `py`), glob filtering (`glob`), and output mode (`output_mode`: `files_with_matches` / `content` / `count_matches`; defaults to `files_with_matches`). `content` mode supports context lines (`-A`, `-B`, `-C`), case-insensitive matching (`-i`), line numbers (`-n`, default true), and multiline matching (`multiline`). All modes support `offset` + `head_limit` pagination; `head_limit` defaults to 250 and `0` means unlimited. Sensitive files such as `.env` files and private keys are automatically filtered out; set `include_ignored=true` to search files ignored by `.gitignore`, though sensitive files remain filtered. -**`Glob`** matches files in a specified directory (`path`; defaults to the working directory) by glob pattern (`pattern`). Results are sorted by modification time in descending order, with a maximum of 100 entries. It respects `.gitignore`, `.ignore`, and `.rgignore` by default; set `include_ignored=true` to include ignored files such as build outputs, while sensitive files remain filtered. Brace patterns such as `*.{ts,tsx}` are supported, and broad wildcard patterns are allowed but usually truncate at the match cap. +**`Glob`** matches files in a specified directory (`path`; defaults to the working directory) by glob pattern (`pattern`). Results are sorted by modification time in descending order, returning 100 entries by default. It respects `.gitignore`, `.ignore`, and `.rgignore` by default; set `include_ignored=true` to include ignored files such as build outputs, while sensitive files remain filtered. Brace patterns such as `*.{ts,tsx}` are supported, and broad wildcard patterns are allowed. + +Use `offset` (default 0) and `head_limit` (default 100) to page through matching paths; the result provides the next offset when more matches are available. Set `head_limit: 0` to remove the match-count limit. The character limit still applies: pages end at a complete path and provide the next offset when necessary. Large pages are saved to a file that the agent can read with `Read`. Each call searches the current filesystem again, so file changes can shift results between pages. Timeouts, unreadable directories, or the output capture limit can still leave the search incomplete; the result warns about these cases, and increasing the offset cannot recover uncollected paths. **`ReadMediaFile`** sends an image or video to the model as multimodal content. It accepts `path`, plus optional image-detail controls such as `region` and `full_resolution`; the file size limit is 100 MB. Default image reads are compressed to the configured model limits. If automatic compression cannot meet those limits safely, the tool returns an error without sending the original image and directs the model to create and read a smaller copy. Availability depends on the current model's vision capabilities (`image_in` / `video_in`). diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 9e6739e4497..f7faf9cda5c 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -29,7 +29,9 @@ **`Grep`** 调用 ripgrep 搜索文件内容,支持正则表达式(`pattern`)、搜索路径(`path`)、文件类型过滤(`type`,如 `ts`、`py`)、glob 过滤(`glob`)和输出模式(`output_mode`:`files_with_matches` / `content` / `count_matches`,默认 `files_with_matches`)。`content` 模式支持上下文行(`-A`、`-B`、`-C`)、忽略大小写(`-i`)、行号(`-n`,默认 true)、跨行匹配(`multiline`)。所有模式支持 `offset` + `head_limit` 分页,`head_limit` 默认 250、传 0 表示不限。`.env`、私钥等敏感文件会被自动过滤;`include_ignored=true` 可搜索被 `.gitignore` 忽略的文件,但敏感文件仍保持过滤。 -**`Glob`** 按 glob 模式(`pattern`)在指定目录(`path`,默认工作目录)中匹配文件,结果按修改时间倒序排列,最多返回 100 条。默认尊重 `.gitignore`、`.ignore` 和 `.rgignore`;设置 `include_ignored=true` 可包含构建产物等被忽略的文件,但敏感文件仍会被过滤。支持 `*.{ts,tsx}` 这类花括号模式,也允许宽泛通配符模式,但通常会在匹配上限处截断。 +**`Glob`** 按 glob 模式(`pattern`)在指定目录(`path`,默认工作目录)中匹配文件,结果按修改时间倒序排列,默认返回 100 条。默认尊重 `.gitignore`、`.ignore` 和 `.rgignore`;设置 `include_ignored=true` 可包含构建产物等被忽略的文件,但敏感文件仍会被过滤。支持 `*.{ts,tsx}` 这类花括号模式,也允许宽泛通配符模式。 + +使用 `offset`(默认 0)和 `head_limit`(默认 100)对匹配路径分页;有更多结果时,工具会给出下一页的 offset。设置 `head_limit: 0` 可取消条数限制,但字符上限仍然有效:达到上限时,页面会在完整路径处结束,并给出下一页的 offset。较大的页面会保存到文件,Agent 可用 `Read` 读取。每次调用都会重新搜索当前文件系统,因此文件变化可能导致跨页结果移动。超时、目录无法读取或输出采集上限仍可能造成搜索不完整;结果会提示这些情况,增加 offset 无法恢复尚未收集的路径。 **`ReadMediaFile`** 将图片或视频以多模态内容发送给模型。它接受 `path`,以及 `region`、`full_resolution` 等可选的图片细节参数;文件大小上限为 100 MB。默认读图会按配置的模型限制压缩;如果自动压缩无法安全满足限制,工具会返回错误且不发送原图,并提示模型先创建更小的副本再读取。是否可用取决于当前模型的视觉能力(`image_in` / `video_in`)。 diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/glob.md b/packages/agent-core-v2/src/agent/tools/os/glob/glob.md index ad299e29afc..6e364bd919c 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/glob.md +++ b/packages/agent-core-v2/src/agent/tools/os/glob/glob.md @@ -10,7 +10,9 @@ Good patterns: - `*.{ts,tsx}` — brace expansion is supported - `{src,test}/**/*.ts` — cartesian brace expansion is supported too -Results are capped at the first 100 matching paths. If a search would return more, a truncation marker is appended. Refine the pattern (extension, subdirectory) when 100 is not enough, or call again with a narrower anchor. +Results default to 100 matching paths. Use `offset` (default 0) and `head_limit` (default 100) to page through results. When more matches are available, the result gives the next offset; keep the other search arguments unchanged. Set `head_limit=0` to remove the match-count limit. Pages still stay within the character retention limit, including notices: when it is reached, only complete paths are returned, with the next offset for continuation. Large pages are saved to a file with a path for Read. + +Each call searches the current filesystem again; pagination is not a snapshot, and file changes can shift results between pages. To collect a large list, use `head_limit=0`, read any saved output, and follow continuation offsets if the character limit is reached. Search timeouts, traversal errors, and output capture limits can still produce partial results; the result reports these limits, and pagination cannot recover paths that were never collected. Narrow the search and retry when it is incomplete. Large-directory caveat — avoid recursing into dependency / build output even with an anchor, especially when `include_ignored` is set: -- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` can produce thousands of results that truncate at the match cap and waste context. Prefer specific subpaths like `node_modules/react/src/**/*.js`. +- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` can produce thousands of results and waste search time and context. Prefer specific subpaths like `node_modules/react/src/**/*.js` unless you need a complete listing. diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts b/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts index 5043a2e68bf..a26fc4320e5 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/glob.ts @@ -5,6 +5,22 @@ import { type AgentTool } from '#/tool/toolContract'; export const GlobInputSchema = z.object({ pattern: z.string().describe('Glob pattern to match files.'), + head_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Maximum number of matching paths to return after offset. Defaults to 100. Pass 0 to remove the match-count limit. The character limit still applies: large pages are saved for Read, and a continuation offset is provided when more paths remain. Search time and output capture limits still apply.', + ), + offset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of matching paths to skip. Defaults to 0. Each call searches the current filesystem again; changes can shift results between pages.', + ), path: z .string() .optional() @@ -27,7 +43,7 @@ export const GlobInputSchema = z.object({ export type GlobInput = z.infer; -export const MAX_MATCHES = 100; +export const DEFAULT_HEAD_LIMIT = 100; export const WINDOWS_PATH_HINT = '\n\nWindows note: the `path` argument accepts both Windows paths ' + diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts index 75110f802bb..7be6dda07a6 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts @@ -17,6 +17,7 @@ import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { + DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS, ToolAccesses, type ExecutableToolResult, type ToolExecution, @@ -37,7 +38,7 @@ import { type GlobInput, GlobInputSchema, IGlobTool, - MAX_MATCHES, + DEFAULT_HEAD_LIMIT, WINDOWS_PATH_HINT, } from './glob'; @@ -238,50 +239,90 @@ export class GlobTool implements IGlobTool { } } - const truncated = kept.length > MAX_MATCHES; - const limited = truncated ? kept.slice(0, MAX_MATCHES) : kept; - - if (limited.length === 0 && !timedOut) { - if (filteredSensitive > 0) { - return { - output: `No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`, - }; - } - return { output: 'No matches found' }; - } + const offset = args.offset ?? 0; + const headLimit = args.head_limit ?? DEFAULT_HEAD_LIMIT; + const limited = headLimit === 0 ? kept.slice(offset) : kept.slice(offset, offset + headLimit); + const partial = bufferTruncated || timedOut || traversalWarning !== undefined; const pathClass = env.pathClass; const shouldRelativize = isWithinDirectory(searchRoot, workspace.workspaceDir, pathClass); - const displayLines = limited.map((p) => + const candidates = limited.map((p) => shouldRelativize ? relativizeIfUnder(p, searchRoot, pathClass) : p, ); - const lines: string[] = []; + const warnings: string[] = []; if (timedOut) { - lines.push( + warnings.push( `Glob timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned.`, ); } if (bufferTruncated) { - lines.push( + warnings.push( `[stdout truncated at ${String(MAX_OUTPUT_BYTES)} bytes; results may be incomplete — use a more specific pattern]`, ); } if (traversalWarning !== undefined) { - lines.push(traversalWarning); + warnings.push(traversalWarning); } - if (truncated) { - lines.push(`[Truncated at ${String(MAX_MATCHES)} matches — use a more specific pattern]`); - lines.push(`Only the first ${String(MAX_MATCHES)} matches are returned.`); - } - lines.push(...displayLines); - if (filteredSensitive > 0) { - lines.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`); + const pageNotices = (count: number, characterLimited: boolean) => { + const lines = [...warnings]; + const footer: string[] = []; + const truncated = characterLimited || offset + count < kept.length; + if (count === 0) { + if (kept.length > 0) { + const resultSet = partial ? 'collected partial result set' : 'current result set'; + lines.push( + `No more matches at offset=${String(offset)} in the ${resultSet} (${String(kept.length)} matches).`, + ); + } else if (partial) { + lines.push('No matches collected; search incomplete.'); + } else if (filteredSensitive > 0) { + lines.push( + `No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`, + ); + } else { + lines.push('No matches found'); + } + } else if (truncated || offset > 0 || partial) { + const total = partial + ? `${String(kept.length)} collected matches (partial result set)` + : String(kept.length); + lines.push(`Showing matches ${String(offset + 1)}–${String(offset + count)} of ${total}.`); + } + if (characterLimited) lines.push('Character limit reached; only complete paths are returned.'); + if (truncated) { + lines.push( + `Continue with the same search arguments and offset=${String(offset + count)}.`, + ); + if (!characterLimited) lines.push('To remove the match-count limit, omit offset and use head_limit=0.'); + } + if (filteredSensitive > 0 && (kept.length > 0 || partial)) { + footer.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`); + } + if (!truncated && !partial && offset === 0 && headLimit > 0 && count === headLimit) { + footer.push(`Found ${String(count)} matches`); + } + return { lines, footer }; + }; + const noticeChars = Math.max(...[false, true].map((characterLimited) => { + const { lines, footer } = pageNotices(candidates.length, characterLimited); + return [...lines, ...footer].join('\n').length + 2; + })); + let remaining = DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS - noticeChars; + const displayLines: string[] = []; + for (const path of candidates) { + if (path.length + 1 > remaining) break; + displayLines.push(path); + remaining -= path.length + 1; } - if (!truncated && limited.length === MAX_MATCHES) { - lines.push(`Found ${String(limited.length)} matches`); + if (candidates.length > 0 && displayLines.length === 0) { + return { + isError: true, + output: 'Glob cannot fit a complete path and its diagnostics within the output limit. Narrow the search path or pattern.', + }; } - return { output: lines.join('\n') }; + const notices = pageNotices(displayLines.length, displayLines.length < candidates.length); + return { output: [...notices.lines, ...displayLines, ...notices.footer].join('\n') }; } } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index b6d09f9eb25..4d8c49bf233 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -673,7 +673,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 18_510, + tokens_before: expect.any(Number), retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1175,7 +1175,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 18_510, + tokens_before: expect.any(Number), duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1400,7 +1400,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 18_510, + tokens_before: expect.any(Number), duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', @@ -1609,6 +1609,7 @@ describe('FullCompaction', () => { const ctx = testAgent(); ctx.configure({ provider: CATALOGUED_PROVIDER, + tools: SNAPSHOT_VISIBLE_TOOLS, modelCapabilities: { ...CATALOGUED_MODEL_CAPABILITIES, max_context_tokens: maxContextTokens, diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index d62565cb8ec..a2447aa789b 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -152,8 +152,8 @@ describe('Agent loop', () => { [wire] context.append_loop_event { "agentId": "main", "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "