Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/core/src/tools/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
*/
export const DEFAULT_TOTAL_MAX_MATCHES = 100;
export const DEFAULT_SEARCH_TIMEOUT_MS = 30000;
export const DEFAULT_GLOB_MAX_RESULTS = 500;
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
"description": "Optional: The absolute path to the directory to search within. If omitted, searches the root directory.",
"type": "string",
},
"max_results": {
"description": "Optional: Maximum number of file paths to return. Use this to limit the size of the response for broad patterns in large repositories. Defaults to 500 if omitted.",
"minimum": 1,
"type": "integer",
},
"pattern": {
"description": "The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
"type": "string",
Expand Down Expand Up @@ -1001,6 +1006,11 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
"description": "Optional: The absolute path to the directory to search within. If omitted, searches the root directory.",
"type": "string",
},
"max_results": {
"description": "Optional: Maximum number of file paths to return. Use this to limit the size of the response for broad patterns in large repositories. Defaults to 500 if omitted.",
"minimum": 1,
"type": "integer",
},
"pattern": {
"description": "The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
"type": "string",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tools/definitions/base-declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const PARAM_DESCRIPTION = 'description';

// -- glob --
export const GLOB_TOOL_NAME = 'glob';
export const GLOB_PARAM_MAX_RESULTS = 'max_results';

// -- grep_search --
export const GREP_TOOL_NAME = 'grep_search';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
PARAM_RESPECT_GIT_IGNORE,
PARAM_RESPECT_GEMINI_IGNORE,
PARAM_FILE_FILTERING_OPTIONS,
GLOB_PARAM_MAX_RESULTS,
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
Expand Down Expand Up @@ -287,6 +288,12 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
'Optional: Whether to respect .geminiignore patterns when finding files. Defaults to true.',
type: 'boolean',
},
[GLOB_PARAM_MAX_RESULTS]: {
description:
'Optional: Maximum number of file paths to return. Use this to limit the size of the response for broad patterns in large repositories. Defaults to 500 if omitted.',
type: 'integer',
minimum: 1,
},
},
required: [PARAM_PATTERN],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
PARAM_RESPECT_GIT_IGNORE,
PARAM_RESPECT_GEMINI_IGNORE,
PARAM_FILE_FILTERING_OPTIONS,
GLOB_PARAM_MAX_RESULTS,
// Tool-specific parameter names
READ_FILE_PARAM_START_LINE,
READ_FILE_PARAM_END_LINE,
Expand Down Expand Up @@ -293,6 +294,12 @@ export const GEMINI_3_SET: CoreToolSet = {
'Optional: Whether to respect .geminiignore patterns when finding files. Defaults to true.',
type: 'boolean',
},
[GLOB_PARAM_MAX_RESULTS]: {
description:
'Optional: Maximum number of file paths to return. Use this to limit the size of the response for broad patterns in large repositories. Defaults to 500 if omitted.',
type: 'integer',
minimum: 1,
},
},
required: [PARAM_PATTERN],
},
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/tools/glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,42 @@ describe('GlobTool', () => {
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.GLOB_EXECUTION_ERROR);
}, 30000);

it('should truncate results when exceeding max_results', async () => {
// Create enough files to trigger truncation
for (let i = 0; i < 15; i++) {
await fs.writeFile(
path.join(tempRootDir, `trunc_${i}.trunc`),
`content${i}`,
);
}
const params: GlobToolParams = { pattern: '*.trunc', max_results: 5 };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 5 file(s)');
expect(result.llmContent).toContain('Results truncated');
expect(result.llmContent).toContain('showing 5 of 15 total matches');
expect(result.llmContent).toContain('max_results');
expect(result.returnDisplay).toContain('(truncated from 15)');
}, 30000);

it('should not truncate when results are under max_results', async () => {
const params: GlobToolParams = { pattern: '*.txt', max_results: 500 };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 2 file(s)');
expect(result.llmContent).not.toContain('truncated');
expect(result.returnDisplay).toBe('Found 2 matching file(s)');
}, 30000);

it('should use DEFAULT_GLOB_MAX_RESULTS when max_results not provided', async () => {
// Create 3 files and verify no truncation at default limit
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).not.toContain('truncated');
expect(result.returnDisplay).toBe('Found 2 matching file(s)');
}, 30000);
});

