diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index f46c8f2c0..58ef0c501 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -5,6 +5,7 @@ import { GenericToolCall } from "../tools/GenericToolCall"; import { BashToolCall } from "../tools/BashToolCall"; import { FileEditToolCall } from "../tools/FileEditToolCall"; import { FileReadToolCall } from "../tools/FileReadToolCall"; +import { FileSearchToolCall } from "../tools/FileSearchToolCall"; import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; import { TodoToolCall } from "../tools/TodoToolCall"; import { StatusSetToolCall } from "../tools/StatusSetToolCall"; @@ -13,6 +14,8 @@ import type { BashToolResult, FileReadToolArgs, FileReadToolResult, + FileSearchToolArgs, + FileSearchToolResult, FileEditInsertToolArgs, FileEditInsertToolResult, FileEditReplaceStringToolArgs, @@ -45,6 +48,11 @@ function isFileReadTool(toolName: string, args: unknown): args is FileReadToolAr return TOOL_DEFINITIONS.file_read.schema.safeParse(args).success; } +function isFileSearchTool(toolName: string, args: unknown): args is FileSearchToolArgs { + if (toolName !== "file_search") return false; + return TOOL_DEFINITIONS.file_search.schema.safeParse(args).success; +} + function isFileEditReplaceStringTool( toolName: string, args: unknown @@ -108,6 +116,18 @@ export const ToolMessage: React.FC = ({ message, className, wo ); } + if (isFileSearchTool(message.toolName, message.args)) { + return ( +
+ +
+ ); + } + if (isFileEditReplaceStringTool(message.toolName, message.args)) { return (
diff --git a/src/components/tools/FileSearchToolCall.tsx b/src/components/tools/FileSearchToolCall.tsx new file mode 100644 index 000000000..a865d833b --- /dev/null +++ b/src/components/tools/FileSearchToolCall.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import type { FileSearchToolArgs, FileSearchToolResult } from "@/types/tools"; +import { + ToolContainer, + ToolHeader, + ExpandIcon, + StatusIndicator, + ToolDetails, + DetailSection, + DetailLabel, + DetailContent, + LoadingDots, +} from "./shared/ToolPrimitives"; +import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; + +interface FileSearchToolCallProps { + args: FileSearchToolArgs; + result?: FileSearchToolResult; + status?: ToolStatus; +} + +export const FileSearchToolCall: React.FC = ({ + args, + result, + status = "pending", +}) => { + const { expanded, toggleExpanded } = useToolExpansion(); + + const matchCount = result?.success ? result.matches.length : 0; + const totalMatches = result?.success ? result.total_matches : 0; + const hasMore = matchCount < totalMatches; + + return ( + + + + + 🔍 + file_search + + {args.file_path} + {result && result.success && ( + + {matchCount} {matchCount === 1 ? "match" : "matches"} + {hasMore && ` (showing first ${matchCount})`} + + )} + {getStatusDisplay(status)} + + + {expanded && ( + + +
+
+ Pattern: + + "{args.pattern}" + +
+ {args.context_lines !== undefined && ( +
+ Context: + {args.context_lines} lines +
+ )} +
+
+ + {!result && ( + +
+ Searching + +
+
+ )} + + {result && !result.success && ( + +
+ Error: + {result.error} +
+
+ )} + + {result && result.success && ( + <> + {matchCount === 0 ? ( + +
No matches found
+
+ ) : ( + result.matches.map((match, idx) => ( + +
+ {/* Context before */} + {match.context_before.length > 0 && ( +
+ {match.context_before.map((line, i) => ( +
+ + {match.line_number - match.context_before.length + i} + + {line} +
+ ))} +
+ )} + + {/* Matching line - highlighted */} +
+ + {match.line_number} + + {match.line_content} +
+ + {/* Context after */} + {match.context_after.length > 0 && ( +
+ {match.context_after.map((line, i) => ( +
+ + {match.line_number + i + 1} + + {line} +
+ ))} +
+ )} +
+
+ )) + )} + + {hasMore && ( + +
+ Showing first {matchCount} of {totalMatches} matches. Increase max_results to + see more. +
+
+ )} + + )} +
+ )} +
+ ); +}; diff --git a/src/services/tools/file_search.test.ts b/src/services/tools/file_search.test.ts new file mode 100644 index 000000000..8644405f7 --- /dev/null +++ b/src/services/tools/file_search.test.ts @@ -0,0 +1,247 @@ +import { describe, test, expect } from "bun:test"; +import * as fs from "fs/promises"; +import type { ToolCallOptions } from "ai"; +import { createFileSearchTool } from "./file_search"; +import type { FileSearchToolArgs, FileSearchToolResult } from "@/types/tools"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import { writeFileString } from "@/utils/runtime/helpers"; + +// Mock ToolCallOptions for testing +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +describe("file_search tool", () => { + function createTestTool() { + const tempDir = new TestTempDir("test-file-search"); + const config = createTestToolConfig(tempDir.path); + const tool = createFileSearchTool(config); + return { + tool, + tempDir, + workspacePath: tempDir.path, + runtime: config.runtime, + [Symbol.dispose]() { + tempDir[Symbol.dispose](); + }, + }; + } + + test("finds single match in file", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/test-search.txt`; + await writeFileString( + testEnv.runtime, + testFilePath, + "line 1\nline 2\ntarget line\nline 4\nline 5" + ); + + const args: FileSearchToolArgs = { + file_path: "test-search.txt", + pattern: "target", + context_lines: 1, + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches).toHaveLength(1); + expect(result.matches[0].line_number).toBe(3); + expect(result.matches[0].line_content).toBe("target line"); + expect(result.matches[0].context_before).toEqual(["line 2"]); + expect(result.matches[0].context_after).toEqual(["line 4"]); + expect(result.total_matches).toBe(1); + } + }); + + test("finds multiple matches in file", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/multi-match.txt`; + await writeFileString(testEnv.runtime, testFilePath, "foo bar\nbaz foo\nqux\nfoo baz\nend"); + + const args: FileSearchToolArgs = { + file_path: "multi-match.txt", + pattern: "foo", + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches).toHaveLength(3); + expect(result.matches[0].line_number).toBe(1); + expect(result.matches[1].line_number).toBe(2); + expect(result.matches[2].line_number).toBe(4); + expect(result.total_matches).toBe(3); + } + }); + + test("returns empty matches when pattern not found", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/no-match.txt`; + await writeFileString(testEnv.runtime, testFilePath, "line 1\nline 2\nline 3"); + + const args: FileSearchToolArgs = { + file_path: "no-match.txt", + pattern: "nonexistent", + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches).toHaveLength(0); + expect(result.total_matches).toBe(0); + } + }); + + test("respects max_results limit", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/many-matches.txt`; + const lines = Array.from({ length: 100 }, (_, i) => `line ${i} with target`).join("\n"); + await writeFileString(testEnv.runtime, testFilePath, lines); + + const args: FileSearchToolArgs = { + file_path: "many-matches.txt", + pattern: "target", + max_results: 10, + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches).toHaveLength(10); + expect(result.total_matches).toBe(10); + } + }); + + test("handles context_lines at file boundaries", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/boundary.txt`; + await writeFileString(testEnv.runtime, testFilePath, "target at start\nline 2\nline 3"); + + const args: FileSearchToolArgs = { + file_path: "boundary.txt", + pattern: "target", + context_lines: 5, + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches[0].context_before).toHaveLength(0); + expect(result.matches[0].context_after).toHaveLength(2); + } + }); + + test("handles zero context_lines", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/no-context.txt`; + await writeFileString(testEnv.runtime, testFilePath, "line 1\ntarget\nline 3"); + + const args: FileSearchToolArgs = { + file_path: "no-context.txt", + pattern: "target", + context_lines: 0, + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches[0].context_before).toHaveLength(0); + expect(result.matches[0].context_after).toHaveLength(0); + } + }); + + test("fails when file does not exist", async () => { + using testEnv = createTestTool(); + + const args: FileSearchToolArgs = { + file_path: "nonexistent.txt", + pattern: "anything", + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("ENOENT"); + } + }); + + test("fails when path is directory", async () => { + using testEnv = createTestTool(); + + const dirPath = `${testEnv.workspacePath}/testdir`; + await fs.mkdir(dirPath); + + const args: FileSearchToolArgs = { + file_path: "testdir", + pattern: "anything", + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("directory"); + } + }); + + test("case-sensitive search", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/case-test.txt`; + await writeFileString(testEnv.runtime, testFilePath, "Target\ntarget\nTARGET"); + + const args: FileSearchToolArgs = { + file_path: "case-test.txt", + pattern: "target", + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + if (!result.success) { + console.log("Case test error:", result.error); + } + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches).toHaveLength(1); + expect(result.matches[0].line_number).toBe(2); + } + }); + + test("searches for exact substring", async () => { + using testEnv = createTestTool(); + + const testFilePath = `${testEnv.workspacePath}/substring.txt`; + await writeFileString( + testEnv.runtime, + testFilePath, + "function foo() {\n return bar;\n}\nfoo()" + ); + + const args: FileSearchToolArgs = { + file_path: "substring.txt", + pattern: "foo", + }; + + const result = (await testEnv.tool.execute!(args, mockToolCallOptions)) as FileSearchToolResult; + + expect(result.success).toBe(true); + if (result.success) { + expect(result.matches).toHaveLength(2); + } + }); +}); diff --git a/src/services/tools/file_search.ts b/src/services/tools/file_search.ts new file mode 100644 index 000000000..850c30b17 --- /dev/null +++ b/src/services/tools/file_search.ts @@ -0,0 +1,145 @@ +import { tool } from "ai"; +import type { FileSearchToolArgs, FileSearchToolResult, FileSearchMatch } from "@/types/tools"; +import type { ToolConfiguration, ToolFactory } from "@/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; +import { validatePathInCwd, validateFileSize } from "./fileCommon"; +import { readFileString } from "@/utils/runtime/helpers"; +import { RuntimeError } from "@/runtime/Runtime"; + +const DEFAULT_CONTEXT_LINES = 3; +const DEFAULT_MAX_RESULTS = 100; + +/** + * File search tool factory for AI assistant + * Searches for a pattern in a file and returns matching lines with context + */ +export const createFileSearchTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.file_search.description, + inputSchema: TOOL_DEFINITIONS.file_search.schema, + execute: async ( + args: FileSearchToolArgs, + { abortSignal: _abortSignal } + ): Promise => { + try { + const { file_path, pattern, context_lines, max_results } = args; + + // Validate path is within workspace + const pathValidation = validatePathInCwd(file_path, config.cwd, config.runtime); + if (pathValidation) { + return { + success: false, + error: pathValidation.error, + }; + } + + // Resolve path using runtime + const resolvedPath = config.runtime.normalizePath(file_path, config.cwd); + + // Check file exists and get stats + let fileStat; + try { + fileStat = await config.runtime.stat(resolvedPath); + } catch (err) { + if (err instanceof RuntimeError) { + return { + success: false, + error: err.message, + }; + } + throw err; + } + + if (fileStat.isDirectory) { + return { + success: false, + error: `Path is a directory, not a file: ${resolvedPath}`, + }; + } + + const sizeValidation = validateFileSize(fileStat); + if (sizeValidation) { + return { + success: false, + error: sizeValidation.error, + }; + } + + // Read file content + let content: string; + try { + content = await readFileString(config.runtime, resolvedPath); + } catch (err) { + if (err instanceof RuntimeError) { + return { + success: false, + error: err.message, + }; + } + throw err; + } + + // Split into lines and search + const lines = content.split("\n"); + const contextLinesCount = context_lines ?? DEFAULT_CONTEXT_LINES; + const maxResults = max_results ?? DEFAULT_MAX_RESULTS; + const matches: FileSearchMatch[] = []; + + // Find all matching lines + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(pattern)) { + // Calculate context range + const startIdx = Math.max(0, i - contextLinesCount); + const endIdx = Math.min(lines.length - 1, i + contextLinesCount); + + const match: FileSearchMatch = { + line_number: i + 1, // 1-indexed + line_content: lines[i], + context_before: lines.slice(startIdx, i), + context_after: lines.slice(i + 1, endIdx + 1), + }; + + matches.push(match); + + // Stop if we've reached max results + if (matches.length >= maxResults) { + break; + } + } + } + + return { + success: true, + file_path: resolvedPath, + pattern, + matches, + total_matches: matches.length, + file_size: fileStat.size, + }; + } catch (error) { + if (error && typeof error === "object" && "code" in error) { + const nodeError = error as { code?: string }; + if (nodeError.code === "ENOENT") { + return { + success: false, + error: `File not found: ${args.file_path}`, + }; + } + + if (nodeError.code === "EACCES") { + return { + success: false, + error: `Permission denied: ${args.file_path}`, + }; + } + } + + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: `Failed to search file: ${message}`, + }; + } + }, + }); +}; diff --git a/src/types/tools.ts b/src/types/tools.ts index a2ae4c7e4..8f6762378 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -58,6 +58,35 @@ export type FileReadToolResult = error: string; }; +// File Search Tool Types +export interface FileSearchToolArgs { + file_path: string; + pattern: string; + context_lines?: number; // lines before/after match (default: 3) + max_results?: number; // maximum number of matches to return (default: 100) +} + +export interface FileSearchMatch { + line_number: number; // 1-indexed line number + line_content: string; // the matching line + context_before: string[]; // lines before the match + context_after: string[]; // lines after the match +} + +export type FileSearchToolResult = + | { + success: true; + file_path: string; + pattern: string; + matches: FileSearchMatch[]; + total_matches: number; // may be > matches.length if max_results exceeded + file_size: number; + } + | { + success: false; + error: string; + }; + export interface FileEditDiffSuccessBase { success: true; diff: string; diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index c9fea0587..7b4cfe0bf 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -68,6 +68,30 @@ export const TOOL_DEFINITIONS = { .describe("Number of lines to return from offset (optional, returns all if not specified)"), }), }, + file_search: { + description: + "Search for a pattern in a file and return matching lines with surrounding context. " + + "Use this to find specific code sections, verify current content before editing, or confirm changes after editing. " + + "The pattern is treated as a literal string (not regex) and search is case-sensitive.", + schema: z.object({ + file_path: z.string().describe("The absolute path to the file to search"), + pattern: z.string().describe("The literal string pattern to search for (case-sensitive)"), + context_lines: z + .number() + .int() + .min(0) + .max(10) + .optional() + .describe("Number of lines to show before and after each match (default: 3, max: 10)"), + max_results: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Maximum number of matches to return (default: 100, max: 1000)"), + }), + }, file_edit_replace_string: { description: "Apply one or more edits to a file by replacing exact text matches. All edits are applied sequentially. Each old_string must be unique in the file unless replace_count > 1 or replace_count is -1. " + @@ -249,6 +273,7 @@ export function getAvailableTools(modelString: string): string[] { const baseTools = [ "bash", "file_read", + "file_search", "file_edit_replace_string", // "file_edit_replace_lines", // DISABLED: causes models to break repo state "file_edit_insert", diff --git a/src/utils/tools/tools.ts b/src/utils/tools/tools.ts index a48be74b1..49f70a4f8 100644 --- a/src/utils/tools/tools.ts +++ b/src/utils/tools/tools.ts @@ -1,5 +1,6 @@ import { type Tool } from "ai"; import { createFileReadTool } from "@/services/tools/file_read"; +import { createFileSearchTool } from "@/services/tools/file_search"; import { createBashTool } from "@/services/tools/bash"; import { createFileEditReplaceStringTool } from "@/services/tools/file_edit_replace_string"; // DISABLED: import { createFileEditReplaceLinesTool } from "@/services/tools/file_edit_replace_lines"; @@ -64,6 +65,7 @@ export async function getToolsForModel( // Wrap them to handle init waiting centrally instead of in each tool const runtimeTools: Record = { file_read: wrap(createFileReadTool(config)), + file_search: wrap(createFileSearchTool(config)), file_edit_replace_string: wrap(createFileEditReplaceStringTool(config)), // DISABLED: file_edit_replace_lines - causes models (particularly GPT-5-Codex) // to leave repository in broken state due to issues with concurrent file modifications