Files
SneakyCode/app/tools/search.py

185 lines
5.8 KiB
Python

"""Search tools: grep_files and find_files."""
import subprocess
from typing import Any
from pydantic import BaseModel, Field
from app.models.tool_call import ToolResult, ToolResultStatus
from app.tools.base import BaseTool
from app.utils.file_helpers import PathSecurityError, resolve_safe_path
_GREP_MAX_MATCHES = 100
_FIND_MAX_RESULTS = 200
class GrepFilesParams(BaseModel):
"""Parameters for the grep_files tool."""
pattern: str = Field(description="Regular expression pattern to search for")
path: str = Field(default=".", description="Directory or file to search in (relative to workspace root)")
file_pattern: str | None = Field(
default=None, description="Glob pattern to filter files (e.g. '*.py')"
)
class GrepFilesTool(BaseTool):
"""Search file contents using grep."""
name = "grep_files"
description = (
"Search for a regex pattern in file contents. Returns matching lines with "
"file paths and line numbers."
)
params_model = GrepFilesParams
def execute(
self,
*,
tool_call_id: str,
pattern: str,
path: str = ".",
file_pattern: str | None = None,
**kwargs: Any,
) -> ToolResult:
try:
safe_path = resolve_safe_path(path, self.workspace_root)
except PathSecurityError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=str(exc),
)
cmd = ["grep", "-rn", pattern, str(safe_path)]
if file_pattern:
cmd.insert(3, f"--include={file_pattern}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error="grep timed out after 30 seconds",
)
if result.returncode == 1:
# No matches — not an error
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output="No matches found.",
)
if result.returncode >= 2:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=result.stderr.strip() or f"grep exited with code {result.returncode}",
)
# Truncate to max matches
lines = result.stdout.splitlines()
output_lines = lines[:_GREP_MAX_MATCHES]
if len(lines) > _GREP_MAX_MATCHES:
output_lines.append(f"\n... truncated ({len(lines)} matches, showing {_GREP_MAX_MATCHES})")
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output="\n".join(output_lines),
)
class FindFilesParams(BaseModel):
"""Parameters for the find_files tool."""
pattern: str = Field(description="File name pattern to search for (e.g. '*.py', 'config.yaml')")
path: str = Field(default=".", description="Directory to search in (relative to workspace root)")
class FindFilesTool(BaseTool):
"""Find files by name pattern."""
name = "find_files"
description = "Search for files matching a name pattern. Returns relative file paths."
params_model = FindFilesParams
def execute(
self,
*,
tool_call_id: str,
pattern: str,
path: str = ".",
**kwargs: Any,
) -> ToolResult:
try:
safe_path = resolve_safe_path(path, self.workspace_root)
except PathSecurityError as exc:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=str(exc),
)
cmd = ["find", str(safe_path), "-name", pattern, "-type", "f"]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error="find timed out after 30 seconds",
)
if result.returncode != 0:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error=result.stderr.strip() or f"find exited with code {result.returncode}",
)
# Make paths relative to workspace root and truncate
lines = result.stdout.strip().splitlines() if result.stdout.strip() else []
relative_lines: list[str] = []
for line in lines[:_FIND_MAX_RESULTS]:
try:
from pathlib import Path
rel = Path(line).relative_to(self.workspace_root)
relative_lines.append(str(rel))
except ValueError:
relative_lines.append(line)
if len(lines) > _FIND_MAX_RESULTS:
relative_lines.append(f"\n... truncated ({len(lines)} results, showing {_FIND_MAX_RESULTS})")
output = "\n".join(relative_lines) if relative_lines else "No files found."
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output=output,
)