Unverified 提交 3deb70b1 authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

fix: limit grep results to prevent context_length_exceeded errors (#2510)

## Summary - Add a configurable `limit` parameter to the grep tool (default: 100, max: 500) to prevent context window overflow - Truncate individual line text to 500 characters to handle very long lines - Add clear truncation notice telling the AI to narrow its search when results are truncated - Add `total` and `truncated` attributes to XML output for visibility ## Test plan - [x] Build passes (`npm run build`) - [x] Lint passes (`npm run lint`) - [x] All 669 unit tests pass (`npm test`) - Manual testing: grep with a broad query like "import" should now return limited results with a truncation notice instead of overflowing the context window Fixes #2509 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2510" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Limits grep results and line lengths to prevent context window overflows and context_length_exceeded errors. Addresses Linear #2509 with a configurable cap, consistent sorting, clearer UI output, and ignoring include_pattern "*" when it matches all files. - **Bug Fixes** - Added limit parameter (default 100, max 250) to cap matches. - Truncated each matched line to 500 chars. - Sorted results by path and line number, and ignored include_pattern "*" with a note to avoid broad searches. - Added truncation notice and "X of Y matches" in UI. - Exposed total and truncated flags in XML output for visibility. <sup>Written for commit ad5979b9352ffc754019e13ead9c6be2e7b24ce9. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com> Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
上级 aa71f805
......@@ -96,7 +96,7 @@
"type": "function",
"function": {
"name": "grep",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -116,6 +116,12 @@
"case_sensitive": {
"description": "Whether the search should be case sensitive (default: false)",
"type": "boolean"
},
"limit": {
"description": "Maximum number of matches to return (default: 100, max: 250). Use include_pattern to narrow results if limit is reached.",
"type": "number",
"minimum": 1,
"maximum": 250
}
},
"required": [
......
......@@ -232,7 +232,7 @@
{
"type": "function",
"name": "grep",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -252,6 +252,12 @@
"case_sensitive": {
"description": "Whether the search should be case sensitive (default: false)",
"type": "boolean"
},
"limit": {
"description": "Maximum number of matches to return (default: 100, max: 250). Use include_pattern to narrow results if limit is reached.",
"type": "number",
"minimum": 1,
"maximum": 250
}
},
"required": [
......
......@@ -231,7 +231,7 @@
"type": "function",
"function": {
"name": "grep",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')",
"description": "Search for a regex pattern in the codebase using ripgrep.\n\n- Returns matching lines with file paths and line numbers\n- By default, the search is case-insensitive\n- Use include_pattern to filter by file type (e.g. '*.tsx')\n- Use exclude_pattern to skip certain files (e.g. '*.test.ts')\n- Results are limited to 100 matches by default (max 250). If results are truncated, narrow your search with include_pattern or a more specific query.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
......@@ -251,6 +251,12 @@
"case_sensitive": {
"description": "Whether the search should be case sensitive (default: false)",
"type": "boolean"
},
"limit": {
"description": "Maximum number of matches to return (default: 100, max: 250). Use include_pattern to narrow results if limit is reached.",
"type": "number",
"minimum": 1,
"maximum": 250
}
},
"required": [
......
......@@ -34,7 +34,7 @@
- button "Copy":
- img
- text: log
- code: "src/main.tsx:2: import App from \"./App.tsx\"; src/main.tsx:4: createRoot(document.getElementById(\"root\")!).render(<App />); src/App.tsx:1: const App = () => <div>Minimal imported app</div>; src/App.tsx:3: export default App;"
- code: "src/App.tsx:1: const App = () => <div>Minimal imported app</div>; src/App.tsx:3: export default App; src/main.tsx:2: import App from \"./App.tsx\"; src/main.tsx:4: createRoot(document.getElementById(\"root\")!).render(<App />);"
- paragraph: I found the matches! The React app is initialized in src/main.tsx using createRoot, and the App component is defined in src/App.tsx and imported in src/main.tsx.
- button "Copy":
- img
......
......@@ -5,4 +5,4 @@
- button "Copy":
- img
- text: log
- code: "src/main.tsx:2: import App from \"./App.tsx\"; src/main.tsx:4: createRoot(document.getElementById(\"root\")!).render(<App />); src/App.tsx:1: const App = () => <div>Minimal imported app</div>; src/App.tsx:3: export default App;"
\ No newline at end of file
- code: "src/App.tsx:1: const App = () => <div>Minimal imported app</div>; src/App.tsx:3: export default App; src/main.tsx:2: import App from \"./App.tsx\"; src/main.tsx:4: createRoot(document.getElementById(\"root\")!).render(<App />);"
\ No newline at end of file
......@@ -21,6 +21,8 @@ interface DyadGrepProps {
exclude?: string;
"case-sensitive"?: string;
count?: string;
total?: string;
truncated?: string;
};
};
}
......@@ -39,6 +41,8 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
const excludePattern = node?.properties?.exclude || "";
const caseSensitive = node?.properties?.["case-sensitive"] === "true";
const count = node?.properties?.count || "";
const total = node?.properties?.total || "";
const truncated = node?.properties?.truncated === "true";
const hasResults = count !== "" && count !== "0";
// Build description
......@@ -55,7 +59,9 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
// Build result summary
const resultSummary = count
? `${count} match${count === "1" ? "" : "es"}`
? truncated && total
? `${count} of ${total} matches`
: `${count} match${count === "1" ? "" : "es"}`
: "";
// Dynamic border styling
......
......@@ -523,6 +523,8 @@ function renderCustomTag(
exclude: attributes.exclude || "",
"case-sensitive": attributes["case-sensitive"] || "",
count: attributes.count || "",
total: attributes.total || "",
truncated: attributes.truncated || "",
},
}}
>
......
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { grepTool } from "./grep";
import type { AgentContext } from "./types";
// Mock electron-log
vi.mock("electron-log", () => ({
default: {
scope: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
},
}));
// Mock only the ripgrep path resolver to point to the real binary in node_modules
vi.mock("@/ipc/utils/ripgrep_utils", () => ({
getRgExecutablePath: () => {
const isWindows = os.platform() === "win32";
const executableName = isWindows ? "rg.exe" : "rg";
// Point to the actual ripgrep binary in node_modules
return path.join(
process.cwd(),
"node_modules",
"@vscode",
"ripgrep",
"bin",
executableName,
);
},
MAX_FILE_SEARCH_SIZE: 1024 * 1024,
RIPGREP_EXCLUDED_GLOBS: ["!node_modules/**", "!.git/**", "!.next/**"],
}));
describe("grepTool", () => {
let testDir: string;
let mockContext: AgentContext;
beforeEach(async () => {
// Create a temp directory with test files
testDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "grep-test-"));
// Create test files
await fs.promises.writeFile(
path.join(testDir, "test1.ts"),
`function hello() {
console.log("hello world");
return true;
}
function goodbye() {
console.log("goodbye world");
return false;
}`,
);
await fs.promises.writeFile(
path.join(testDir, "test2.ts"),
`const HELLO = "greeting";
const GOODBYE = "farewell";
export function greet(name: string) {
return \`Hello, \${name}!\`;
}`,
);
await fs.promises.writeFile(
path.join(testDir, "readme.md"),
`# Hello Project
This is a hello world example.
Say goodbye when you leave.`,
);
await fs.promises.mkdir(path.join(testDir, "nested"));
await fs.promises.writeFile(
path.join(testDir, "nested", "deep.ts"),
`// Deep nested file
function deepHello() {
return "hello from the deep";
}`,
);
mockContext = {
event: {} as any,
appId: 1,
appPath: testDir,
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
messageId: 1,
isSharedModulesChanged: false,
isDyadPro: false,
todos: [],
dyadRequestId: "test-request",
fileEditTracker: {},
onXmlStream: vi.fn(),
onXmlComplete: vi.fn(),
requireConsent: vi.fn().mockResolvedValue(true),
appendUserMessage: vi.fn(),
onUpdateTodos: vi.fn(),
};
});
afterEach(async () => {
// Clean up temp directory
await fs.promises.rm(testDir, { recursive: true, force: true });
vi.clearAllMocks();
});
describe("schema validation", () => {
it("has the correct name", () => {
expect(grepTool.name).toBe("grep");
});
it("has defaultConsent set to always", () => {
expect(grepTool.defaultConsent).toBe("always");
});
it("validates required query field", () => {
const schema = grepTool.inputSchema;
// Missing query
expect(() => schema.parse({})).toThrow();
// With query
expect(() => schema.parse({ query: "hello" })).not.toThrow();
});
it("validates limit bounds", () => {
const schema = grepTool.inputSchema;
// Limit too low
expect(() => schema.parse({ query: "test", limit: 0 })).toThrow();
// Limit too high
expect(() => schema.parse({ query: "test", limit: 251 })).toThrow();
// Valid limits
expect(() => schema.parse({ query: "test", limit: 1 })).not.toThrow();
expect(() => schema.parse({ query: "test", limit: 250 })).not.toThrow();
});
});
describe("execute - basic search", () => {
it("finds matches across multiple files", async () => {
const result = await grepTool.execute({ query: "hello" }, mockContext);
expect(result).toContain("test1.ts");
expect(result).toContain("test2.ts");
expect(result).toContain("readme.md");
expect(result).toContain("nested/deep.ts");
});
it("returns line numbers", async () => {
const result = await grepTool.execute({ query: "goodbye" }, mockContext);
// Should contain file:line: format
expect(result).toMatch(/test1\.ts:\d+:/);
expect(result).toMatch(/test2\.ts:\d+:/);
});
it("returns no matches found when nothing matches", async () => {
const result = await grepTool.execute(
{ query: "nonexistent_pattern_xyz" },
mockContext,
);
expect(result).toBe("No matches found.");
});
it("calls onXmlComplete with proper XML for no matches", async () => {
await grepTool.execute({ query: "nonexistent_pattern_xyz" }, mockContext);
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining("No matches found."),
);
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining("query="),
);
});
it("calls onXmlComplete with proper XML for matches", async () => {
await grepTool.execute({ query: "hello" }, mockContext);
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining("<dyad-grep"),
);
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining("</dyad-grep>"),
);
});
});
describe("execute - case sensitivity", () => {
it("is case-insensitive by default", async () => {
const result = await grepTool.execute({ query: "HELLO" }, mockContext);
// Should find lowercase "hello" too
expect(result).toContain("test1.ts");
expect(result).toContain("hello world");
});
it("respects case_sensitive option", async () => {
const result = await grepTool.execute(
{ query: "HELLO", case_sensitive: true },
mockContext,
);
// Should only find "HELLO" (uppercase constant in test2.ts)
expect(result).toContain("test2.ts");
expect(result).not.toContain("hello world");
});
});
describe("execute - file filtering", () => {
it("filters by include_pattern", async () => {
const result = await grepTool.execute(
{ query: "hello", include_pattern: "*.md" },
mockContext,
);
expect(result).toContain("readme.md");
expect(result).not.toContain("test1.ts");
expect(result).not.toContain("test2.ts");
});
it("filters by exclude_pattern", async () => {
const result = await grepTool.execute(
{ query: "hello", exclude_pattern: "*.md" },
mockContext,
);
expect(result).toContain("test1.ts");
expect(result).not.toContain("readme.md");
});
it("supports glob patterns for nested files", async () => {
const result = await grepTool.execute(
{ query: "hello", include_pattern: "nested/**" },
mockContext,
);
expect(result).toContain("nested/deep.ts");
expect(result).not.toContain("test1.ts");
});
it("does not search node_modules even with include_pattern '*'", async () => {
// Create a node_modules directory with a matching file
const nodeModulesDir = path.join(testDir, "node_modules", "some-pkg");
await fs.promises.mkdir(nodeModulesDir, { recursive: true });
await fs.promises.writeFile(
path.join(nodeModulesDir, "index.js"),
`function hello() { return "hello from node_modules"; }`,
);
const result = await grepTool.execute(
{ query: "hello", include_pattern: "*" },
mockContext,
);
// Should find matches in project files but NOT in node_modules
expect(result).toContain("test1.ts");
expect(result).not.toContain("node_modules");
// Should warn the LLM that "*" was ignored
expect(result).toContain(
'include_pattern="*" was ignored because it matches all files',
);
});
it("does not search node_modules without include_pattern", async () => {
// Create a node_modules directory with a matching file
const nodeModulesDir = path.join(testDir, "node_modules", "some-pkg");
await fs.promises.mkdir(nodeModulesDir, { recursive: true });
await fs.promises.writeFile(
path.join(nodeModulesDir, "index.js"),
`function hello() { return "hello from node_modules"; }`,
);
const result = await grepTool.execute({ query: "hello" }, mockContext);
// Should find matches in project files but NOT in node_modules
expect(result).toContain("test1.ts");
expect(result).not.toContain("node_modules");
});
});
describe("execute - regex patterns", () => {
it("supports basic regex", async () => {
const result = await grepTool.execute(
{ query: "function \\w+" },
mockContext,
);
expect(result).toContain("function hello");
expect(result).toContain("function goodbye");
expect(result).toContain("function greet");
});
it("supports character classes", async () => {
const result = await grepTool.execute({ query: "[hg]ello" }, mockContext);
expect(result).toContain("hello");
});
it("supports alternation", async () => {
const result = await grepTool.execute(
{ query: "hello|goodbye" },
mockContext,
);
expect(result).toContain("hello");
expect(result).toContain("goodbye");
});
});
describe("execute - result limiting", () => {
it("respects limit parameter", async () => {
const result = await grepTool.execute(
{ query: "hello", limit: 2 },
mockContext,
);
// Count the number of result lines (file:line: format)
const matchLines = result
.split("\n")
.filter((line) => line.match(/:\d+:/));
expect(matchLines.length).toBeLessThanOrEqual(2);
});
it("includes truncation notice when results are limited", async () => {
const result = await grepTool.execute(
{ query: "hello", limit: 1 },
mockContext,
);
expect(result).toMatchInlineSnapshot(`
"nested/deep.ts:2: function deepHello() {
[TRUNCATED: Showing 1 of 8 matches. Use include_pattern to narrow your search (e.g., include_pattern="*.tsx") or use a more specific query.]"
`);
});
it("includes truncation info in XML attributes", async () => {
await grepTool.execute({ query: "hello", limit: 1 }, mockContext);
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining('truncated="true"'),
);
expect(mockContext.onXmlComplete).toHaveBeenCalledWith(
expect.stringContaining('total="'),
);
});
});
describe("execute - result sorting", () => {
it("returns results sorted by path then line number", async () => {
const result = await grepTool.execute({ query: "hello" }, mockContext);
const lines = result.split("\n").filter((line) => line.match(/:\d+:/));
const paths = lines.map((line) => line.split(":")[0]);
// Verify paths are sorted
const sortedPaths = [...paths].sort();
expect(paths).toEqual(sortedPaths);
// Verify line numbers within same file are sorted
const pathToLines = new Map<string, number[]>();
for (const line of lines) {
const [path, lineNum] = line.split(":");
if (!pathToLines.has(path)) {
pathToLines.set(path, []);
}
pathToLines.get(path)!.push(Number.parseInt(lineNum, 10));
}
// Check each file's line numbers are sorted
for (const [_path, lineNums] of pathToLines.entries()) {
const sortedLineNums = [...lineNums].sort((a, b) => a - b);
expect(lineNums).toEqual(sortedLineNums);
}
});
});
describe("execute - line truncation", () => {
it("truncates lines longer than 500 characters", async () => {
// Create a file with a very long line
const longLine = "x".repeat(600);
await fs.promises.writeFile(
path.join(testDir, "long.ts"),
`const short = "hello";\nconst veryLongVariable = "${longLine}";\n`,
);
const result = await grepTool.execute(
{ query: "veryLongVariable" },
mockContext,
);
const lines = result.split("\n").filter((line) => line.match(/:\d+:/));
expect(lines.length).toBe(1);
// Extract the content after "path:lineNum: "
const match = lines[0].match(/^[^:]+:\d+:\s+(.*)$/);
expect(match).toBeTruthy();
const content = match![1];
// Should be truncated to 500 chars + "..." suffix (503 total)
expect(content.length).toBe(503);
expect(content.endsWith("...")).toBe(true);
});
});
describe("buildXml", () => {
it("returns undefined when query is missing", () => {
const result = grepTool.buildXml?.({}, false);
expect(result).toBeUndefined();
});
it("returns undefined when complete (execute handles final XML)", () => {
const result = grepTool.buildXml?.({ query: "hello" }, true);
expect(result).toBeUndefined();
});
it("builds partial XML during streaming", () => {
const result = grepTool.buildXml?.({ query: "hello" }, false);
expect(result).toContain("<dyad-grep");
expect(result).toContain('query="hello"');
expect(result).toContain("Searching...");
});
it("escapes special XML characters in query", () => {
const result = grepTool.buildXml?.(
{ query: 'test <tag> & "quote"' },
false,
);
expect(result).toContain("&lt;tag&gt;");
expect(result).toContain("&amp;");
expect(result).toContain("&quot;");
});
it("includes include_pattern in attributes", () => {
const result = grepTool.buildXml?.(
{ query: "test", include_pattern: "*.ts" },
false,
);
expect(result).toContain('include="*.ts"');
});
it("includes exclude_pattern in attributes", () => {
const result = grepTool.buildXml?.(
{ query: "test", exclude_pattern: "*.md" },
false,
);
expect(result).toContain('exclude="*.md"');
});
it("includes case-sensitive in attributes when true", () => {
const result = grepTool.buildXml?.(
{ query: "test", case_sensitive: true },
false,
);
expect(result).toContain('case-sensitive="true"');
});
});
describe("getConsentPreview", () => {
it("returns preview with query", () => {
const preview = grepTool.getConsentPreview?.({ query: "hello" });
expect(preview).toBe('Search for "hello"');
});
it("includes include_pattern in preview", () => {
const preview = grepTool.getConsentPreview?.({
query: "hello",
include_pattern: "*.ts",
});
expect(preview).toBe('Search for "hello" in *.ts');
});
});
});
......@@ -15,6 +15,10 @@ import log from "electron-log";
const logger = log.scope("grep");
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 250;
const MAX_LINE_LENGTH = 500;
const grepSchema = z.object({
query: z.string().describe("The regex pattern to search for"),
include_pattern: z
......@@ -31,6 +35,14 @@ const grepSchema = z.object({
.boolean()
.optional()
.describe("Whether the search should be case sensitive (default: false)"),
limit: z
.number()
.min(1)
.max(MAX_LIMIT)
.optional()
.describe(
`Maximum number of matches to return (default: ${DEFAULT_LIMIT}, max: ${MAX_LIMIT}). Use include_pattern to narrow results if limit is reached.`,
),
});
interface RipgrepMatch {
......@@ -42,6 +54,7 @@ interface RipgrepMatch {
function buildGrepAttributes(
args: Partial<z.infer<typeof grepSchema>>,
count?: number,
totalCount?: number,
): string {
const attrs: string[] = [];
if (args.query) {
......@@ -59,9 +72,20 @@ function buildGrepAttributes(
if (count !== undefined) {
attrs.push(`count="${count}"`);
}
if (totalCount !== undefined && totalCount > (count ?? 0)) {
attrs.push(`total="${totalCount}"`);
attrs.push(`truncated="true"`);
}
return attrs.join(" ");
}
function truncateLineText(text: string): string {
if (text.length <= MAX_LINE_LENGTH) {
return text;
}
return text.slice(0, MAX_LINE_LENGTH) + "...";
}
async function runRipgrep({
appPath,
query,
......@@ -82,7 +106,6 @@ async function runRipgrep({
"--no-config",
"--max-filesize",
`${MAX_FILE_SEARCH_SIZE}`,
...RIPGREP_EXCLUDED_GLOBS.flatMap((glob) => ["--glob", glob]),
];
// Case sensitivity: default is case-insensitive
......@@ -90,8 +113,9 @@ async function runRipgrep({
args.push("--ignore-case");
}
// Include pattern
if (includePat) {
// Include pattern (skip no-op "*" which would override exclusion globs
// and .gitignore rules since --glob always takes precedence over ignore logic)
if (includePat && includePat !== "*") {
args.push("--glob", includePat);
}
......@@ -100,6 +124,10 @@ async function runRipgrep({
args.push("--glob", `!${excludePat}`);
}
// Exclusion globs come LAST so they always take precedence over any
// include pattern (later --glob flags override earlier ones in ripgrep)
args.push(...RIPGREP_EXCLUDED_GLOBS.flatMap((glob) => ["--glob", glob]));
args.push("--", query, ".");
const rg = spawn(getRgExecutablePath(), args, { cwd: appPath });
......@@ -168,7 +196,8 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
- Returns matching lines with file paths and line numbers
- By default, the search is case-insensitive
- Use include_pattern to filter by file type (e.g. '*.tsx')
- Use exclude_pattern to skip certain files (e.g. '*.test.ts')`,
- Use exclude_pattern to skip certain files (e.g. '*.test.ts')
- Results are limited to ${DEFAULT_LIMIT} matches by default (max ${MAX_LIMIT}). If results are truncated, narrow your search with include_pattern or a more specific query.`,
inputSchema: grepSchema,
defaultConsent: "always",
......@@ -192,7 +221,9 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
},
execute: async (args, ctx: AgentContext) => {
const matches = await runRipgrep({
const includePatWasWildcard = args.include_pattern === "*";
const allMatches = await runRipgrep({
appPath: ctx.appPath,
query: args.query,
includePat: args.include_pattern,
......@@ -200,18 +231,37 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
caseSensitive: args.case_sensitive,
});
const attrs = buildGrepAttributes(args, matches.length);
const totalCount = allMatches.length;
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
// Sort for deterministic output (ripgrep's parallel execution can produce varying order)
const sortedMatches = [...allMatches].sort(
(a, b) => a.path.localeCompare(b.path) || a.lineNumber - b.lineNumber,
);
const matches = sortedMatches.slice(0, limit);
const wasTruncated = totalCount > limit;
const attrs = buildGrepAttributes(args, matches.length, totalCount);
if (matches.length === 0) {
ctx.onXmlComplete(`<dyad-grep ${attrs}>No matches found.</dyad-grep>`);
return "No matches found.";
}
// Format output: path:line: content
// Format output: path:line: content (with truncated line text)
const lines = matches.map(
(m) => `${m.path}:${m.lineNumber}: ${m.lineText}`,
(m) => `${m.path}:${m.lineNumber}: ${truncateLineText(m.lineText)}`,
);
const resultText = lines.join("\n");
let resultText = lines.join("\n");
// Add truncation notice for the AI
if (wasTruncated) {
resultText += `\n\n[TRUNCATED: Showing ${matches.length} of ${totalCount} matches. Use include_pattern to narrow your search (e.g., include_pattern="*.tsx") or use a more specific query.]`;
}
// Warn the LLM that "*" was ignored so it doesn't retry with the same pattern
if (includePatWasWildcard) {
resultText += `\n\n[NOTE: include_pattern="*" was ignored because it matches all files including git-ignored files! Omit include_pattern to search all files, or use a specific glob like "*.ts".]`;
}
ctx.onXmlComplete(
`<dyad-grep ${attrs}>\n${escapeXmlContent(resultText)}\n</dyad-grep>`,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论