@@ -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 | 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 | 10 | from pathlib import Path |
| 11 | +import shlex |
| 12 | +import signal |
| 13 | +import subprocess |
| 6 | 14 | from typing import Any |
| 7 | 15 | |
| 8 | 16 | from ..runtime.permissions import PermissionMode |
| 9 | 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 | 405 | class BashTool(Tool): |
| 13 | | - """Execute bash commands.""" |
| 406 | + """Execute bash commands and manage their subprocess lifecycle.""" |
| 14 | 407 | |
| 15 | 408 | required_permission = PermissionMode.DANGER_FULL_ACCESS |
| 16 | 409 | OUTPUT_LIMIT = 50_000 |
| 17 | 410 | |
| 18 | | - # Commands that are generally safe (read-only operations) |
| 19 | 411 | SAFE_COMMANDS = { |
| 20 | 412 | "ls", "cat", "head", "tail", "grep", "find", "pwd", "whoami", "date", |
| 21 | 413 | "wc", "sort", "uniq", "diff", "file", "stat", "du", "df", |
@@ -24,9 +416,32 @@ class BashTool(Tool): |
| 24 | 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 | 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 | 446 | @property |
| 32 | 447 | def name(self) -> str: |
@@ -34,7 +449,11 @@ class BashTool(Tool): |
| 34 | 449 | |
| 35 | 450 | @property |
| 36 | 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 | 458 | @property |
| 40 | 459 | def parameters(self) -> dict[str, Any]: |
@@ -54,6 +473,11 @@ class BashTool(Tool): |
| 54 | 473 | "description": f"Timeout in seconds (default: {self.timeout})", |
| 55 | 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 | 482 | "required": ["command"], |
| 59 | 483 | } |
@@ -63,21 +487,17 @@ class BashTool(Tool): |
| 63 | 487 | return True |
| 64 | 488 | |
| 65 | 489 | def _is_safe_command(self, command: str) -> bool: |
| 66 | | - """Check if command is a known safe (read-only) command.""" |
| 67 | 490 | cmd = command.strip().lower() |
| 68 | | - # Check exact matches and prefix matches |
| 69 | 491 | for safe in self.SAFE_COMMANDS: |
| 70 | 492 | if cmd == safe or cmd.startswith(safe + " "): |
| 71 | 493 | return True |
| 72 | 494 | return False |
| 73 | 495 | |
| 74 | 496 | def get_required_permission(self, **kwargs: Any) -> PermissionMode: |
| 75 | | - """Classify one shell invocation by its mutability.""" |
| 76 | 497 | command = str(kwargs.get("command", "")) |
| 77 | 498 | return self.classify_command_permission(command) |
| 78 | 499 | |
| 79 | 500 | def classify_command_permission(self, command: str) -> PermissionMode: |
| 80 | | - """Classify a shell command into a runtime permission mode.""" |
| 81 | 501 | normalized = command.strip().lower() |
| 82 | 502 | if not normalized: |
| 83 | 503 | return PermissionMode.DANGER_FULL_ACCESS |
@@ -108,7 +528,6 @@ class BashTool(Tool): |
| 108 | 528 | if skip_confirmation: |
| 109 | 529 | return |
| 110 | 530 | command = kwargs.get("command", "") |
| 111 | | - # Safe commands don't need confirmation |
| 112 | 531 | if self._is_safe_command(command): |
| 113 | 532 | return |
| 114 | 533 | raise ConfirmationRequired( |
@@ -118,102 +537,196 @@ class BashTool(Tool): |
| 118 | 537 | ) |
| 119 | 538 | |
| 120 | 539 | def _is_command_allowed(self, command: str) -> bool: |
| 121 | | - """Check if command is in the allowed list.""" |
| 122 | 540 | if self.allowed_commands is None: |
| 123 | 541 | return True |
| 124 | | - |
| 125 | | - # Extract the base command |
| 126 | 542 | try: |
| 127 | 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 | 544 | except ValueError: |
| 133 | 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 | 554 | async def execute( |
| 136 | 555 | self, |
| 137 | 556 | command: str, |
| 138 | 557 | cwd: str | None = None, |
| 139 | 558 | timeout: float | None = None, |
| 559 | + background: bool = False, |
| 140 | 560 | **kwargs: Any, |
| 141 | 561 | ) -> ToolResult: |
| 562 | + del kwargs |
| 142 | 563 | if not self._is_command_allowed(command): |
| 143 | 564 | return ToolResult( |
| 144 | 565 | f"Command not allowed. Allowed commands: {self.allowed_commands}", |
| 145 | 566 | is_error=True, |
| 146 | 567 | ) |
| 147 | 568 | |
| 148 | | - timeout = timeout or self.timeout |
| 149 | | - resolved_cwd = None |
| 150 | | - if cwd: |
| 151 | | - resolved_cwd = str(Path(cwd).expanduser().resolve()) |
| 569 | + effective_timeout = self.timeout if timeout is None else timeout |
| 570 | + if not background and self._looks_long_running(command): |
| 571 | + return ToolResult( |
| 572 | + "This command looks long-running and would block Loader in the foreground. " |
| 573 | + "Re-run it with background=true, then use bash_wait or bash_jobs to inspect it.", |
| 574 | + is_error=True, |
| 575 | + metadata={ |
| 576 | + "command": command, |
| 577 | + "cwd": str(Path(cwd).expanduser().resolve()) if cwd else None, |
| 578 | + "background": background, |
| 579 | + "suggest_background": True, |
| 580 | + "mutability": self.classify_command_permission(command).as_str(), |
| 581 | + }, |
| 582 | + ) |
| 152 | 583 | |
| 153 | 584 | try: |
| 154 | | - process = await asyncio.create_subprocess_shell( |
| 155 | | - command, |
| 156 | | - stdout=asyncio.subprocess.PIPE, |
| 157 | | - stderr=asyncio.subprocess.PIPE, |
| 158 | | - cwd=resolved_cwd, |
| 585 | + job = await self.manager.start( |
| 586 | + command=command, |
| 587 | + cwd=cwd, |
| 588 | + timeout=effective_timeout, |
| 589 | + background=background, |
| 590 | + mutability=self.classify_command_permission(command).as_str(), |
| 159 | 591 | ) |
| 160 | | - |
| 592 | + if background: |
| 593 | + return self.manager.launch_result_for(job) |
| 594 | + if job.completion_task is None: |
| 595 | + return ToolResult("Bash job failed to start correctly", is_error=True) |
| 161 | 596 | try: |
| 162 | | - stdout, stderr = await asyncio.wait_for( |
| 163 | | - process.communicate(), |
| 164 | | - timeout=timeout, |
| 165 | | - ) |
| 166 | | - except TimeoutError: |
| 167 | | - process.kill() |
| 168 | | - await process.wait() |
| 169 | | - return ToolResult( |
| 170 | | - f"Command timed out after {timeout}s", |
| 171 | | - is_error=True, |
| 172 | | - ) |
| 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 |
| 606 | + return ToolResult( |
| 607 | + f"Error executing command: {exc}", |
| 608 | + is_error=True, |
| 609 | + metadata={"command": command, "cwd": resolved_cwd, "background": background}, |
| 610 | + ) |
| 173 | 611 | |
| 174 | | - output_parts = [] |
| 175 | 612 | |
| 176 | | - if stdout: |
| 177 | | - stdout_text = stdout.decode("utf-8", errors="replace") |
| 178 | | - output_parts.append(stdout_text) |
| 613 | +class BashJobsTool(Tool): |
| 614 | + """List tracked bash jobs for the current Loader runtime.""" |
| 179 | 615 | |
| 180 | | - if stderr: |
| 181 | | - stderr_text = stderr.decode("utf-8", errors="replace") |
| 182 | | - if stderr_text.strip(): |
| 183 | | - output_parts.append(f"[stderr]\n{stderr_text}") |
| 616 | + required_permission = PermissionMode.READ_ONLY |
| 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 |
| 188 | | - truncated = len(output) > self.OUTPUT_LIMIT |
| 189 | | - if truncated: |
| 190 | | - output = output[: self.OUTPUT_LIMIT] + "\n\n... (output truncated)" |
| 191 | | - else: |
| 192 | | - truncated = False |
| 193 | | - |
| 194 | | - metadata = { |
| 195 | | - "command": command, |
| 196 | | - "cwd": resolved_cwd, |
| 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 | | - |
| 205 | | - if process.returncode != 0: |
| 206 | | - return ToolResult( |
| 207 | | - f"Exit code {process.returncode}\n{output}", |
| 208 | | - is_error=True, |
| 209 | | - metadata=metadata, |
| 210 | | - ) |
| 621 | + @property |
| 622 | + def name(self) -> str: |
| 623 | + return "bash_jobs" |
| 211 | 624 | |
| 212 | | - return ToolResult(output, metadata=metadata) |
| 625 | + @property |
| 626 | + def description(self) -> str: |
| 627 | + return "List active and recent bash jobs for this Loader session." |
| 213 | 628 | |
| 214 | | - except Exception as e: |
| 215 | | - return ToolResult( |
| 216 | | - f"Error executing command: {e}", |
| 217 | | - is_error=True, |
| 218 | | - metadata={"command": command, "cwd": resolved_cwd}, |
| 219 | | - ) |
| 629 | + @property |
| 630 | + def parameters(self) -> dict[str, Any]: |
| 631 | + return { |
| 632 | + "type": "object", |
| 633 | + "properties": { |
| 634 | + "limit": { |
| 635 | + "type": "integer", |
| 636 | + "description": "Maximum number of recent jobs to include", |
| 637 | + "default": 20, |
| 638 | + }, |
| 639 | + }, |
| 640 | + } |
| 641 | + |
| 642 | + async def execute(self, limit: int = 20, **kwargs: Any) -> ToolResult: |
| 643 | + del kwargs |
| 644 | + output, metadata = self.manager.render_jobs(limit=limit) |
| 645 | + return ToolResult(output, metadata=metadata) |
| 646 | + |
| 647 | + |
| 648 | +class BashWaitTool(Tool): |
| 649 | + """Wait for one tracked background bash job.""" |
| 650 | + |
| 651 | + required_permission = PermissionMode.READ_ONLY |
| 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) |