""" 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 os import re import subprocess import tempfile from pathlib import Path from typing import Any MAX_OUTPUT_CHARS = 25_000 MAX_LINE_LENGTH = 4000 DEFAULT_READ_LINES = 2000 DEFAULT_TIMEOUT = 120 MAX_TIMEOUT = 36000 # 10 hours — needed for long training runs (e.g. PostTrainBench) _ANSI_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07') # Track files that have been read this session (enforces read-before-write/edit) _files_read: set[str] = set() def _resolve_path(path: str) -> str: try: return str(Path(path).resolve()) except Exception: return path def _atomic_write(path: Path, content: str) -> None: """Write file atomically via temp file + os.replace(). Ensures the file is never left in a partial/corrupted state — it's either the old content or the new content, never half-written. """ path.parent.mkdir(parents=True, exist_ok=True) fd = None tmp_path = None try: fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") os.write(fd, content.encode("utf-8")) os.fsync(fd) os.close(fd) fd = None os.replace(tmp_path, str(path)) tmp_path = None # successfully replaced, nothing to clean up finally: if fd is not None: os.close(fd) if tmp_path is not None: try: os.unlink(tmp_path) except OSError: pass def _strip_ansi(text: str) -> str: return _ANSI_RE.sub('', text) def _truncate_output(output: str, max_chars: int = MAX_OUTPUT_CHARS, head_ratio: float = 0.25) -> str: """Tail-biased truncation with temp file spillover for full output access.""" if len(output) <= max_chars: return output # Write full output to temp file so LLM can read specific sections spill_path = None try: with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', prefix='bash_output_', delete=False) as f: f.write(output) spill_path = f.name except Exception: pass head_budget = int(max_chars * head_ratio) tail_budget = max_chars - head_budget head = output[:head_budget] tail = output[-tail_budget:] total = len(output) omitted = total - max_chars meta = f"\n\n... ({omitted:,} of {total:,} chars omitted, showing first {head_budget:,} + last {tail_budget:,}) ...\n" if spill_path: meta += f"Full output saved to {spill_path} — use the read tool with offset/limit to inspect specific sections.\n" meta += "IMPORTANT: The command has finished. Analyze the output above and continue with your next action.\n" return head + meta + tail # ── 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 = _strip_ansi(result.stdout + result.stderr) output = _truncate_output(output) if not output.strip(): output = "(no output)" return output, result.returncode == 0 except subprocess.TimeoutExpired: return ( f"Command timed out after {timeout}s and was killed.\n\n" f"For long-running commands, run in the background and poll:\n" f" nohup > /tmp/output.log 2>&1 & echo $!\n" f"Then check status with:\n" f" kill -0 2>/dev/null && echo 'running' || echo 'done'\n" f" tail -n 50 /tmp/output.log" ), 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: raw_content = p.read_text() except Exception as e: return f"read error: {e}", False _files_read.add(_resolve_path(file_path)) lines = raw_content.splitlines() 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) if p.exists() and _resolve_path(file_path) not in _files_read: return ( f"You must read {file_path} before overwriting it. " f"Use the read tool first to see current contents." ), False try: _atomic_write(p, content) _files_read.add(_resolve_path(file_path)) msg = f"Wrote {len(content)} bytes to {file_path}" # Syntax validation for Python files if p.suffix == ".py": from agent.tools.edit_utils import validate_python warnings = validate_python(content, file_path) if warnings: msg += "\n\nValidation warnings:\n" + "\n".join(f" ⚠ {w}" for w in warnings) return msg, True except Exception as e: return f"write error: {e}", False async def _edit_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]: from agent.tools.edit_utils import apply_edit, validate_python file_path = args.get("path", "") old_str = args.get("old_str", "") new_str = args.get("new_str", "") replace_all = args.get("replace_all", False) mode = args.get("mode", "replace") 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 if _resolve_path(file_path) not in _files_read: return ( f"You must read {file_path} before editing it. " f"Use the read tool first to see current contents." ), False try: text = p.read_text() except Exception as e: return f"edit read error: {e}", False try: new_text, replacements, fuzzy_note = apply_edit( text, old_str, new_str, mode=mode, replace_all=replace_all ) except ValueError as e: return str(e), False try: _atomic_write(p, new_text) except Exception as e: return f"edit write error: {e}", False msg = f"Edited {file_path} ({replacements} replacement{'s' if replacements > 1 else ''})" if fuzzy_note: msg += f" {fuzzy_note}" # Syntax validation for Python files if p.suffix == ".py": warnings = validate_python(new_text, file_path) if warnings: msg += "\n\nValidation warnings:\n" + "\n".join(f" ⚠ {w}" for w in warnings) return msg, True # ── Local tool specs (override sandbox /app references) ──────────────── _LOCAL_TOOL_SPECS = { "bash": { "description": ( "Run a shell command on the local machine and return stdout/stderr.\n" "\n" "IMPORTANT: Do NOT use bash for file operations — use the dedicated tools instead:\n" "- To read files: use read (not cat/head/tail)\n" "- To edit files: use edit (not sed/awk)\n" "- To write files: use write (not echo/cat < > /tmp/output.log 2>&1 & echo $!\n" "Then check status:\n" " kill -0 2>/dev/null && echo 'running' || echo 'done'\n" " tail -n 50 /tmp/output.log\n" "\n" "Timeout default 120s, max 36000s." ), "parameters": { "type": "object", "required": ["command"], "additionalProperties": False, "properties": { "command": { "type": "string", "description": "The shell command to execute.", }, "description": { "type": "string", "description": "Short description (5-10 words, active voice).", }, "work_dir": { "type": "string", "description": "Working directory (default: current directory).", }, "timeout": { "type": "integer", "description": "Optional timeout in seconds (default: 120, max: 36000).", }, }, }, }, "read": { "description": ( "Reads a file from the local filesystem. Returns contents with line numbers " "(cat -n format).\n" "\n" "Usage:\n" "- By default, reads up to 2000 lines from the beginning of the file.\n" "- You can optionally specify offset and limit for large files, but prefer " "reading the whole file first.\n" "- Lines longer than 4000 chars are truncated.\n" "- Cannot read directories — use bash with 'ls' instead.\n" "- You should read multiple potentially useful files in parallel when possible.\n" "- IMPORTANT: Always read a file before editing or overwriting it. The edit and " "write tools will reject operations on files you haven't read." ), "parameters": { "type": "object", "required": ["path"], "additionalProperties": False, "properties": { "path": { "type": "string", "description": "Absolute path to the file to read.", }, "offset": { "type": "integer", "description": "The line number to start reading from (1-based). Only provide if the file is too large to read at once.", }, "limit": { "type": "integer", "description": "The number of lines to read. Only provide if the file is too large to read at once.", }, }, }, }, "write": { "description": ( "Writes a file to the local filesystem. Overwrites the existing file if one " "exists at the path.\n" "\n" "- If this is an existing file, you MUST use the read tool first. This tool " "will fail if you did not read the file first.\n" "- ALWAYS prefer editing existing files with the edit tool over overwriting " "with write.\n" "- Creates parent directories as needed." ), "parameters": { "type": "object", "required": ["path", "content"], "additionalProperties": False, "properties": { "path": { "type": "string", "description": "Absolute path to the file to write.", }, "content": { "type": "string", "description": "The complete file content to write.", }, }, }, }, "edit": { "description": ( "Performs string replacements in files. Supports exact matching with " "fuzzy fallback.\n" "\n" "Usage:\n" "- You must read the file at least once before editing. This tool will " "error if you attempt an edit without reading the file.\n" "- The edit will FAIL if old_str is not unique in the file. Either provide " "a larger string with more surrounding context to make it unique, or set " "replace_all to true.\n" "- old_str and new_str must differ.\n" "- Preserve indentation exactly as it appears in the file.\n" "- Do NOT include line number prefixes from read output in old_str or new_str.\n" "- To delete code, set new_str to empty string.\n" "- Use replace_all for renaming variables or strings across the file.\n" "\n" "Modes:\n" "- replace (default): replace first occurrence of old_str with new_str.\n" "- append_after: insert new_str immediately after old_str (old_str is kept).\n" "- prepend_before: insert new_str immediately before old_str (old_str is kept)." ), "parameters": { "type": "object", "required": ["path", "old_str", "new_str"], "additionalProperties": False, "properties": { "path": { "type": "string", "description": "Absolute path to the file to edit.", }, "old_str": { "type": "string", "description": "The text to find in the file. Must match exactly (fuzzy matching is used as fallback).", }, "new_str": { "type": "string", "description": "The replacement text. For append_after/prepend_before modes, the text to insert.", }, "replace_all": { "type": "boolean", "description": "Replace all occurrences of old_str (default: false).", "default": False, }, "mode": { "type": "string", "enum": ["replace", "append_after", "prepend_before"], "description": "Edit mode (default: replace).", "default": "replace", }, }, }, }, } _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 _LOCAL_TOOL_SPECS.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