Skip to content

Commit f12fdab

Browse files
committed
feat: page through Glob matches beyond the first hundred
Glob now takes offset and head_limit, defaults to 100 matches, and accepts head_limit=0 to lift the match-count limit. Every page stays within the output character limit, ends on a complete path, reports the range it covers, and gives the next offset when more matches remain. The tool card counts only paths, and marks a page that is not the last.
1 parent 2d4d27a commit f12fdab

10 files changed

Lines changed: 275 additions & 57 deletions

File tree

apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,26 @@ const grepChip: ChipProvider = (_toolCall, result) => {
9393
return pluralize(matches, 'match', 'matches');
9494
};
9595

96+
const GLOB_PAGE_HEADER =
97+
/^Showing matches (\d+)\u2013(\d+) of (\d+)( collected matches \(partial result set\))?\.$/;
98+
const GLOB_NOTICE =
99+
/^(?:Continue with the same search arguments and offset=\d+\.|To remove the match-count limit, omit offset and use head_limit=0\.|Character limit reached; only complete paths are returned\.|No more matches at offset=\d+ in the (?:current|collected partial) result set \(\d+ matches\)\.|No matches collected; search incomplete\.)$/;
100+
96101
const globChip: ChipProvider = (_toolCall, result) => {
97-
const files = countNonEmptyLines(result.output);
102+
let partial = false;
103+
let files = 0;
104+
for (const line of result.output.split('\n')) {
105+
if (line.trim().length === 0) continue;
106+
const page = GLOB_PAGE_HEADER.exec(line);
107+
if (page !== null) {
108+
partial = Number(page[2]) < Number(page[3]) || page[4] !== undefined;
109+
continue;
110+
}
111+
if (GLOB_NOTICE.test(line)) continue;
112+
files++;
113+
}
98114
if (files === 0) return 'no files';
99-
return pluralize(files, 'file');
115+
return `${String(files)}${partial ? '+' : ''} ${files === 1 ? 'file' : 'files'}`;
100116
};
101117

102118
const fetchChip: ChipProvider = (_toolCall, result) =>

apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,41 @@ describe('chip registry', () => {
6565
expect(chipFor('Glob', { pattern: '**/*.ts' }, result('a.ts\nb.ts'))).toBe('2 files');
6666
});
6767

