Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/glob-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Allow file searches to retrieve matches beyond the first 100 results.
4 changes: 3 additions & 1 deletion docs/en/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` and omit `offset` to retrieve all collected matches in one search. Outputs exceeding the character budget 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`).

Expand Down
4 changes: 3 additions & 1 deletion docs/zh/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。

Expand Down
6 changes: 4 additions & 2 deletions packages/agent-core-v2/src/agent/tools/os/glob/glob.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` and omit `offset` to retrieve all collected matches in one search. Large outputs 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. When you need a complete list from one search, prefer `head_limit=0` and read the saved output if necessary. 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.
18 changes: 17 additions & 1 deletion packages/agent-core-v2/src/agent/tools/os/glob/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 for all collected matches; large results are saved to a file for Read. 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()
Expand All @@ -27,7 +43,7 @@ export const GlobInputSchema = z.object({

export type GlobInput = z.infer<typeof GlobInputSchema>;

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 ' +
Expand Down
49 changes: 33 additions & 16 deletions packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
type GlobInput,
GlobInputSchema,
IGlobTool,
MAX_MATCHES,
DEFAULT_HEAD_LIMIT,
WINDOWS_PATH_HINT,
} from './glob';

Expand Down Expand Up @@ -238,17 +238,11 @@ 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the complete unlimited Glob result

When head_limit=0 produces roughly 10.0–10.5 million ASCII characters, this returns the entire collected list, but the downstream truncation pipeline saves only the first 10,000,000 characters (DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS), while runRgOnce can collect up to 10 * 1024 * 1024 bytes. Consequently, the spill file advertised for retrieving all collected matches loses its tail, potentially in the middle of a path, and Read cannot recover those matches; either keep this result within the retention cap or provide a complete tool-owned spill.

Useful? React with 👍 / 👎.

const truncated = offset + limited.length < kept.length;
const partial = bufferTruncated || timedOut || traversalWarning !== undefined;

const pathClass = env.pathClass;
const shouldRelativize = isWithinDirectory(searchRoot, workspace.workspaceDir, pathClass);
Expand All @@ -270,15 +264,38 @@ export class GlobTool implements IGlobTool {
if (traversalWarning !== undefined) {
lines.push(traversalWarning);
}
if (limited.length === 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 + limited.length)} of ${total}.`);
}
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(
`Continue with the same search arguments and offset=${String(offset + limited.length)}.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Teach the TUI parser about the new Glob notices

Whenever Glob paginates, these new status lines are passed to parseGlobOutput, but the NOTICE expression in apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts:55-56 does not recognize Showing matches, Continue with, To retrieve, No more matches, or No matches collected. The TUI therefore treats them as file paths: for example, a default 100-file page is displayed as 103 files and its collapsed glance shows notice text instead of actual paths, while an exhausted page is displayed as one file. Update the parser alongside the output format.

Useful? React with 👍 / 👎.

);
lines.push('To retrieve all collected matches in one search, omit offset and use head_limit=0.');
}
lines.push(...displayLines);
if (filteredSensitive > 0) {
if (filteredSensitive > 0 && (kept.length > 0 || partial)) {
lines.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`);
}
if (!truncated && limited.length === MAX_MATCHES) {
if (!truncated && !partial && offset === 0 && headLimit > 0 && limited.length === headLimit) {
lines.push(`Found ${String(limited.length)} matches`);
}
return { output: lines.join('\n') };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ import { parseToolCallArguments } from '#/tool/tool-args-parse';
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
import { ToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncationService';
import { ReadTool } from '#/agent/tools/os/read/readTool';
import { GlobTool } from '#/agent/tools/os/glob/globTool';
import { ReadInputSchema, type ReadInput } from '#/agent/tools/os/read/read';
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { HostProcessService } from '#/os/backends/node-local/hostProcessService';
import { FakeRuntime } from '#/runtime/fakeRuntime';
import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog';
Expand All @@ -54,7 +56,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
import { IEventBus } from '#/app/event/eventBus';
import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
Expand Down Expand Up @@ -1071,8 +1073,8 @@ describe('truncation pipeline', () => {
await readConfig.ready;
const runtime = Object.assign(new FakeRuntime(
{ workspaceId: 'workspace', runtimeId: 'local', generation: 'test' },
{ capabilities: ['fs'] },
), { fs: new HostFileSystem() });
{ capabilities: ['fs', 'process'] },
), { fs: new HostFileSystem(), process: new HostProcessService() });
const binding: IAgentRuntimeService = {
_serviceBrand: undefined,
onDidChange: () => ({ dispose: () => {} }),
Expand All @@ -1087,6 +1089,7 @@ describe('truncation pipeline', () => {
truncation,
readConfig,
));
registry.register(new GlobTool(binding, stubWorkspaceContext(homeDir), noopTelemetryService));
});

afterEach(async () => {
Expand Down Expand Up @@ -1129,6 +1132,34 @@ describe('truncation pipeline', () => {
expect(readFileSync(outputPath, 'utf8')).toBe(fullOutput);
});

it('recovers every Glob match through spill and Read when the match limit is disabled', async () => {
const expected = Array.from({ length: 500 }, (_, index) =>
`file-${String(index).padStart(3, '0')}-${'x'.repeat(100)}.ts`,
);
await Promise.all(expected.map((name) => writeFile(join(homeDir, name), '')));

const [result] = await execute([toolCall('glob_all', 'Glob', { pattern: '*.ts', head_limit: 0 })]);

expect(result?.isError).not.toBe(true);
expect(result?.truncated).toBe(true);
if (typeof result?.output !== 'string') throw new Error('expected Glob text');
const path = renderedOutputPath(result.output);
let args: ReadInput | undefined = { path, max_chars: 8000 };
const recovered: string[] = [];
let pages = 0;
while (args !== undefined && pages < 20) {
const [page] = await execute([toolCall(`read_glob_${String(pages++)}`, 'Read', args)]);
expect(page?.isError).not.toBe(true);
if (typeof page?.output !== 'string') throw new Error('expected Read text');
recovered.push(...page.output.replaceAll(/^\d+\t/gm, '').split('\n').filter(Boolean));
const next = /Next Read: (\{[^\n]*\})/.exec(page.note ?? '')?.[1];
args = next === undefined ? undefined : ReadInputSchema.parse(JSON.parse(next));
}
expect(args).toBeUndefined();
expect(pages).toBeGreaterThan(1);
expect(recovered.toSorted()).toEqual(expected);
});

it('recovers MCP structured records through spill and Read without repeating the MCP call', async () => {
const structuredContent = {
rows: Array.from({ length: 1200 }, (_, index) => ({
Expand Down
Loading
Loading