# ========================================== # Copyright (c) 2026 Gabriela Berger AI Oberland # All Rights Reserved. # This code is subject to the custom NON-COMMERCIAL # & ANTI-CORPORATE LICENSE (Maximum 20 PCs) found in the LICENSE file. # ========================================== """agent_tools.py Workspace-scoped tools the AI can call. Every path goes through `_safe` so the agent cannot read or write outside the chosen workspace folder. """ from __future__ import annotations import json import os import re import shutil import subprocess from pathlib import Path from typing import Any, Dict, List, Optional _DEFAULT_IGNORE = { ".git", "__pycache__", "node_modules", ".venv", "venv", ".mypy_cache", ".pytest_cache", "dist", "build", } class WorkspaceTools: def __init__(self, workspace_root: str): self.root = Path(workspace_root).expanduser().resolve() def set_root(self, workspace_root: str) -> None: self.root = Path(workspace_root).expanduser().resolve() def _safe(self, path: str) -> Path: p = Path(path) if not p.is_absolute(): p = self.root / p try: p_resolved = p.resolve() except OSError: p_resolved = p try: p_resolved.relative_to(self.root) except ValueError as e: raise PermissionError( f"path {p_resolved} is outside workspace {self.root}" ) from e return p_resolved def _rel(self, p: Path) -> str: try: r = str(p.relative_to(self.root)) return r if r != "." else "." except ValueError: return str(p) # ---- read -------------------------------------------------------- def list_dir(self, path: str = ".") -> Dict[str, Any]: p = self._safe(path) if not p.is_dir(): return {"error": f"not a directory: {self._rel(p)}"} items = [] for entry in sorted(p.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): items.append({ "name": entry.name, "type": "dir" if entry.is_dir() else "file", "size": entry.stat().st_size if entry.is_file() else None, }) return {"path": self._rel(p), "items": items} def workspace_tree(self, max_depth: int = 4, ignore: Optional[List[str]] = None) -> Dict[str, Any]: ignore_set = set(ignore or []) | _DEFAULT_IGNORE out: List[Dict[str, Any]] = [] for cur, dirs, files in os.walk(self.root): dirs[:] = [d for d in dirs if d not in ignore_set] rel = Path(cur).relative_to(self.root) depth = 0 if str(rel) == "." else len(rel.parts) if depth > max_depth: dirs[:] = [] continue out.append({ "dir": str(rel) if str(rel) != "." else ".", "subdirs": sorted(dirs), "files": sorted(files), }) return {"root": str(self.root), "tree": out} def read_file(self, path: str, max_bytes: int = 400_000) -> Dict[str, Any]: p = self._safe(path) if not p.is_file(): return {"error": f"not a file: {self._rel(p)}"} size = p.stat().st_size if size > max_bytes: return {"error": f"file too large: {size} bytes (limit {max_bytes})"} try: return { "path": self._rel(p), "content": p.read_text(encoding="utf-8", errors="replace"), "bytes": size, } except Exception as e: return {"error": f"{type(e).__name__}: {e}"} def search(self, pattern: str, path: str = ".", regex: bool = False, max_results: int = 200) -> Dict[str, Any]: base = self._safe(path) if regex: try: rx = re.compile(pattern) except re.error as e: return {"error": f"bad regex: {e}"} match = lambda line: rx.search(line) is not None else: needle = pattern match = lambda line: needle in line hits = [] for cur, dirs, files in os.walk(base): dirs[:] = [d for d in dirs if d not in _DEFAULT_IGNORE] for fn in files: fp = Path(cur) / fn try: with fp.open("r", encoding="utf-8", errors="replace") as f: for i, line in enumerate(f, 1): if match(line): hits.append({ "path": self._rel(fp), "line": i, "text": line.rstrip("\n")[:300], }) if len(hits) >= max_results: return {"pattern": pattern, "hits": hits, "truncated": True} except Exception: continue return {"pattern": pattern, "hits": hits, "truncated": False} # ---- write ------------------------------------------------------- def write_file(self, path: str, content: str, backup: bool = True) -> Dict[str, Any]: p = self._safe(path) p.parent.mkdir(parents=True, exist_ok=True) had_backup = False if backup and p.exists() and p.is_file(): shutil.copy2(p, p.with_suffix(p.suffix + ".bak")) had_backup = True p.write_text(content, encoding="utf-8") return {"ok": True, "path": self._rel(p), "bytes": len(content), "backup": had_backup} def append_file(self, path: str, content: str) -> Dict[str, Any]: p = self._safe(path) p.parent.mkdir(parents=True, exist_ok=True) with p.open("a", encoding="utf-8") as f: f.write(content) return {"ok": True, "path": self._rel(p)} def copy_file(self, src: str, dst: str) -> Dict[str, Any]: s = self._safe(src) d = self._safe(dst) if not s.exists(): return {"error": f"source missing: {self._rel(s)}"} d.parent.mkdir(parents=True, exist_ok=True) if s.is_dir(): shutil.copytree(s, d, dirs_exist_ok=True) else: shutil.copy2(s, d) return {"ok": True, "src": self._rel(s), "dst": self._rel(d)} def move_file(self, src: str, dst: str) -> Dict[str, Any]: s = self._safe(src) d = self._safe(dst) d.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(s), str(d)) return {"ok": True, "src": self._rel(s), "dst": self._rel(d)} def delete_file(self, path: str) -> Dict[str, Any]: p = self._safe(path) if p == self.root: return {"error": "refusing to delete workspace root"} if p.is_file(): p.unlink() return {"ok": True, "deleted": self._rel(p)} if p.is_dir(): shutil.rmtree(p) return {"ok": True, "deleted_dir": self._rel(p)} return {"error": f"not found: {self._rel(p)}"} def make_dir(self, path: str) -> Dict[str, Any]: p = self._safe(path) p.mkdir(parents=True, exist_ok=True) return {"ok": True, "path": self._rel(p)} # ---- run --------------------------------------------------------- def run_bash(self, command: str, timeout: int = 900) -> Dict[str, Any]: try: result = subprocess.run( command, shell=True, cwd=str(self.root), capture_output=True, text=True, timeout=timeout, ) return { "stdout": result.stdout[-20000:], "stderr": result.stderr[-20000:], "returncode": result.returncode, } except subprocess.TimeoutExpired: return {"error": f"timeout after {timeout}s"} except Exception as e: return {"error": f"{type(e).__name__}: {e}"} # ---- tool description / parsing ------------------------------------------ def tool_schemas() -> List[Dict[str, Any]]: return [ {"name": "list_dir", "args": "path: string ('.')", "desc": "list files/dirs in a workspace folder"}, {"name": "workspace_tree", "args": "max_depth: int (4)", "desc": "recursive tree of the whole workspace"}, {"name": "read_file", "args": "path: string", "desc": "read a file's contents"}, {"name": "search", "args": "pattern: string, path: string ('.'), regex: bool (false)", "desc": "search file contents"}, {"name": "write_file", "args": "path: string, content: string, backup: bool (true)", "desc": "create/overwrite file (auto .bak)"}, {"name": "append_file", "args": "path: string, content: string", "desc": "append text to a file"}, {"name": "copy_file", "args": "src: string, dst: string", "desc": "copy file or directory"}, {"name": "move_file", "args": "src: string, dst: string", "desc": "move/rename file or directory"}, {"name": "delete_file", "args": "path: string", "desc": "delete file or directory"}, {"name": "make_dir", "args": "path: string", "desc": "create directory (mkdir -p)"}, {"name": "run_bash", "args": "command: string, timeout: int (900)", "desc": "run a shell command in the workspace cwd"}, ] def tool_prompt() -> str: lines = [ "You can act on the workspace by emitting tool calls in this exact format:", "", '{"name": "", "arguments": { ... }}', "", "", "You may emit several blocks in one reply; all of them run and", "their JSON results come back to you in the next turn. When you have enough", "info, answer the user normally with NO block — that ends the loop.", "", "Rules:", "- All paths are relative to the workspace root (or absolute inside it).", "- Anything outside the workspace will be refused.", "- Do not invent file contents — read with read_file first.", "- Prefer doing the work (use tools) over describing what you would do.", "", "Available tools:", ] for s in tool_schemas(): lines.append(f"- {s['name']}({s['args']}) — {s['desc']}") return "\n".join(lines) _TOOL_CALL_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL) def parse_tool_calls(text: str) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] for m in _TOOL_CALL_RE.finditer(text or ""): raw = m.group(1) try: out.append(json.loads(raw)) except json.JSONDecodeError: continue return out def strip_tool_calls(text: str) -> str: return _TOOL_CALL_RE.sub("", text or "").strip() def dispatch(tools: WorkspaceTools, call: Dict[str, Any]) -> Dict[str, Any]: name = call.get("name") args = call.get("arguments") or {} if not isinstance(name, str) or name.startswith("_"): return {"error": f"invalid tool name: {name!r}"} fn = getattr(tools, name, None) if not callable(fn): return {"error": f"unknown tool: {name}"} if not isinstance(args, dict): return {"error": "arguments must be an object"} try: return fn(**args) except TypeError as e: return {"error": f"bad arguments: {e}"} except PermissionError as e: return {"error": str(e)} except Exception as e: return {"error": f"{type(e).__name__}: {e}"} __all__ = [ "WorkspaceTools", "tool_schemas", "tool_prompt", "parse_tool_calls", "strip_tool_calls", "dispatch", ]