describe('validateToolParams', () => {
Expand Down Expand Up @@ -333,6 +369,32 @@ describe('GlobTool', () => {
'Search path is not a directory',
);
});

it('should return error if max_results is 0', () => {
const params: GlobToolParams = { pattern: '*.txt', max_results: 0 };
expect(globTool.validateToolParams(params)).toContain(
'params/max_results must be >= 1',
);
});

it('should return error if max_results is negative', () => {
const params: GlobToolParams = { pattern: '*.txt', max_results: -1 };
expect(globTool.validateToolParams(params)).toContain(
'params/max_results must be >= 1',
);
});

it('should return error if max_results is not an integer', () => {
const params: GlobToolParams = { pattern: '*.txt', max_results: 1.5 };
expect(globTool.validateToolParams(params)).toContain(
'params/max_results must be integer',
);
});

it('should return null for valid max_results', () => {
const params: GlobToolParams = { pattern: '*.txt', max_results: 100 };
expect(globTool.validateToolParams(params)).toBeNull();
});
});

describe('workspace boundary validation', () => {
Expand Down
27 changes: 25 additions & 2 deletions packages/core/src/tools/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getErrorMessage } from '../utils/errors.js';
import { debugLogger } from '../utils/debugLogger.js';
import { GLOB_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { DEFAULT_GLOB_MAX_RESULTS } from './constants.js';

// Subset of 'Path' interface provided by 'glob' that we can implement for testing
export interface GlobPath {
Expand Down Expand Up @@ -92,6 +93,11 @@ export interface GlobToolParams {
* Whether to respect .geminiignore patterns (optional, defaults to true)
*/
respect_gemini_ignore?: boolean;

/**
* Optional: Maximum number of file paths to return. Defaults to 500.
*/
max_results?: number;
}

class GlobToolInvocation extends BaseToolInvocation<
Expand Down Expand Up @@ -239,7 +245,14 @@ class GlobToolInvocation extends BaseToolInvocation<
oneDayInMs,
);

const sortedAbsolutePaths = sortedEntries.map((entry) =>
const maxResults = this.params.max_results ?? DEFAULT_GLOB_MAX_RESULTS;
const totalMatchCount = sortedEntries.length;
const wasTruncated = totalMatchCount > maxResults;
const limitedEntries = wasTruncated
? sortedEntries.slice(0, maxResults)
: sortedEntries;

const sortedAbsolutePaths = limitedEntries.map((entry) =>
entry.fullpath(),
);
const fileListDescription = sortedAbsolutePaths.join('\n');
Expand All @@ -254,11 +267,14 @@ class GlobToolInvocation extends BaseToolInvocation<
if (ignoredCount > 0) {
resultMessage += ` (${ignoredCount} additional files were ignored)`;
}
if (wasTruncated) {
resultMessage += `\n⚠️ Results truncated: showing ${fileCount} of ${totalMatchCount} total matches. Use a more specific pattern or set max_results to see more.`;
}
resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`;

return {
llmContent: resultMessage,
returnDisplay: `Found ${fileCount} matching file(s)`,
returnDisplay: `Found ${fileCount} matching file(s)${wasTruncated ? ` (truncated from ${totalMatchCount})` : ''}`,
};
} catch (error) {
debugLogger.warn(`GlobLogic execute Error`, error);
Expand Down Expand Up @@ -336,6 +352,13 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
return "The 'pattern' parameter cannot be empty.";
}

if (
params.max_results !== undefined &&
(params.max_results < 1 || !Number.isInteger(params.max_results))
) {
return 'max_results must be a positive integer.';
}

return null;
}

Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/tools/grep-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { formatGrepResults, type GrepMatch } from './grep-utils.js';

describe('formatGrepResults truncation messaging', () => {
it('should include actionable truncation warning when results are truncated', async () => {
const matches: GrepMatch[] = Array.from({ length: 100 }, (_, i) => ({
filePath: `file${i}.ts`,
absolutePath: `/workspace/file${i}.ts`,
lineNumber: i + 1,
line: `match line ${i}`,
}));

const result = await formatGrepResults(
matches,
{ pattern: 'test' },
'in the workspace directory',
100, // totalMaxMatches = exactly matchCount, triggers wasTruncated (>=)
);

expect(result.llmContent).toContain('RESULTS TRUNCATED');
expect(result.llmContent).toContain('total_max_matches');
expect(result.returnDisplay).toContain('truncated at 100');
});

it('should NOT include truncation warning when results are under limit', async () => {
const matches: GrepMatch[] = Array.from({ length: 5 }, (_, i) => ({
filePath: `file${i}.ts`,
absolutePath: `/workspace/file${i}.ts`,
lineNumber: i + 1,
line: `match line ${i}`,
}));

const result = await formatGrepResults(
matches,
{ pattern: 'test' },
'in the workspace directory',
100,
);

expect(result.llmContent).not.toContain('TRUNCATED');
expect(result.returnDisplay).not.toContain('truncated');
});

it('should include truncation warning in names_only mode', async () => {
const matches: GrepMatch[] = Array.from({ length: 50 }, (_, i) => ({
filePath: `file${i}.ts`,
absolutePath: `/workspace/file${i}.ts`,
lineNumber: 1,
line: `match`,
}));

const result = await formatGrepResults(
matches,
{ pattern: 'test', names_only: true },
'in the workspace directory',
50, // triggers wasTruncated (>=)
);

expect(result.llmContent).toContain('RESULTS TRUNCATED');
expect(result.returnDisplay).toContain('truncated at 50 matches');
});

it('should NOT include truncation warning in names_only mode when under limit', async () => {
const matches: GrepMatch[] = Array.from({ length: 5 }, (_, i) => ({
filePath: `file${i}.ts`,
absolutePath: `/workspace/file${i}.ts`,
lineNumber: 1,
line: `match`,
}));

const result = await formatGrepResults(
matches,
{ pattern: 'test', names_only: true },
'in the workspace directory',
100,
);

expect(result.llmContent).not.toContain('TRUNCATED');
expect(result.returnDisplay).not.toContain('truncated');
});
});
8 changes: 4 additions & 4 deletions packages/core/src/tools/grep-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,20 @@ export async function formatGrepResults(
include_pattern ? ` (filter: "${include_pattern}")` : ''
}${
wasTruncated
? ` (results limited to ${totalMaxMatches} matches for performance)`
? `\n⚠️ RESULTS TRUNCATED: Showing files from the first ${totalMaxMatches} matches only. More matching files may exist. Use a more specific pattern or increase total_max_matches.`
: ''
}:\n`;
llmContent += filePaths.join('\n');
return {
llmContent: llmContent.trim(),
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ` (truncated at ${totalMaxMatches} matches)` : ''}`,
};
}

let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${pattern}" ${searchLocationDescription}${include_pattern ? ` (filter: "${include_pattern}")` : ''}`;

if (wasTruncated) {
llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`;
llmContent += `\n⚠️ RESULTS TRUNCATED: Returned ${totalMaxMatches} matches but more exist. The results are INCOMPLETE. To see all matches, either:\n- Use a more specific pattern or include_pattern to narrow the search\n- Set total_max_matches to a higher value (current: ${totalMaxMatches})`;
}

llmContent += `:\n---\n`;
Expand All @@ -214,7 +214,7 @@ export async function formatGrepResults(
return {
llmContent: llmContent.trim(),
returnDisplay: `Found ${matchCount} ${matchTerm}${
wasTruncated ? ' (limited)' : ''
wasTruncated ? ` (truncated at ${totalMaxMatches}, more exist)` : ''
}`,
};
}
8 changes: 4 additions & 4 deletions packages/core/src/tools/grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,16 +471,16 @@ describe('GrepTool', () => {
const result = await invocation.execute(abortSignal);

expect(result.llmContent).toContain('Found 2 matches');
expect(result.llmContent).toContain(
'results limited to 2 matches for performance',
);
expect(result.llmContent).toContain('RESULTS TRUNCATED');
// It should find matches in fileA.txt first (2 matches)
expect(result.llmContent).toContain('File: fileA.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('L2: second line with world');
// And sub/fileC.txt should be excluded because limit reached
expect(result.llmContent).not.toContain('File: sub/fileC.txt');
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
expect(result.returnDisplay).toBe(
'Found 2 matches (truncated at 2, more exist)',
);
});

it('should respect max_matches_per_file in JS fallback', async () => {
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/tools/ripGrep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@ describe('RipGrepTool', () => {
});
const result = await invocation.execute(abortSignal);

expect(result.returnDisplay).toContain('(limited)');
expect(result.returnDisplay).toContain('truncated at');
}, 10000);

it('should filter out files based on FileDiscoveryService even if ripgrep returns them', async () => {
Expand Down Expand Up @@ -1940,13 +1940,13 @@ describe('RipGrepTool', () => {
const result = await invocation.execute(abortSignal);

expect(result.llmContent).toContain('Found 2 matches');
expect(result.llmContent).toContain(
'results limited to 2 matches for performance',
);
expect(result.llmContent).toContain('RESULTS TRUNCATED');
expect(result.llmContent).toContain('L1: match 1');
expect(result.llmContent).toContain('L2: match 2');
expect(result.llmContent).not.toContain('L3: match 3');
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
expect(result.returnDisplay).toBe(
'Found 2 matches (truncated at 2, more exist)',
);
});

it('should return only file paths when names_only is true', async () => {
Expand Down