mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 12:12:22 +02:00
- PostHog integration: collector, analytics subapp, opt-in UI, Analytics page - 9Router: auto-start, OAuth subscription flow, /v1/messages Anthropic format support - Settings overhaul: multi-provider API keys, subscription connect UI, onboarding modal - Unified usage: merge 9Router cost/token data into Settings Usage tab - Provider system: providers/, agent_loop, tools/ (unused, for future non-Anthropic support) - Agent SDK: restored as primary with 9Router ANTHROPIC_BASE_URL fallback - Updated system prompt, credential resolution, dashboard analytics
477 lines
18 KiB
Python
477 lines
18 KiB
Python
"""Filesystem tools: Read, Write, Edit, Glob, Grep."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from backend.apps.agents.tools.base import BaseTool, ToolContext
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
|
|
_MAX_OUTPUT_BYTES = 50 * 1024 # ~50 KB cap for grep output
|
|
|
|
|
|
def _resolve(file_path: str, cwd: str) -> Path:
|
|
"""Resolve *file_path* against *cwd* when it is relative."""
|
|
p = Path(file_path)
|
|
if not p.is_absolute():
|
|
p = Path(cwd) / p
|
|
return p.resolve()
|
|
|
|
|
|
def _text_block(text: str) -> list[dict]:
|
|
return [{"type": "text", "text": text}]
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# ReadTool
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class ReadTool(BaseTool):
|
|
name = "Read"
|
|
description = (
|
|
"Read a file from the filesystem. Returns lines with line numbers "
|
|
"(cat -n style). For image files returns base64 content. Supports "
|
|
"offset and limit parameters for reading portions of large files."
|
|
)
|
|
|
|
def get_schema(self) -> dict:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"file_path": {
|
|
"type": "string",
|
|
"description": "Absolute or relative path to the file to read.",
|
|
},
|
|
"offset": {
|
|
"type": "integer",
|
|
"description": "1-based line number to start reading from.",
|
|
},
|
|
"limit": {
|
|
"type": "integer",
|
|
"description": "Maximum number of lines to return (default 2000).",
|
|
},
|
|
},
|
|
"required": ["file_path"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
|
file_path = _resolve(input_data["file_path"], context.cwd)
|
|
|
|
if not file_path.exists():
|
|
return _text_block(f"Error: file not found: {file_path}")
|
|
|
|
if not file_path.is_file():
|
|
return _text_block(f"Error: not a regular file: {file_path}")
|
|
|
|
# Binary / image files → base64
|
|
ext = file_path.suffix.lower()
|
|
if ext in _IMAGE_EXTENSIONS:
|
|
try:
|
|
raw = file_path.read_bytes()
|
|
b64 = base64.b64encode(raw).decode("ascii")
|
|
media = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
return [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": media,
|
|
"data": b64,
|
|
},
|
|
}
|
|
]
|
|
except Exception as exc:
|
|
return _text_block(f"Error reading image {file_path}: {exc}")
|
|
|
|
# Text files
|
|
offset = max(input_data.get("offset", 1), 1)
|
|
limit = input_data.get("limit", 2000)
|
|
if limit <= 0:
|
|
limit = 2000
|
|
|
|
try:
|
|
with open(file_path, "r", errors="replace") as fh:
|
|
lines: list[str] = []
|
|
for lineno, line in enumerate(fh, start=1):
|
|
if lineno < offset:
|
|
continue
|
|
if len(lines) >= limit:
|
|
break
|
|
# cat -n style: right-justified line number + tab + content
|
|
lines.append(f"{lineno:>6}\t{line.rstrip()}")
|
|
if not lines:
|
|
return _text_block(f"(file is empty or offset beyond end of file: {file_path})")
|
|
return _text_block("\n".join(lines))
|
|
except Exception as exc:
|
|
return _text_block(f"Error reading {file_path}: {exc}")
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# WriteTool
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class WriteTool(BaseTool):
|
|
name = "Write"
|
|
description = (
|
|
"Write content to a file. Creates parent directories if they do not "
|
|
"exist. Overwrites the file if it already exists."
|
|
)
|
|
|
|
def get_schema(self) -> dict:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"file_path": {
|
|
"type": "string",
|
|
"description": "Absolute or relative path to the file to write.",
|
|
},
|
|
"content": {
|
|
"type": "string",
|
|
"description": "The full content to write to the file.",
|
|
},
|
|
},
|
|
"required": ["file_path", "content"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
|
file_path = _resolve(input_data["file_path"], context.cwd)
|
|
content: str = input_data["content"]
|
|
|
|
try:
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
file_path.write_text(content, encoding="utf-8")
|
|
return _text_block(f"Successfully wrote {len(content)} bytes to {file_path}")
|
|
except Exception as exc:
|
|
return _text_block(f"Error writing {file_path}: {exc}")
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# EditTool
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class EditTool(BaseTool):
|
|
name = "Edit"
|
|
description = (
|
|
"Perform exact string replacements in a file. By default the "
|
|
"old_string must appear exactly once (not unique → error). Pass "
|
|
"replace_all=true to replace every occurrence."
|
|
)
|
|
|
|
def get_schema(self) -> dict:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"file_path": {
|
|
"type": "string",
|
|
"description": "Absolute or relative path to the file to edit.",
|
|
},
|
|
"old_string": {
|
|
"type": "string",
|
|
"description": "The exact text to find in the file.",
|
|
},
|
|
"new_string": {
|
|
"type": "string",
|
|
"description": "The text to replace old_string with.",
|
|
},
|
|
"replace_all": {
|
|
"type": "boolean",
|
|
"description": "If true, replace all occurrences. Default false.",
|
|
"default": False,
|
|
},
|
|
},
|
|
"required": ["file_path", "old_string", "new_string"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
|
file_path = _resolve(input_data["file_path"], context.cwd)
|
|
old_string: str = input_data["old_string"]
|
|
new_string: str = input_data["new_string"]
|
|
replace_all: bool = input_data.get("replace_all", False)
|
|
|
|
if not file_path.exists():
|
|
return _text_block(f"Error: file not found: {file_path}")
|
|
if not file_path.is_file():
|
|
return _text_block(f"Error: not a regular file: {file_path}")
|
|
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8")
|
|
except Exception as exc:
|
|
return _text_block(f"Error reading {file_path}: {exc}")
|
|
|
|
count = content.count(old_string)
|
|
if count == 0:
|
|
return _text_block(
|
|
f"Error: old_string not found in {file_path}. "
|
|
"Make sure the string matches exactly, including whitespace and indentation."
|
|
)
|
|
|
|
if not replace_all and count > 1:
|
|
return _text_block(
|
|
f"Error: old_string appears {count} times in {file_path}. "
|
|
"Provide more surrounding context to make the match unique, "
|
|
"or set replace_all=true to replace every occurrence."
|
|
)
|
|
|
|
if replace_all:
|
|
new_content = content.replace(old_string, new_string)
|
|
else:
|
|
# Replace only the first (and only) occurrence
|
|
new_content = content.replace(old_string, new_string, 1)
|
|
|
|
try:
|
|
file_path.write_text(new_content, encoding="utf-8")
|
|
except Exception as exc:
|
|
return _text_block(f"Error writing {file_path}: {exc}")
|
|
|
|
replacements = count if replace_all else 1
|
|
return _text_block(
|
|
f"Successfully edited {file_path} ({replacements} replacement{'s' if replacements != 1 else ''})."
|
|
)
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# GlobTool
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class GlobTool(BaseTool):
|
|
name = "Glob"
|
|
description = (
|
|
"Fast file pattern matching. Supports glob patterns like '**/*.py'. "
|
|
"Returns matching file paths sorted by modification time (newest first)."
|
|
)
|
|
|
|
def get_schema(self) -> dict:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"pattern": {
|
|
"type": "string",
|
|
"description": "Glob pattern to match files (e.g. '**/*.py', 'src/**/*.ts').",
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Directory to search in. Defaults to the working directory.",
|
|
},
|
|
},
|
|
"required": ["pattern"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
|
pattern: str = input_data["pattern"]
|
|
base = Path(input_data.get("path") or context.cwd)
|
|
|
|
if not base.is_dir():
|
|
return _text_block(f"Error: directory not found: {base}")
|
|
|
|
try:
|
|
matches: list[Path] = []
|
|
for p in base.glob(pattern):
|
|
if p.is_file():
|
|
matches.append(p)
|
|
if len(matches) >= 500:
|
|
break
|
|
|
|
# Sort by modification time, newest first
|
|
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
|
|
if not matches:
|
|
return _text_block(f"No files matched pattern '{pattern}' in {base}")
|
|
|
|
result = "\n".join(str(p) for p in matches)
|
|
return _text_block(result)
|
|
except Exception as exc:
|
|
return _text_block(f"Error during glob '{pattern}' in {base}: {exc}")
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# GrepTool
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class GrepTool(BaseTool):
|
|
name = "Grep"
|
|
description = (
|
|
"Search file contents using regular expressions. Uses ripgrep (rg) "
|
|
"when available, otherwise falls back to Python's re module. "
|
|
"Supports output modes: files_with_matches, content, count."
|
|
)
|
|
|
|
def get_schema(self) -> dict:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"pattern": {
|
|
"type": "string",
|
|
"description": "Regular expression pattern to search for.",
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": "File or directory to search in. Defaults to the working directory.",
|
|
},
|
|
"glob": {
|
|
"type": "string",
|
|
"description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx}').",
|
|
},
|
|
"output_mode": {
|
|
"type": "string",
|
|
"enum": ["files_with_matches", "content", "count"],
|
|
"description": "Output mode. Default: files_with_matches.",
|
|
"default": "files_with_matches",
|
|
},
|
|
},
|
|
"required": ["pattern"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
|
pattern: str = input_data["pattern"]
|
|
search_path: str = input_data.get("path") or context.cwd
|
|
file_glob: str | None = input_data.get("glob")
|
|
output_mode: str = input_data.get("output_mode", "files_with_matches")
|
|
|
|
# Try ripgrep first
|
|
try:
|
|
result = await self._run_rg(pattern, search_path, file_glob, output_mode)
|
|
if result is not None:
|
|
return result
|
|
except FileNotFoundError:
|
|
pass # rg not installed, fall through to Python fallback
|
|
|
|
# Python fallback
|
|
return await self._python_grep(pattern, search_path, file_glob, output_mode)
|
|
|
|
async def _run_rg(
|
|
self,
|
|
pattern: str,
|
|
search_path: str,
|
|
file_glob: str | None,
|
|
output_mode: str,
|
|
) -> list[dict] | None:
|
|
"""Run ripgrep and return results, or None if rg is not available."""
|
|
cmd = ["rg", "--no-heading", "--color=never"]
|
|
|
|
if output_mode == "files_with_matches":
|
|
cmd.append("--files-with-matches")
|
|
elif output_mode == "count":
|
|
cmd.append("--count")
|
|
else:
|
|
cmd.extend(["--line-number"])
|
|
|
|
if file_glob:
|
|
cmd.extend(["--glob", file_glob])
|
|
|
|
cmd.append(pattern)
|
|
cmd.append(search_path)
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
except FileNotFoundError:
|
|
raise # re-raise so caller knows rg is missing
|
|
except asyncio.TimeoutError:
|
|
return _text_block("Error: grep timed out after 30 seconds.")
|
|
except Exception as exc:
|
|
return _text_block(f"Error running ripgrep: {exc}")
|
|
|
|
output = stdout.decode("utf-8", errors="replace")
|
|
|
|
if proc.returncode not in (0, 1):
|
|
err = stderr.decode("utf-8", errors="replace").strip()
|
|
if err:
|
|
return _text_block(f"Grep error: {err}")
|
|
|
|
if not output.strip():
|
|
return _text_block(f"No matches found for pattern '{pattern}'.")
|
|
|
|
# Truncate if too large
|
|
if len(output) > _MAX_OUTPUT_BYTES:
|
|
output = output[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
|
|
|
|
return _text_block(output.rstrip())
|
|
|
|
async def _python_grep(
|
|
self,
|
|
pattern: str,
|
|
search_path: str,
|
|
file_glob: str | None,
|
|
output_mode: str,
|
|
) -> list[dict]:
|
|
"""Pure-Python grep fallback using the re module."""
|
|
try:
|
|
regex = re.compile(pattern)
|
|
except re.error as exc:
|
|
return _text_block(f"Invalid regex pattern: {exc}")
|
|
|
|
base = Path(search_path)
|
|
if base.is_file():
|
|
files = [base]
|
|
elif base.is_dir():
|
|
glob_pat = file_glob or "**/*"
|
|
files = [p for p in base.glob(glob_pat) if p.is_file()]
|
|
else:
|
|
return _text_block(f"Error: path not found: {search_path}")
|
|
|
|
lines_out: list[str] = []
|
|
total_bytes = 0
|
|
truncated = False
|
|
|
|
for fp in sorted(files):
|
|
try:
|
|
text = fp.read_text(encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
continue
|
|
|
|
file_matches: list[tuple[int, str]] = []
|
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
if regex.search(line):
|
|
file_matches.append((lineno, line))
|
|
|
|
if not file_matches:
|
|
continue
|
|
|
|
if output_mode == "files_with_matches":
|
|
entry = str(fp)
|
|
elif output_mode == "count":
|
|
entry = f"{fp}:{len(file_matches)}"
|
|
else:
|
|
parts = [f"{fp}:{ln}:{txt}" for ln, txt in file_matches]
|
|
entry = "\n".join(parts)
|
|
|
|
total_bytes += len(entry)
|
|
if total_bytes > _MAX_OUTPUT_BYTES:
|
|
truncated = True
|
|
break
|
|
|
|
lines_out.append(entry)
|
|
|
|
if not lines_out:
|
|
return _text_block(f"No matches found for pattern '{pattern}'.")
|
|
|
|
result = "\n".join(lines_out)
|
|
if truncated:
|
|
result += "\n... (output truncated)"
|
|
|
|
return _text_block(result)
|