| import asyncio
|
| import sys
|
| import time
|
| import collections
|
| from typing import Optional, Callable, Awaitable, Tuple, Dict
|
| from src.constants import MAX_OUTPUT_CHARS
|
|
|
| DEFAULT_BASH_TIMEOUT = 60 * 60
|
| DEFAULT_PYTHON_TIMEOUT = 60 * 60
|
|
|
| PROGRESS_INTERVAL_S = 2.0
|
| PROGRESS_TAIL_LINES = 12
|
|
|
| async def _run_subprocess_streaming(
|
| proc: asyncio.subprocess.Process,
|
| *,
|
| timeout: float,
|
| progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
| ) -> Tuple[str, str, Optional[int], bool]:
|
| started = time.time()
|
| stdout_full: list[str] = []
|
| stderr_full: list[str] = []
|
| tail = collections.deque(maxlen=PROGRESS_TAIL_LINES)
|
|
|
| async def _reader(stream, full_buf, label: str):
|
| if stream is None:
|
| return
|
| while True:
|
| line = await stream.readline()
|
| if not line:
|
| break
|
| decoded = line.decode("utf-8", errors="replace").rstrip("\n")
|
| full_buf.append(decoded)
|
| if label == "err":
|
| tail.append(f"! {decoded}")
|
| else:
|
| tail.append(decoded)
|
|
|
| async def _progress_emitter():
|
| await asyncio.sleep(PROGRESS_INTERVAL_S)
|
| while True:
|
| if progress_cb:
|
| try:
|
| await progress_cb({
|
| "elapsed_s": round(time.time() - started, 1),
|
| "tail": "\n".join(list(tail)),
|
| })
|
| except Exception:
|
| pass
|
| await asyncio.sleep(PROGRESS_INTERVAL_S)
|
|
|
| rd_out = asyncio.create_task(_reader(proc.stdout, stdout_full, "out"))
|
| rd_err = asyncio.create_task(_reader(proc.stderr, stderr_full, "err"))
|
| prog_task = asyncio.create_task(_progress_emitter()) if progress_cb else None
|
|
|
| timed_out = False
|
| try:
|
| await asyncio.wait_for(proc.wait(), timeout=timeout)
|
| except asyncio.TimeoutError:
|
| timed_out = True
|
| try:
|
| proc.kill()
|
| except Exception:
|
| pass
|
| try:
|
| await asyncio.wait_for(proc.wait(), timeout=2)
|
| except Exception:
|
| pass
|
| except asyncio.CancelledError:
|
| try:
|
| proc.kill()
|
| except Exception:
|
| pass
|
| try:
|
| await asyncio.wait_for(proc.wait(), timeout=2)
|
| except Exception:
|
| pass
|
| for t in (rd_out, rd_err):
|
| t.cancel()
|
| if prog_task is not None:
|
| prog_task.cancel()
|
| raise
|
| finally:
|
| if prog_task is not None and not prog_task.done():
|
| prog_task.cancel()
|
| try:
|
| await prog_task
|
| except (asyncio.CancelledError, Exception):
|
| pass
|
| for t in (rd_out, rd_err):
|
| try:
|
| await asyncio.wait_for(t, timeout=1)
|
| except Exception:
|
| pass
|
|
|
| return (
|
| "\n".join(stdout_full),
|
| "\n".join(stderr_full),
|
| proc.returncode,
|
| timed_out,
|
| )
|
|
|
| class BashTool:
|
| async def execute(self, content: str, ctx: dict) -> dict:
|
| from src.tool_execution import agent_cwd, _truncate
|
| progress_cb = ctx.get("progress_cb")
|
| _subproc_env = ctx.get("subproc_env")
|
| proc = await asyncio.create_subprocess_shell(
|
| content,
|
| stdout=asyncio.subprocess.PIPE,
|
| stderr=asyncio.subprocess.PIPE,
|
| env=_subproc_env,
|
| cwd=agent_cwd(),
|
| )
|
| stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
| proc,
|
| timeout=DEFAULT_BASH_TIMEOUT,
|
| progress_cb=progress_cb,
|
| )
|
| if timed_out:
|
| return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)}
|
| output = stdout.rstrip()
|
| err = stderr.rstrip()
|
| if err:
|
| output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
|
| output = _truncate(output, MAX_OUTPUT_CHARS)
|
| return {"output": output or "(no output)", "exit_code": rc or 0}
|
|
|
| class PythonTool:
|
| async def execute(self, content: str, ctx: dict) -> dict:
|
| from src.tool_execution import agent_cwd, _truncate
|
| progress_cb = ctx.get("progress_cb")
|
| _subproc_env = ctx.get("subproc_env")
|
| proc = await asyncio.create_subprocess_exec(
|
| (sys.executable or "python"), "-I", "-c", content,
|
| stdout=asyncio.subprocess.PIPE,
|
| stderr=asyncio.subprocess.PIPE,
|
| env=_subproc_env,
|
| cwd=agent_cwd(),
|
| )
|
| stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
| proc,
|
| timeout=DEFAULT_PYTHON_TIMEOUT,
|
| progress_cb=progress_cb,
|
| )
|
| if timed_out:
|
| return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)}
|
| output = stdout.rstrip()
|
| err = stderr.rstrip()
|
| if err:
|
| output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
|
| output = _truncate(output, MAX_OUTPUT_CHARS)
|
| return {"output": output or "(no output)", "exit_code": rc or 0}
|
|
|