@@ -1,21 +1,413 @@ |
| 1 | -"""Shell command execution tools.""" | 1 | +"""Shell command execution tools with stateful job control.""" |
| | 2 | + |
| | 3 | +from __future__ import annotations |
| 2 | | 4 | |
| 3 | import asyncio | 5 | import asyncio |
| 4 | -import shlex | 6 | +import contextlib |
| | 7 | +from dataclasses import dataclass, field |
| | 8 | +from datetime import UTC, datetime |
| | 9 | +import os |
| 5 | from pathlib import Path | 10 | from pathlib import Path |
| | 11 | +import shlex |
| | 12 | +import signal |
| | 13 | +import subprocess |
| 6 | from typing import Any | 14 | from typing import Any |
| 7 | | 15 | |
| 8 | from ..runtime.permissions import PermissionMode | 16 | from ..runtime.permissions import PermissionMode |
| 9 | from .base import ConfirmationRequired, Tool, ToolResult | 17 | from .base import ConfirmationRequired, Tool, ToolResult |
| 10 | | 18 | |
| 11 | | 19 | |
| | 20 | +def _utc_now() -> str: |
| | 21 | + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") |
| | 22 | + |
| | 23 | + |
| | 24 | +@dataclass(slots=True) |
| | 25 | +class _OutputBuffer: |
| | 26 | + """Bounded text buffer for process output.""" |
| | 27 | + |
| | 28 | + limit: int |
| | 29 | + chunks: list[str] = field(default_factory=list) |
| | 30 | + size: int = 0 |
| | 31 | + truncated: bool = False |
| | 32 | + |
| | 33 | + def append(self, data: bytes) -> None: |
| | 34 | + text = data.decode("utf-8", errors="replace") |
| | 35 | + if not text: |
| | 36 | + return |
| | 37 | + remaining = max(self.limit - self.size, 0) |
| | 38 | + if remaining <= 0: |
| | 39 | + self.truncated = True |
| | 40 | + return |
| | 41 | + kept = text[:remaining] |
| | 42 | + if kept: |
| | 43 | + self.chunks.append(kept) |
| | 44 | + self.size += len(kept) |
| | 45 | + if len(text) > remaining: |
| | 46 | + self.truncated = True |
| | 47 | + |
| | 48 | + def text(self) -> str: |
| | 49 | + return "".join(self.chunks) |
| | 50 | + |
| | 51 | + |
| | 52 | +@dataclass(slots=True) |
| | 53 | +class BashJob: |
| | 54 | + """One tracked bash subprocess.""" |
| | 55 | + |
| | 56 | + job_id: str |
| | 57 | + command: str |
| | 58 | + cwd: str | None |
| | 59 | + background: bool |
| | 60 | + timeout: float | None |
| | 61 | + mutability: str |
| | 62 | + started_at: str |
| | 63 | + pid: int |
| | 64 | + process: asyncio.subprocess.Process |
| | 65 | + stdout_buffer: _OutputBuffer |
| | 66 | + stderr_buffer: _OutputBuffer |
| | 67 | + status: str = "running" |
| | 68 | + exit_code: int | None = None |
| | 69 | + finished_at: str | None = None |
| | 70 | + timed_out: bool = False |
| | 71 | + interrupted: bool = False |
| | 72 | + killed: bool = False |
| | 73 | + stdout_task: asyncio.Task[None] | None = None |
| | 74 | + stderr_task: asyncio.Task[None] | None = None |
| | 75 | + completion_task: asyncio.Task[None] | None = None |
| | 76 | + |
| | 77 | + @property |
| | 78 | + def is_running(self) -> bool: |
| | 79 | + return self.process.returncode is None |
| | 80 | + |
| | 81 | + |
| | 82 | +class BashJobManager: |
| | 83 | + """Track bash subprocesses for the lifetime of one Loader runtime.""" |
| | 84 | + |
| | 85 | + def __init__(self, *, output_limit: int = 50_000, recent_limit: int = 25) -> None: |
| | 86 | + self.output_limit = output_limit |
| | 87 | + self.recent_limit = recent_limit |
| | 88 | + self._jobs: dict[str, BashJob] = {} |
| | 89 | + self._job_order: list[str] = [] |
| | 90 | + self._counter = 0 |
| | 91 | + self._active_foreground_job_id: str | None = None |
| | 92 | + |
| | 93 | + @property |
| | 94 | + def active_foreground_job_id(self) -> str | None: |
| | 95 | + return self._active_foreground_job_id |
| | 96 | + |
| | 97 | + def list_jobs(self, *, limit: int | None = None) -> list[BashJob]: |
| | 98 | + selected: list[BashJob] = [] |
| | 99 | + for job_id in reversed(self._job_order): |
| | 100 | + job = self._jobs[job_id] |
| | 101 | + selected.append(job) |
| | 102 | + if limit is not None and len(selected) >= limit: |
| | 103 | + break |
| | 104 | + return selected |
| | 105 | + |
| | 106 | + def get_job(self, job_id: str) -> BashJob | None: |
| | 107 | + return self._jobs.get(job_id) |
| | 108 | + |
| | 109 | + async def start( |
| | 110 | + self, |
| | 111 | + *, |
| | 112 | + command: str, |
| | 113 | + cwd: str | None, |
| | 114 | + timeout: float | None, |
| | 115 | + background: bool, |
| | 116 | + mutability: str, |
| | 117 | + ) -> BashJob: |
| | 118 | + resolved_cwd = str(Path(cwd).expanduser().resolve()) if cwd else None |
| | 119 | + self._counter += 1 |
| | 120 | + job_id = f"bash-{self._counter}" |
| | 121 | + |
| | 122 | + popen_kwargs: dict[str, Any] = { |
| | 123 | + "stdout": asyncio.subprocess.PIPE, |
| | 124 | + "stderr": asyncio.subprocess.PIPE, |
| | 125 | + "cwd": resolved_cwd, |
| | 126 | + } |
| | 127 | + if os.name == "nt": |
| | 128 | + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP |
| | 129 | + else: |
| | 130 | + popen_kwargs["start_new_session"] = True |
| | 131 | + |
| | 132 | + process = await asyncio.create_subprocess_shell(command, **popen_kwargs) |
| | 133 | + job = BashJob( |
| | 134 | + job_id=job_id, |
| | 135 | + command=command, |
| | 136 | + cwd=resolved_cwd, |
| | 137 | + background=background, |
| | 138 | + timeout=timeout, |
| | 139 | + mutability=mutability, |
| | 140 | + started_at=_utc_now(), |
| | 141 | + pid=process.pid, |
| | 142 | + process=process, |
| | 143 | + stdout_buffer=_OutputBuffer(self.output_limit), |
| | 144 | + stderr_buffer=_OutputBuffer(self.output_limit), |
| | 145 | + ) |
| | 146 | + job.stdout_task = asyncio.create_task(self._read_stream(job.stdout_buffer, process.stdout)) |
| | 147 | + job.stderr_task = asyncio.create_task(self._read_stream(job.stderr_buffer, process.stderr)) |
| | 148 | + job.completion_task = asyncio.create_task(self._monitor_job(job)) |
| | 149 | + |
| | 150 | + self._jobs[job_id] = job |
| | 151 | + self._job_order.append(job_id) |
| | 152 | + self._trim_completed_jobs() |
| | 153 | + if not background: |
| | 154 | + self._active_foreground_job_id = job_id |
| | 155 | + return job |
| | 156 | + |
| | 157 | + async def wait_for_job( |
| | 158 | + self, |
| | 159 | + job_id: str, |
| | 160 | + *, |
| | 161 | + timeout: float | None = None, |
| | 162 | + ) -> ToolResult: |
| | 163 | + job = self._jobs.get(job_id) |
| | 164 | + if job is None: |
| | 165 | + return ToolResult(f"Unknown bash job: {job_id}", is_error=True) |
| | 166 | + if job.completion_task is None: |
| | 167 | + return ToolResult(f"Bash job {job_id} has no completion task", is_error=True) |
| | 168 | + |
| | 169 | + if job.is_running and timeout is not None: |
| | 170 | + try: |
| | 171 | + await asyncio.wait_for(asyncio.shield(job.completion_task), timeout) |
| | 172 | + except TimeoutError: |
| | 173 | + return ToolResult( |
| | 174 | + f"Wait timed out after {timeout}s; bash job {job_id} is still running.", |
| | 175 | + is_error=True, |
| | 176 | + metadata=self.metadata_for(job), |
| | 177 | + ) |
| | 178 | + else: |
| | 179 | + await asyncio.shield(job.completion_task) |
| | 180 | + return self.tool_result_for(job) |
| | 181 | + |
| | 182 | + async def kill_job( |
| | 183 | + self, |
| | 184 | + job_id: str, |
| | 185 | + *, |
| | 186 | + force_after_ms: int = 1_000, |
| | 187 | + interrupted: bool = False, |
| | 188 | + ) -> ToolResult: |
| | 189 | + job = self._jobs.get(job_id) |
| | 190 | + if job is None: |
| | 191 | + return ToolResult(f"Unknown bash job: {job_id}", is_error=True) |
| | 192 | + if not job.is_running: |
| | 193 | + return ToolResult( |
| | 194 | + f"Bash job {job_id} is already {job.status}.", |
| | 195 | + metadata=self.metadata_for(job), |
| | 196 | + ) |
| | 197 | + |
| | 198 | + await self._terminate_job(job, force_after_ms=force_after_ms, interrupted=interrupted) |
| | 199 | + if job.completion_task is not None: |
| | 200 | + await asyncio.shield(job.completion_task) |
| | 201 | + status = "Interrupted" if interrupted else "Stopped" |
| | 202 | + summary = f"{status} bash job {job.job_id} (pid {job.pid})." |
| | 203 | + output = self._render_combined_output(job) |
| | 204 | + if output != "(no output)": |
| | 205 | + summary += f"\n\n{output}" |
| | 206 | + return ToolResult(summary, metadata=self.metadata_for(job)) |
| | 207 | + |
| | 208 | + async def interrupt_active_foreground(self) -> ToolResult | None: |
| | 209 | + if self._active_foreground_job_id is None: |
| | 210 | + return None |
| | 211 | + job = self._jobs.get(self._active_foreground_job_id) |
| | 212 | + if job is None or not job.is_running: |
| | 213 | + return None |
| | 214 | + return await self.kill_job(job.job_id, interrupted=True) |
| | 215 | + |
| | 216 | + def terminate_all_now(self) -> list[str]: |
| | 217 | + killed: list[str] = [] |
| | 218 | + for job in self._jobs.values(): |
| | 219 | + if not job.is_running: |
| | 220 | + continue |
| | 221 | + job.killed = True |
| | 222 | + job.interrupted = True |
| | 223 | + self._send_signal(job.process, signal.SIGTERM) |
| | 224 | + self._send_signal(job.process, signal.SIGKILL) |
| | 225 | + killed.append(job.job_id) |
| | 226 | + self._active_foreground_job_id = None |
| | 227 | + return killed |
| | 228 | + |
| | 229 | + def render_jobs(self, *, limit: int = 20) -> tuple[str, dict[str, Any]]: |
| | 230 | + jobs = self.list_jobs(limit=limit) |
| | 231 | + if not jobs: |
| | 232 | + return "No bash jobs tracked for this Loader session.", {"jobs": []} |
| | 233 | + |
| | 234 | + lines = ["Bash jobs:"] |
| | 235 | + for job in jobs: |
| | 236 | + marker = "bg" if job.background else "fg" |
| | 237 | + lines.append( |
| | 238 | + f"- {job.job_id} [{job.status}] ({marker}, pid={job.pid}) {job.command}" |
| | 239 | + ) |
| | 240 | + return "\n".join(lines), { |
| | 241 | + "jobs": [self.metadata_for(job) for job in jobs], |
| | 242 | + "active_foreground_job_id": self._active_foreground_job_id, |
| | 243 | + } |
| | 244 | + |
| | 245 | + def metadata_for(self, job: BashJob) -> dict[str, Any]: |
| | 246 | + return { |
| | 247 | + "job_id": job.job_id, |
| | 248 | + "pid": job.pid, |
| | 249 | + "command": job.command, |
| | 250 | + "cwd": job.cwd, |
| | 251 | + "background": job.background, |
| | 252 | + "status": job.status, |
| | 253 | + "started_at": job.started_at, |
| | 254 | + "finished_at": job.finished_at, |
| | 255 | + "exit_code": job.exit_code, |
| | 256 | + "stdout": job.stdout_buffer.text(), |
| | 257 | + "stderr": job.stderr_buffer.text(), |
| | 258 | + "stdout_truncated": job.stdout_buffer.truncated, |
| | 259 | + "stderr_truncated": job.stderr_buffer.truncated, |
| | 260 | + "truncated": job.stdout_buffer.truncated or job.stderr_buffer.truncated, |
| | 261 | + "timed_out": job.timed_out, |
| | 262 | + "interrupted": job.interrupted, |
| | 263 | + "killed": job.killed, |
| | 264 | + "running": job.is_running, |
| | 265 | + "output_limit": self.output_limit, |
| | 266 | + } |
| | 267 | + |
| | 268 | + def tool_result_for(self, job: BashJob) -> ToolResult: |
| | 269 | + output = self._render_combined_output(job) |
| | 270 | + metadata = self.metadata_for(job) |
| | 271 | + if job.timed_out: |
| | 272 | + return ToolResult( |
| | 273 | + f"Command timed out after {job.timeout}s\n{output}", |
| | 274 | + is_error=True, |
| | 275 | + metadata=metadata, |
| | 276 | + ) |
| | 277 | + if job.interrupted or job.killed: |
| | 278 | + return ToolResult( |
| | 279 | + f"Command interrupted\n{output}", |
| | 280 | + is_error=True, |
| | 281 | + metadata=metadata, |
| | 282 | + ) |
| | 283 | + if (job.exit_code or 0) != 0: |
| | 284 | + return ToolResult( |
| | 285 | + f"Exit code {job.exit_code}\n{output}", |
| | 286 | + is_error=True, |
| | 287 | + metadata=metadata, |
| | 288 | + ) |
| | 289 | + return ToolResult(output, metadata=metadata) |
| | 290 | + |
| | 291 | + def launch_result_for(self, job: BashJob) -> ToolResult: |
| | 292 | + output = ( |
| | 293 | + f"Started background bash job {job.job_id} (pid {job.pid}).\n" |
| | 294 | + f"Use bash_wait(job_id=\"{job.job_id}\") to wait for completion or " |
| | 295 | + f"bash_kill(job_id=\"{job.job_id}\") to stop it." |
| | 296 | + ) |
| | 297 | + return ToolResult(output, metadata=self.metadata_for(job)) |
| | 298 | + |
| | 299 | + async def _read_stream( |
| | 300 | + self, |
| | 301 | + buffer: _OutputBuffer, |
| | 302 | + stream: asyncio.StreamReader | None, |
| | 303 | + ) -> None: |
| | 304 | + if stream is None: |
| | 305 | + return |
| | 306 | + while True: |
| | 307 | + chunk = await stream.read(4096) |
| | 308 | + if not chunk: |
| | 309 | + break |
| | 310 | + buffer.append(chunk) |
| | 311 | + |
| | 312 | + async def _monitor_job(self, job: BashJob) -> None: |
| | 313 | + try: |
| | 314 | + if job.timeout is None: |
| | 315 | + await job.process.wait() |
| | 316 | + else: |
| | 317 | + try: |
| | 318 | + await asyncio.wait_for(job.process.wait(), timeout=job.timeout) |
| | 319 | + except TimeoutError: |
| | 320 | + job.timed_out = True |
| | 321 | + await self._terminate_job( |
| | 322 | + job, |
| | 323 | + force_after_ms=500, |
| | 324 | + interrupted=True, |
| | 325 | + ) |
| | 326 | + await job.process.wait() |
| | 327 | + finally: |
| | 328 | + await asyncio.gather( |
| | 329 | + job.stdout_task or asyncio.sleep(0), |
| | 330 | + job.stderr_task or asyncio.sleep(0), |
| | 331 | + ) |
| | 332 | + job.exit_code = job.process.returncode |
| | 333 | + job.finished_at = _utc_now() |
| | 334 | + if job.timed_out: |
| | 335 | + job.status = "timed_out" |
| | 336 | + elif (job.interrupted or job.killed) and job.background: |
| | 337 | + job.status = "killed" |
| | 338 | + elif job.interrupted: |
| | 339 | + job.status = "interrupted" |
| | 340 | + elif (job.exit_code or 0) == 0: |
| | 341 | + job.status = "completed" |
| | 342 | + else: |
| | 343 | + job.status = "failed" |
| | 344 | + if self._active_foreground_job_id == job.job_id: |
| | 345 | + self._active_foreground_job_id = None |
| | 346 | + |
| | 347 | + async def _terminate_job( |
| | 348 | + self, |
| | 349 | + job: BashJob, |
| | 350 | + *, |
| | 351 | + force_after_ms: int, |
| | 352 | + interrupted: bool, |
| | 353 | + ) -> None: |
| | 354 | + if not job.is_running: |
| | 355 | + return |
| | 356 | + job.interrupted = interrupted |
| | 357 | + job.killed = not interrupted |
| | 358 | + self._send_signal(job.process, signal.SIGTERM) |
| | 359 | + try: |
| | 360 | + await asyncio.wait_for(job.process.wait(), timeout=force_after_ms / 1000) |
| | 361 | + except TimeoutError: |
| | 362 | + self._send_signal(job.process, signal.SIGKILL) |
| | 363 | + |
| | 364 | + def _send_signal( |
| | 365 | + self, |
| | 366 | + process: asyncio.subprocess.Process, |
| | 367 | + sig: int, |
| | 368 | + ) -> None: |
| | 369 | + if process.returncode is not None: |
| | 370 | + return |
| | 371 | + with contextlib.suppress(ProcessLookupError): |
| | 372 | + if os.name == "nt": |
| | 373 | + process.send_signal(sig) |
| | 374 | + else: |
| | 375 | + os.killpg(process.pid, sig) |
| | 376 | + |
| | 377 | + def _render_combined_output(self, job: BashJob) -> str: |
| | 378 | + parts: list[str] = [] |
| | 379 | + stdout = job.stdout_buffer.text() |
| | 380 | + stderr = job.stderr_buffer.text() |
| | 381 | + if stdout: |
| | 382 | + parts.append(stdout) |
| | 383 | + if stderr.strip(): |
| | 384 | + parts.append(f"[stderr]\n{stderr}") |
| | 385 | + output = "\n".join(parts) if parts else "(no output)" |
| | 386 | + if job.stdout_buffer.truncated or job.stderr_buffer.truncated: |
| | 387 | + output += "\n\n... (output truncated)" |
| | 388 | + return output |
| | 389 | + |
| | 390 | + def _trim_completed_jobs(self) -> None: |
| | 391 | + if len(self._job_order) <= self.recent_limit: |
| | 392 | + return |
| | 393 | + overflow = len(self._job_order) - self.recent_limit |
| | 394 | + for job_id in list(self._job_order): |
| | 395 | + if overflow <= 0: |
| | 396 | + break |
| | 397 | + job = self._jobs[job_id] |
| | 398 | + if job.is_running: |
| | 399 | + continue |
| | 400 | + self._job_order.remove(job_id) |
| | 401 | + self._jobs.pop(job_id, None) |
| | 402 | + overflow -= 1 |
| | 403 | + |
| | 404 | + |
| 12 | class BashTool(Tool): | 405 | class BashTool(Tool): |
| 13 | - """Execute bash commands.""" | 406 | + """Execute bash commands and manage their subprocess lifecycle.""" |
| 14 | | 407 | |
| 15 | required_permission = PermissionMode.DANGER_FULL_ACCESS | 408 | required_permission = PermissionMode.DANGER_FULL_ACCESS |
| 16 | OUTPUT_LIMIT = 50_000 | 409 | OUTPUT_LIMIT = 50_000 |
| 17 | | 410 | |
| 18 | - # Commands that are generally safe (read-only operations) | | |
| 19 | SAFE_COMMANDS = { | 411 | SAFE_COMMANDS = { |
| 20 | "ls", "cat", "head", "tail", "grep", "find", "pwd", "whoami", "date", | 412 | "ls", "cat", "head", "tail", "grep", "find", "pwd", "whoami", "date", |
| 21 | "wc", "sort", "uniq", "diff", "file", "stat", "du", "df", | 413 | "wc", "sort", "uniq", "diff", "file", "stat", "du", "df", |
@@ -24,9 +416,32 @@ class BashTool(Tool): |
| 24 | "uv --version", "pip list", "pip show", | 416 | "uv --version", "pip list", "pip show", |
| 25 | } | 417 | } |
| 26 | | 418 | |
| 27 | - def __init__(self, timeout: float = 120.0, allowed_commands: list[str] | None = None): | 419 | + LONG_RUNNING_HINTS = ( |
| | 420 | + "python -m http.server", |
| | 421 | + "python3 -m http.server", |
| | 422 | + "npm run dev", |
| | 423 | + "npm run start", |
| | 424 | + "npm run serve", |
| | 425 | + "npm run watch", |
| | 426 | + "pnpm dev", |
| | 427 | + "yarn dev", |
| | 428 | + "bun run dev", |
| | 429 | + "vite", |
| | 430 | + "next dev", |
| | 431 | + "tail -f", |
| | 432 | + "watch ", |
| | 433 | + "uvicorn --reload", |
| | 434 | + ) |
| | 435 | + |
| | 436 | + def __init__( |
| | 437 | + self, |
| | 438 | + timeout: float = 120.0, |
| | 439 | + allowed_commands: list[str] | None = None, |
| | 440 | + manager: BashJobManager | None = None, |
| | 441 | + ) -> None: |
| 28 | self.timeout = timeout | 442 | self.timeout = timeout |
| 29 | - self.allowed_commands = allowed_commands # None means all allowed | 443 | + self.allowed_commands = allowed_commands |
| | 444 | + self.manager = manager or BashJobManager(output_limit=self.OUTPUT_LIMIT) |
| 30 | | 445 | |
| 31 | @property | 446 | @property |
| 32 | def name(self) -> str: | 447 | def name(self) -> str: |
@@ -34,7 +449,11 @@ class BashTool(Tool): |
| 34 | | 449 | |
| 35 | @property | 450 | @property |
| 36 | def description(self) -> str: | 451 | def description(self) -> str: |
| 37 | - return "Execute a bash command and return the output. Use for git, npm, build tools, etc." | 452 | + return ( |
| | 453 | + "Execute a bash command and return the output. For servers, watchers, or " |
| | 454 | + "other long-running processes, set background=true and inspect them later " |
| | 455 | + "with bash_wait or bash_jobs." |
| | 456 | + ) |
| 38 | | 457 | |
| 39 | @property | 458 | @property |
| 40 | def parameters(self) -> dict[str, Any]: | 459 | def parameters(self) -> dict[str, Any]: |
@@ -54,6 +473,11 @@ class BashTool(Tool): |
| 54 | "description": f"Timeout in seconds (default: {self.timeout})", | 473 | "description": f"Timeout in seconds (default: {self.timeout})", |
| 55 | "default": self.timeout, | 474 | "default": self.timeout, |
| 56 | }, | 475 | }, |
| | 476 | + "background": { |
| | 477 | + "type": "boolean", |
| | 478 | + "description": "Run the command in the background and return immediately", |
| | 479 | + "default": False, |
| | 480 | + }, |
| 57 | }, | 481 | }, |
| 58 | "required": ["command"], | 482 | "required": ["command"], |
| 59 | } | 483 | } |
@@ -63,21 +487,17 @@ class BashTool(Tool): |
| 63 | return True | 487 | return True |
| 64 | | 488 | |
| 65 | def _is_safe_command(self, command: str) -> bool: | 489 | def _is_safe_command(self, command: str) -> bool: |
| 66 | - """Check if command is a known safe (read-only) command.""" | | |
| 67 | cmd = command.strip().lower() | 490 | cmd = command.strip().lower() |
| 68 | - # Check exact matches and prefix matches | | |
| 69 | for safe in self.SAFE_COMMANDS: | 491 | for safe in self.SAFE_COMMANDS: |
| 70 | if cmd == safe or cmd.startswith(safe + " "): | 492 | if cmd == safe or cmd.startswith(safe + " "): |
| 71 | return True | 493 | return True |
| 72 | return False | 494 | return False |
| 73 | | 495 | |
| 74 | def get_required_permission(self, **kwargs: Any) -> PermissionMode: | 496 | def get_required_permission(self, **kwargs: Any) -> PermissionMode: |
| 75 | - """Classify one shell invocation by its mutability.""" | | |
| 76 | command = str(kwargs.get("command", "")) | 497 | command = str(kwargs.get("command", "")) |
| 77 | return self.classify_command_permission(command) | 498 | return self.classify_command_permission(command) |
| 78 | | 499 | |
| 79 | def classify_command_permission(self, command: str) -> PermissionMode: | 500 | def classify_command_permission(self, command: str) -> PermissionMode: |
| 80 | - """Classify a shell command into a runtime permission mode.""" | | |
| 81 | normalized = command.strip().lower() | 501 | normalized = command.strip().lower() |
| 82 | if not normalized: | 502 | if not normalized: |
| 83 | return PermissionMode.DANGER_FULL_ACCESS | 503 | return PermissionMode.DANGER_FULL_ACCESS |
@@ -108,7 +528,6 @@ class BashTool(Tool): |
| 108 | if skip_confirmation: | 528 | if skip_confirmation: |
| 109 | return | 529 | return |
| 110 | command = kwargs.get("command", "") | 530 | command = kwargs.get("command", "") |
| 111 | - # Safe commands don't need confirmation | | |
| 112 | if self._is_safe_command(command): | 531 | if self._is_safe_command(command): |
| 113 | return | 532 | return |
| 114 | raise ConfirmationRequired( | 533 | raise ConfirmationRequired( |
@@ -118,102 +537,196 @@ class BashTool(Tool): |
| 118 | ) | 537 | ) |
| 119 | | 538 | |
| 120 | def _is_command_allowed(self, command: str) -> bool: | 539 | def _is_command_allowed(self, command: str) -> bool: |
| 121 | - """Check if command is in the allowed list.""" | | |
| 122 | if self.allowed_commands is None: | 540 | if self.allowed_commands is None: |
| 123 | return True | 541 | return True |
| 124 | - | | |
| 125 | - # Extract the base command | | |
| 126 | try: | 542 | try: |
| 127 | parts = shlex.split(command) | 543 | parts = shlex.split(command) |
| 128 | - if not parts: | | |
| 129 | - return False | | |
| 130 | - base_cmd = parts[0] | | |
| 131 | - return base_cmd in self.allowed_commands | | |
| 132 | except ValueError: | 544 | except ValueError: |
| 133 | return False | 545 | return False |
| | 546 | + if not parts: |
| | 547 | + return False |
| | 548 | + return parts[0] in self.allowed_commands |
| | 549 | + |
| | 550 | + def _looks_long_running(self, command: str) -> bool: |
| | 551 | + normalized = " ".join(command.lower().split()) |
| | 552 | + return any(hint in normalized for hint in self.LONG_RUNNING_HINTS) |
| 134 | | 553 | |
| 135 | async def execute( | 554 | async def execute( |
| 136 | self, | 555 | self, |
| 137 | command: str, | 556 | command: str, |
| 138 | cwd: str | None = None, | 557 | cwd: str | None = None, |
| 139 | timeout: float | None = None, | 558 | timeout: float | None = None, |
| | 559 | + background: bool = False, |
| 140 | **kwargs: Any, | 560 | **kwargs: Any, |
| 141 | ) -> ToolResult: | 561 | ) -> ToolResult: |
| | 562 | + del kwargs |
| 142 | if not self._is_command_allowed(command): | 563 | if not self._is_command_allowed(command): |
| 143 | return ToolResult( | 564 | return ToolResult( |
| 144 | f"Command not allowed. Allowed commands: {self.allowed_commands}", | 565 | f"Command not allowed. Allowed commands: {self.allowed_commands}", |
| 145 | is_error=True, | 566 | is_error=True, |
| 146 | ) | 567 | ) |
| 147 | | 568 | |
| 148 | - timeout = timeout or self.timeout | 569 | + effective_timeout = self.timeout if timeout is None else timeout |
| 149 | - resolved_cwd = None | 570 | + if not background and self._looks_long_running(command): |
| 150 | - if cwd: | 571 | + return ToolResult( |
| 151 | - resolved_cwd = str(Path(cwd).expanduser().resolve()) | 572 | + "This command looks long-running and would block Loader in the foreground. " |
| 152 | - | 573 | + "Re-run it with background=true, then use bash_wait or bash_jobs to inspect it.", |
| 153 | - try: | 574 | + is_error=True, |
| 154 | - process = await asyncio.create_subprocess_shell( | 575 | + metadata={ |
| 155 | - command, | 576 | + "command": command, |
| 156 | - stdout=asyncio.subprocess.PIPE, | 577 | + "cwd": str(Path(cwd).expanduser().resolve()) if cwd else None, |
| 157 | - stderr=asyncio.subprocess.PIPE, | 578 | + "background": background, |
| 158 | - cwd=resolved_cwd, | 579 | + "suggest_background": True, |
| | 580 | + "mutability": self.classify_command_permission(command).as_str(), |
| | 581 | + }, |
| 159 | ) | 582 | ) |
| 160 | | 583 | |
| 161 | try: | 584 | try: |
| 162 | - stdout, stderr = await asyncio.wait_for( | 585 | + job = await self.manager.start( |
| 163 | - process.communicate(), | 586 | + command=command, |
| 164 | - timeout=timeout, | 587 | + cwd=cwd, |
| | 588 | + timeout=effective_timeout, |
| | 589 | + background=background, |
| | 590 | + mutability=self.classify_command_permission(command).as_str(), |
| 165 | ) | 591 | ) |
| 166 | - except TimeoutError: | 592 | + if background: |
| 167 | - process.kill() | 593 | + return self.manager.launch_result_for(job) |
| 168 | - await process.wait() | 594 | + if job.completion_task is None: |
| | 595 | + return ToolResult("Bash job failed to start correctly", is_error=True) |
| | 596 | + try: |
| | 597 | + await asyncio.shield(job.completion_task) |
| | 598 | + except asyncio.CancelledError: |
| | 599 | + await self.manager.kill_job(job.job_id, interrupted=True) |
| | 600 | + raise |
| | 601 | + return self.manager.tool_result_for(job) |
| | 602 | + except asyncio.CancelledError: |
| | 603 | + raise |
| | 604 | + except Exception as exc: |
| | 605 | + resolved_cwd = str(Path(cwd).expanduser().resolve()) if cwd else None |
| 169 | return ToolResult( | 606 | return ToolResult( |
| 170 | - f"Command timed out after {timeout}s", | 607 | + f"Error executing command: {exc}", |
| 171 | is_error=True, | 608 | is_error=True, |
| | 609 | + metadata={"command": command, "cwd": resolved_cwd, "background": background}, |
| 172 | ) | 610 | ) |
| 173 | | 611 | |
| 174 | - output_parts = [] | | |
| 175 | | 612 | |
| 176 | - if stdout: | 613 | +class BashJobsTool(Tool): |
| 177 | - stdout_text = stdout.decode("utf-8", errors="replace") | 614 | + """List tracked bash jobs for the current Loader runtime.""" |
| 178 | - output_parts.append(stdout_text) | | |
| 179 | | 615 | |
| 180 | - if stderr: | 616 | + required_permission = PermissionMode.READ_ONLY |
| 181 | - stderr_text = stderr.decode("utf-8", errors="replace") | | |
| 182 | - if stderr_text.strip(): | | |
| 183 | - output_parts.append(f"[stderr]\n{stderr_text}") | | |
| 184 | | 617 | |
| 185 | - output = "\n".join(output_parts) if output_parts else "(no output)" | 618 | + def __init__(self, manager: BashJobManager) -> None: |
| | 619 | + self.manager = manager |
| 186 | | 620 | |
| 187 | - # Truncate if too long | 621 | + @property |
| 188 | - truncated = len(output) > self.OUTPUT_LIMIT | 622 | + def name(self) -> str: |
| 189 | - if truncated: | 623 | + return "bash_jobs" |
| 190 | - output = output[: self.OUTPUT_LIMIT] + "\n\n... (output truncated)" | | |
| 191 | - else: | | |
| 192 | - truncated = False | | |
| 193 | | 624 | |
| 194 | - metadata = { | 625 | + @property |
| 195 | - "command": command, | 626 | + def description(self) -> str: |
| 196 | - "cwd": resolved_cwd, | 627 | + return "List active and recent bash jobs for this Loader session." |
| 197 | - "exit_code": process.returncode, | | |
| 198 | - "stdout": stdout.decode("utf-8", errors="replace") if stdout else "", | | |
| 199 | - "stderr": stderr.decode("utf-8", errors="replace") if stderr else "", | | |
| 200 | - "mutability": self.classify_command_permission(command).as_str(), | | |
| 201 | - "truncated": truncated, | | |
| 202 | - "output_limit": self.OUTPUT_LIMIT, | | |
| 203 | - } | | |
| 204 | | 628 | |
| 205 | - if process.returncode != 0: | 629 | + @property |
| 206 | - return ToolResult( | 630 | + def parameters(self) -> dict[str, Any]: |
| 207 | - f"Exit code {process.returncode}\n{output}", | 631 | + return { |
| 208 | - is_error=True, | 632 | + "type": "object", |
| 209 | - metadata=metadata, | 633 | + "properties": { |
| 210 | - ) | 634 | + "limit": { |
| | 635 | + "type": "integer", |
| | 636 | + "description": "Maximum number of recent jobs to include", |
| | 637 | + "default": 20, |
| | 638 | + }, |
| | 639 | + }, |
| | 640 | + } |
| 211 | | 641 | |
| | 642 | + async def execute(self, limit: int = 20, **kwargs: Any) -> ToolResult: |
| | 643 | + del kwargs |
| | 644 | + output, metadata = self.manager.render_jobs(limit=limit) |
| 212 | return ToolResult(output, metadata=metadata) | 645 | return ToolResult(output, metadata=metadata) |
| 213 | | 646 | |
| 214 | - except Exception as e: | 647 | + |
| 215 | - return ToolResult( | 648 | +class BashWaitTool(Tool): |
| 216 | - f"Error executing command: {e}", | 649 | + """Wait for one tracked background bash job.""" |
| 217 | - is_error=True, | 650 | + |
| 218 | - metadata={"command": command, "cwd": resolved_cwd}, | 651 | + required_permission = PermissionMode.READ_ONLY |
| 219 | - ) | 652 | + |
| | 653 | + def __init__(self, manager: BashJobManager) -> None: |
| | 654 | + self.manager = manager |
| | 655 | + |
| | 656 | + @property |
| | 657 | + def name(self) -> str: |
| | 658 | + return "bash_wait" |
| | 659 | + |
| | 660 | + @property |
| | 661 | + def description(self) -> str: |
| | 662 | + return "Wait for a tracked background bash job to finish and return its output." |
| | 663 | + |
| | 664 | + @property |
| | 665 | + def parameters(self) -> dict[str, Any]: |
| | 666 | + return { |
| | 667 | + "type": "object", |
| | 668 | + "properties": { |
| | 669 | + "job_id": { |
| | 670 | + "type": "string", |
| | 671 | + "description": "Tracked bash job id, for example bash-1", |
| | 672 | + }, |
| | 673 | + "timeout": { |
| | 674 | + "type": "number", |
| | 675 | + "description": "Optional wait timeout in seconds", |
| | 676 | + }, |
| | 677 | + }, |
| | 678 | + "required": ["job_id"], |
| | 679 | + } |
| | 680 | + |
| | 681 | + async def execute( |
| | 682 | + self, |
| | 683 | + job_id: str, |
| | 684 | + timeout: float | None = None, |
| | 685 | + **kwargs: Any, |
| | 686 | + ) -> ToolResult: |
| | 687 | + del kwargs |
| | 688 | + return await self.manager.wait_for_job(job_id, timeout=timeout) |
| | 689 | + |
| | 690 | + |
| | 691 | +class BashKillTool(Tool): |
| | 692 | + """Stop one tracked bash job.""" |
| | 693 | + |
| | 694 | + required_permission = PermissionMode.WORKSPACE_WRITE |
| | 695 | + |
| | 696 | + def __init__(self, manager: BashJobManager) -> None: |
| | 697 | + self.manager = manager |
| | 698 | + |
| | 699 | + @property |
| | 700 | + def name(self) -> str: |
| | 701 | + return "bash_kill" |
| | 702 | + |
| | 703 | + @property |
| | 704 | + def description(self) -> str: |
| | 705 | + return "Stop a tracked bash job started during this Loader session." |
| | 706 | + |
| | 707 | + @property |
| | 708 | + def parameters(self) -> dict[str, Any]: |
| | 709 | + return { |
| | 710 | + "type": "object", |
| | 711 | + "properties": { |
| | 712 | + "job_id": { |
| | 713 | + "type": "string", |
| | 714 | + "description": "Tracked bash job id, for example bash-1", |
| | 715 | + }, |
| | 716 | + "force_after_ms": { |
| | 717 | + "type": "integer", |
| | 718 | + "description": "Grace period before force-killing the job process group", |
| | 719 | + "default": 1000, |
| | 720 | + }, |
| | 721 | + }, |
| | 722 | + "required": ["job_id"], |
| | 723 | + } |
| | 724 | + |
| | 725 | + async def execute( |
| | 726 | + self, |
| | 727 | + job_id: str, |
| | 728 | + force_after_ms: int = 1_000, |
| | 729 | + **kwargs: Any, |
| | 730 | + ) -> ToolResult: |
| | 731 | + del kwargs |
| | 732 | + return await self.manager.kill_job(job_id, force_after_ms=force_after_ms) |