ml-intern / agent /tools /local_tools.py
akseljoonas's picture
feat: upgrade CLI with local tools, slash commands, and interrupt support
392de34
Raw
History Blame
5.19 kB
"""
Local tool implementations β€” bash/read/write/edit running on the user's machine.
Drop-in replacement for sandbox tools when running in CLI (local) mode.
Same tool specs (names, parameters) but handlers execute locally via
subprocess/pathlib instead of going through a remote sandbox.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any
from agent.tools.sandbox_client import Sandbox
MAX_OUTPUT_CHARS = 30_000
MAX_LINE_LENGTH = 2000
DEFAULT_READ_LINES = 2000
DEFAULT_TIMEOUT = 120
MAX_TIMEOUT = 600
# ── Handlers ────────────────────────────────────────────────────────────
async def _bash_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
command = args.get("command", "")
if not command:
return "No command provided.", False
work_dir = args.get("work_dir", ".")
timeout = min(args.get("timeout") or DEFAULT_TIMEOUT, MAX_TIMEOUT)
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
cwd=work_dir,
timeout=timeout,
)
output = result.stdout + result.stderr
if len(output) > MAX_OUTPUT_CHARS:
output = output[:MAX_OUTPUT_CHARS] + "\n... (output truncated)"
if not output.strip():
output = "(no output)"
return output, result.returncode == 0
except subprocess.TimeoutExpired:
return f"Command timed out after {timeout}s.", False
except Exception as e:
return f"bash error: {e}", False
async def _read_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
file_path = args.get("path", "")
if not file_path:
return "No path provided.", False
p = Path(file_path)
if not p.exists():
return f"File not found: {file_path}", False
if p.is_dir():
return "Cannot read a directory. Use bash with 'ls' instead.", False
try:
lines = p.read_text().splitlines()
except Exception as e:
return f"read error: {e}", False
offset = max((args.get("offset") or 1), 1)
limit = args.get("limit") or DEFAULT_READ_LINES
selected = lines[offset - 1 : offset - 1 + limit]
numbered = []
for i, line in enumerate(selected, start=offset):
if len(line) > MAX_LINE_LENGTH:
line = line[:MAX_LINE_LENGTH] + "..."
numbered.append(f"{i:>6}\t{line}")
return "\n".join(numbered), True
async def _write_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
file_path = args.get("path", "")
content = args.get("content", "")
if not file_path:
return "No path provided.", False
p = Path(file_path)
try:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
return f"Wrote {len(content)} bytes to {file_path}", True
except Exception as e:
return f"write error: {e}", False
async def _edit_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
file_path = args.get("path", "")
old_str = args.get("old_str", "")
new_str = args.get("new_str", "")
replace_all = args.get("replace_all", False)
if not file_path:
return "No path provided.", False
if old_str == new_str:
return "old_str and new_str must differ.", False
p = Path(file_path)
if not p.exists():
return f"File not found: {file_path}", False
try:
text = p.read_text()
except Exception as e:
return f"edit read error: {e}", False
count = text.count(old_str)
if count == 0:
return "old_str not found in file.", False
if count > 1 and not replace_all:
return (
f"old_str appears {count} times. Use replace_all=true to replace all, "
"or provide a more specific old_str."
), False
new_text = text.replace(old_str, new_str) if replace_all else text.replace(old_str, new_str, 1)
try:
p.write_text(new_text)
except Exception as e:
return f"edit write error: {e}", False
replacements = count if replace_all else 1
return f"Edited {file_path} ({replacements} replacement{'s' if replacements > 1 else ''})", True
# ── Public API ──────────────────────────────────────────────────────────
_HANDLERS = {
"bash": _bash_handler,
"read": _read_handler,
"write": _write_handler,
"edit": _edit_handler,
}
def get_local_tools():
"""Return local ToolSpecs for bash/read/write/edit (no sandbox_create)."""
from agent.core.tools import ToolSpec
tools = []
for name, spec in Sandbox.TOOLS.items():
handler = _HANDLERS.get(name)
if handler is None:
continue
tools.append(
ToolSpec(
name=name,
description=spec["description"],
parameters=spec["parameters"],
handler=handler,
)
)
return tools