68+
it('counts only paths on a Glob page with a continuation notice', () => {
69+
const output = [
70+
'Showing matches 1\u2013100 of 347.',
71+
'Continue with the same search arguments and offset=100.',
72+
'To remove the match-count limit, omit offset and use head_limit=0.',
73+
...Array.from({ length: 100 }, (_, i) => `file-${String(i)}.ts`),
74+
].join('\n');
75+
expect(chipFor('Glob', {}, result(output))).toBe('100+ files');
76+
});
77+
78+
it.each([
79+
'No more matches at offset=347 in the current result set (347 matches).',
80+
'No matches collected; search incomplete.',
81+
])('does not count an empty Glob page as a file: %s', (output) => {
82+
expect(chipFor('Glob', {}, result(output))).toBe('no files');
83+
});
84+
85+
it('distinguishes the last Glob page from a partial result set', () => {
86+
expect(chipFor('Glob', {}, result('Showing matches 3\u20134 of 4.\nc.ts\nd.ts'))).toBe('2 files');
87+
expect(
88+
chipFor(
89+
'Glob',
90+
{},
91+
result('Showing matches 3\u20134 of 4 collected matches (partial result set).\nc.ts\nd.ts'),
92+
),
93+
).toBe('2+ files');
94+
});
95+
96+
it('keeps notice-like file names and leaves Grep interpretation unchanged', () => {
97+
expect(
98+
chipFor('Glob', {}, result('Showing matches.ts\nContinue with.txt\nNo more matches.ts')),
99+
).toBe('3 files');
100+
expect(chipFor('Grep', {}, result('Showing matches 1\u20132 of 3.'))).toBe('1 match');
101+
});
102+
68103
it('FetchURL chip shows size and is non-empty', () => {
69104
const out = chipFor('FetchURL', { url: 'https://example.com' }, result('hello world'));
70105
expect(out).toMatch(/\d+\s*B/);

docs/reference/tools.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ File tools handle reading, writing, and searching the local filesystem — the f
2525

2626
**`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.
2727

28-
**`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.
28+
**`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.
29+
30+
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.
2931

3032
**`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`).
3133

packages/agent-core-v2/src/agent/tools/os/glob/glob.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ Good patterns:
1010
- `*.{ts,tsx}` — brace expansion is supported
1111
- `{src,test}/**/*.ts` — cartesian brace expansion is supported too
1212

13-
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.
13+
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.
14+
15+
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.
1416

1517
Large-directory caveat — avoid recursing into dependency / build output even with an anchor, especially when `include_ignored` is set:
16-
- `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`.
18+
- `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.

packages/agent-core-v2/src/agent/tools/os/glob/glob.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ import { type AgentTool } from '#/tool/toolContract';
55

66
export const GlobInputSchema = z.object({
77
pattern: z.string().describe('Glob pattern to match files.'),
8+
head_limit: z
9+
.number()
10+
.int()
11+
.nonnegative()
12+
.optional()
13+
.describe(
14+
'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.',
15+
),
16+
offset: z
17+
.number()
18+
.int()
19+
.nonnegative()
20+
.optional()
21+
.describe(
22+
'Number of matching paths to skip. Defaults to 0. Each call searches the current filesystem again; changes can shift results between pages.',
23+
),
824
path: z
925
.string()
1026
.optional()
@@ -27,7 +43,7 @@ export const GlobInputSchema = z.object({
2743

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

30-
export const MAX_MATCHES = 100;
46+
export const DEFAULT_HEAD_LIMIT = 100;
3147

3248
export const WINDOWS_PATH_HINT =
3349
'\n\nWindows note: the `path` argument accepts both Windows paths ' +

packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog';
1717
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
1818
import { ITelemetryService } from '#/app/telemetry/telemetry';
1919
import {
20+
DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS,
2021
ToolAccesses,
2122
type ExecutableToolResult,
2223
type ToolExecution,
@@ -37,7 +38,7 @@ import {
3738
type GlobInput,
3839
GlobInputSchema,
3940
IGlobTool,
40-
MAX_MATCHES,
41+
DEFAULT_HEAD_LIMIT,
4142
WINDOWS_PATH_HINT,
4243
} from './glob';
4344

@@ -238,50 +239,90 @@ export class GlobTool implements IGlobTool {
238239
}
239240
}
240241

241-
const truncated = kept.length > MAX_MATCHES;
242-
const limited = truncated ? kept.slice(0, MAX_MATCHES) : kept;
243-
244-
if (limited.length === 0 && !timedOut) {
245-
if (filteredSensitive > 0) {
246-
return {
247-
output: `No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`,
248-
};
249-
}
250-
return { output: 'No matches found' };
251-
}
242+
const offset = args.offset ?? 0;
243+
const headLimit = args.head_limit ?? DEFAULT_HEAD_LIMIT;
244+
const limited = headLimit === 0 ? kept.slice(offset) : kept.slice(offset, offset + headLimit);
245+
const partial = bufferTruncated || timedOut || traversalWarning !== undefined;
252246

253247
const pathClass = env.pathClass;
254248
const shouldRelativize = isWithinDirectory(searchRoot, workspace.workspaceDir, pathClass);
255-
const displayLines = limited.map((p) =>
249+
const candidates = limited.map((p) =>
256250
shouldRelativize ? relativizeIfUnder(p, searchRoot, pathClass) : p,
257251
);
258252

259-
const lines: string[] = [];
253+
const warnings: string[] = [];
260254
if (timedOut) {
261-
lines.push(
255+
warnings.push(
262256
`Glob timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned.`,
263257
);
264258
}
265259
if (bufferTruncated) {
266-
lines.push(
260+
warnings.push(
267261
`[stdout truncated at ${String(MAX_OUTPUT_BYTES)} bytes; results may be incomplete — use a more specific pattern]`,
268262
);
269263
}
270264
if (traversalWarning !== undefined) {
271-
lines.push(traversalWarning);
265+
warnings.push(traversalWarning);
272266
}
273-
if (truncated) {
274-
lines.push(`[Truncated at ${String(MAX_MATCHES)} matches — use a more specific pattern]`);
275-
lines.push(`Only the first ${String(MAX_MATCHES)} matches are returned.`);
276-
}
277-
lines.push(...displayLines);
278-
if (filteredSensitive > 0) {
279-
lines.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`);
267+
const pageNotices = (count: number, characterLimited: boolean) => {
268+
const lines = [...warnings];
269+
const footer: string[] = [];
270+
const truncated = characterLimited || offset + count < kept.length;
271+
if (count === 0) {
272+
if (kept.length > 0) {
273+
const resultSet = partial ? 'collected partial result set' : 'current result set';
274+
lines.push(
275+
`No more matches at offset=${String(offset)} in the ${resultSet} (${String(kept.length)} matches).`,
276+
);
277+
} else if (partial) {
278+
lines.push('No matches collected; search incomplete.');
279+
} else if (filteredSensitive > 0) {
280+
lines.push(
281+
`No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`,
282+
);
283+
} else {
284+
lines.push('No matches found');
285+
}
286+
} else if (truncated || offset > 0 || partial) {
287+
const total = partial
288+
? `${String(kept.length)} collected matches (partial result set)`
289+
: String(kept.length);
290+
lines.push(`Showing matches ${String(offset + 1)}${String(offset + count)} of ${total}.`);
291+
}
292+
if (characterLimited) lines.push('Character limit reached; only complete paths are returned.');
293+
if (truncated) {
294+
lines.push(
295+
`Continue with the same search arguments and offset=${String(offset + count)}.`,
296+
);
297+
if (!characterLimited) lines.push('To remove the match-count limit, omit offset and use head_limit=0.');
298+
}
299+
if (filteredSensitive > 0 && (kept.length > 0 || partial)) {
300+
footer.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`);
301+
}
302+
if (!truncated && !partial && offset === 0 && headLimit > 0 && count === headLimit) {
303+
footer.push(`Found ${String(count)} matches`);
304+
}
305+
return { lines, footer };
306+
};
307+
const noticeChars = Math.max(...[false, true].map((characterLimited) => {
308+
const { lines, footer } = pageNotices(candidates.length, characterLimited);
309+
return [...lines, ...footer].join('\n').length + 2;
310+
}));
311+
let remaining = DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS - noticeChars;
312+
const displayLines: string[] = [];
313+
for (const path of candidates) {
314+
if (path.length + 1 > remaining) break;
315+
displayLines.push(path);
316+
remaining -= path.length + 1;
280317
}
281-
if (!truncated && limited.length === MAX_MATCHES) {
282-
lines.push(`Found ${String(limited.length)} matches`);
318+
if (candidates.length > 0 && displayLines.length === 0) {
319+
return {
320+
isError: true,
321+
output: 'Glob cannot fit a complete path and its diagnostics within the output limit. Narrow the search path or pattern.',
322+
};
283323
}
284-
return { output: lines.join('\n') };
324+
const notices = pageNotices(displayLines.length, displayLines.length < candidates.length);
325+
return { output: [...notices.lines, ...displayLines, ...notices.footer].join('\n') };
285326
}
286327
}
287328

packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ describe('FullCompaction', () => {
658658
event: 'compaction_finished',
659659
properties: expect.objectContaining({
660660
source: 'manual',
661-
tokens_before: 18_193,
661+
tokens_before: expect.any(Number),
662662
retry_count: 1,
663663
trace_id: 'trace-compact-1',
664664
}),
@@ -1125,7 +1125,7 @@ describe('FullCompaction', () => {
11251125
properties: expect.objectContaining({
11261126
agent_id: 'main',
11271127
source: 'manual',
1128-
tokens_before: 18_193,
1128+
tokens_before: expect.any(Number),
11291129
duration_ms: expect.any(Number),
11301130
round: 1,
11311131
retry_count: 0,
@@ -1350,7 +1350,7 @@ describe('FullCompaction', () => {
13501350
event: 'compaction_failed',
13511351
properties: expect.objectContaining({
13521352
source: 'manual',
1353-
tokens_before: 18_193,
1353+
tokens_before: expect.any(Number),
13541354
duration_ms: expect.any(Number),
13551355
retry_count: 4,
13561356
error_type: 'APIConnectionError',
@@ -1551,6 +1551,7 @@ describe('FullCompaction', () => {
15511551
const ctx = testAgent();
15521552
ctx.configure({
15531553
provider: CATALOGUED_PROVIDER,
1554+
tools: SNAPSHOT_VISIBLE_TOOLS,
15541555
modelCapabilities: {
15551556
...CATALOGUED_MODEL_CAPABILITIES,
15561557
max_context_tokens: maxContextTokens,

packages/agent-core-v2/test/agent/loop/loop.test.ts

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)