""" Tools system for Meeting Room agents. Provides file access and web capabilities via OpenAI function calling. The ToolRegistry class encapsulates all tool logic with a configurable workdir. Module-level wrappers maintain backward compatibility. """ import fnmatch import httpx import json import os import re import subprocess # ─── Tool schemas (OpenAI function calling format) ─── TOOL_SCHEMAS = [ { "type": "function", "function": { "name": "read_file", "description": "Read file contents. Returns text with line numbers.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Path to the file"}, "offset": {"type": "integer", "description": "Start from this line (1-indexed)", "default": 1}, "limit": {"type": "integer", "description": "Maximum number of lines", "default": 100}, }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "list_files", "description": "List files and directories.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Directory to list"}, "pattern": {"type": "string", "description": "Filter by name (glob)"}, "max_depth": {"type": "integer", "description": "Traversal depth", "default": 3}, }, "required": [], }, }, }, { "type": "function", "function": { "name": "search_in_files", "description": "Search text in files (regex).", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query (regex)"}, "path": {"type": "string", "description": "Directory to search in"}, "file_pattern": {"type": "string", "description": "Filter by extension (e.g. *.py)"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "web_search", "description": "Search the web. Returns titles, snippets and URLs.", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "max_results": {"type": "integer", "description": "Maximum results", "default": 5}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "web_fetch", "description": "Fetch web page content.", "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "URL to fetch"}, "max_length": {"type": "integer", "description": "Maximum characters", "default": 5000}, }, "required": ["url"], }, }, }, { "type": "function", "function": { "name": "write_file", "description": "Write content to a file.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Path to the file"}, "content": {"type": "string", "description": "Content to write"}, }, "required": ["path", "content"], }, }, }, { "type": "function", "function": { "name": "run_command", "description": "Run a shell command.", "parameters": { "type": "object", "properties": { "command": {"type": "string", "description": "Command to run"}, "timeout": {"type": "integer", "description": "Timeout in seconds", "default": 30}, }, "required": ["command"], }, }, }, ] # Directories to skip during file traversal _SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".mypy_cache", ".pytest_cache"} # Tool set definitions TOOL_SETS = { "all": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"], "full": ["read_file", "list_files", "search_in_files", "web_search", "web_fetch", "write_file", "run_command"], "files": ["read_file", "list_files", "search_in_files", "write_file"], "readonly": ["read_file", "list_files", "search_in_files"], "web": ["web_search", "web_fetch"], "none": [], } def _truncate(text: str, max_len: int = 5000) -> str: if len(text) <= max_len: return text return text[:max_len] + f"\n... [truncated, {len(text)} chars total]" class ToolRegistry: """Manages tool schemas and execution for a given working directory.""" def __init__(self, workdir: str = ".") -> None: self._workdir = os.path.abspath(workdir) # ── Path resolution ── def _resolve_path(self, path: str) -> str: if os.path.isabs(path): return path return os.path.normpath(os.path.join(self._workdir, path)) # ── Schema / dispatch ── def get_tool_schemas(self, tool_set: str | list = "all") -> list[dict]: if isinstance(tool_set, list): names = tool_set else: names = TOOL_SETS.get(tool_set, TOOL_SETS["all"]) return [s for s in TOOL_SCHEMAS if s["function"]["name"] in names] def execute_tool(self, name: str, arguments: dict) -> str: method_map = { "read_file": self.tool_read_file, "list_files": self.tool_list_files, "search_in_files": self.tool_search_in_files, "web_search": self.tool_web_search, "web_fetch": self.tool_web_fetch, "write_file": self.tool_write_file, "run_command": self.tool_run_command, } func = method_map.get(name) if not func: return f"Unknown tool: {name}" try: return func(**arguments) except Exception as e: return f"Error executing {name}: {e}" # ── Tool implementations ── def tool_read_file(self, path: str, offset: int = 1, limit: int = 100) -> str: resolved = self._resolve_path(path) if not os.path.isfile(resolved): return f"Error: file not found — {resolved}" try: with open(resolved, encoding="utf-8", errors="replace") as f: lines = f.readlines() total = len(lines) selected = lines[offset - 1 : offset - 1 + limit] result = "".join(f"{i}|{line}" for i, line in enumerate(selected, start=offset)) return _truncate(f"[{resolved} — {total} lines]\n{result}") except Exception as e: return f"Error reading file: {e}" def tool_list_files(self, path: str = "", pattern: str = "", max_depth: int = 3) -> str: base = self._resolve_path(path or ".") if not os.path.isdir(base): return f"Error: directory not found — {base}" try: results: list[str] = [] base_depth = base.count(os.sep) for dirpath, dirnames, filenames in os.walk(base): # Prune skipped directories in-place so os.walk skips them dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] # Check depth current_depth = dirpath.count(os.sep) - base_depth if current_depth >= max_depth: # Don't descend further, but still list this dir's files dirnames.clear() # Add directory itself rel_dir = os.path.relpath(dirpath, base) if rel_dir == ".": results.append(dirpath) else: results.append(dirpath) # Add files for fname in filenames: if pattern and not fnmatch.fnmatch(fname, pattern): continue results.append(os.path.join(dirpath, fname)) results.sort() return _truncate("\n".join(results)) except Exception as e: return f"Error: {e}" def tool_search_in_files(self, query: str, path: str = "", file_pattern: str = "") -> str: base = self._resolve_path(path or ".") if not os.path.isdir(base): return f"Error: directory not found — {base}" try: regex = re.compile(query, re.IGNORECASE) except re.error as e: return f"Error: invalid regex — {e}" try: results: list[str] = [] for dirpath, dirnames, filenames in os.walk(base): dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] for fname in filenames: if file_pattern and not fnmatch.fnmatch(fname, file_pattern): continue fpath = os.path.join(dirpath, fname) try: with open(fpath, encoding="utf-8", errors="replace") as f: for line_num, line in enumerate(f, start=1): if regex.search(line): results.append(f"{fpath}:{line_num}:{line.rstrip()}") if len(results) >= 200: return _truncate("\n".join(results)) except (OSError, UnicodeDecodeError): continue if not results: return "No matches found." return _truncate("\n".join(results)) except Exception as e: return f"Error: {e}" def tool_web_search(self, query: str, max_results: int = 5) -> str: try: client = httpx.Client(timeout=15, follow_redirects=True) resp = client.get( "https://html.duckduckgo.com/html/", params={"q": query}, headers={"User-Agent": "Mozilla/5.0"}, ) resp.raise_for_status() results = [] for match in re.finditer( r'\s*(.*?)\s*.*?' r'(.*?)', resp.text, re.DOTALL, ): url = match.group(1) title = re.sub(r'<[^>]+>', '', match.group(2)).strip() snippet = re.sub(r'<[^>]+>', '', match.group(3)).strip() results.append(f"{title}\n {url}\n {snippet}") if len(results) >= max_results: break if not results: return "No results found." return _truncate("\n\n".join(f"[{i+1}] {r}" for i, r in enumerate(results))) except Exception as e: return f"Search error: {e}" def tool_web_fetch(self, url: str, max_length: int = 5000) -> str: try: client = httpx.Client(timeout=20, follow_redirects=True) resp = client.get(url, headers={"User-Agent": "Mozilla/5.0"}) resp.raise_for_status() content_type = resp.headers.get("content-type", "") if "html" in content_type: text = re.sub(r']*>.*?', '', resp.text, flags=re.DOTALL) text = re.sub(r']*>.*?', '', text, flags=re.DOTALL) text = re.sub(r'<[^>]+>', ' ', text) text = re.sub(r'\s+', ' ', text).strip() else: text = resp.text return _truncate(f"[{url} — {len(resp.text)} chars]\n{text}", max_length) except Exception as e: return f"Fetch error: {e}" def tool_write_file(self, path: str, content: str) -> str: resolved = self._resolve_path(path) try: os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True) with open(resolved, "w", encoding="utf-8") as f: f.write(content) return f"Wrote {len(content)} chars to {resolved}" except Exception as e: return f"Write error: {e}" def tool_run_command(self, command: str, timeout: int = 30) -> str: try: result = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=timeout, cwd=self._workdir, ) output = result.stdout if result.stderr: output += f"\nSTDERR:\n{result.stderr}" if result.returncode != 0: output += f"\n[exit code: {result.returncode}]" return _truncate(output) except subprocess.TimeoutExpired: return f"Timeout ({timeout}s)" except Exception as e: return f"Error: {e}" # ─── Module-level backward-compatible wrappers ─── _default_registry: ToolRegistry | None = None def set_workdir(path: str) -> None: """Set the working directory for module-level tool functions.""" global _default_registry _default_registry = ToolRegistry(workdir=path) def _get_registry() -> ToolRegistry: global _default_registry if _default_registry is None: _default_registry = ToolRegistry() return _default_registry def get_tool_schemas(tool_set: str | list = "all") -> list[dict]: """Return tool schemas for the given set name or explicit list.""" return _get_registry().get_tool_schemas(tool_set) def execute_tool(name: str, arguments: dict) -> str: """Execute a tool by name with the given arguments.""" return _get_registry().execute_tool(name, arguments) # Legacy dict-based dispatch (kept for any code that imports TOOL_FUNCTIONS) TOOL_FUNCTIONS = { "read_file": lambda **kw: _get_registry().tool_read_file(**kw), "list_files": lambda **kw: _get_registry().tool_list_files(**kw), "search_in_files": lambda **kw: _get_registry().tool_search_in_files(**kw), "web_search": lambda **kw: _get_registry().tool_web_search(**kw), "web_fetch": lambda **kw: _get_registry().tool_web_fetch(**kw), "write_file": lambda **kw: _get_registry().tool_write_file(**kw), "run_command": lambda **kw: _get_registry().tool_run_command(**kw), }