import asyncio import os import re import shutil 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 # 1 hour DEFAULT_PYTHON_TIMEOUT = 60 * 60 PROGRESS_INTERVAL_S = 2.0 PROGRESS_TAIL_LINES = 12 TMUX_CAPTURE_LINES = 2000 def _tmux_session_name(session_id: Optional[str]) -> str: raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-") return f"ody-agent-{raw[:80] or 'default'}" async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]: proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: try: proc.kill() except Exception: pass return "", "timeout", 124 return ( out_b.decode("utf-8", errors="replace"), err_b.decode("utf-8", errors="replace"), proc.returncode or 0, ) async def _tmux_has_session(name: str) -> bool: _, _, rc = await _run_exec("tmux", "has-session", "-t", name, timeout=3) return rc == 0 async def _tmux_capture(name: str) -> str: out, _, _ = await _run_exec( "tmux", "capture-pane", "-p", "-J", "-S", f"-{TMUX_CAPTURE_LINES}", "-t", name, timeout=5, ) return out async def _tmux_send_line(name: str, line: str) -> None: if line: await _run_exec("tmux", "send-keys", "-t", name, "-l", line, timeout=5) await _run_exec("tmux", "send-keys", "-t", name, "C-m", timeout=5) async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None: if await _tmux_has_session(name): await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) return await _run_exec( "tmux", "new-session", "-d", "-s", name, "-c", cwd, "env", f"TERM={env.get('TERM', 'xterm-256color') if env else 'xterm-256color'}", f"COLUMNS={env.get('COLUMNS', '120') if env else '120'}", f"LINES={env.get('LINES', '40') if env else '40'}", "/bin/bash", "--noprofile", "--norc", timeout=10, ) if not await _tmux_has_session(name): raise RuntimeError(f"failed to create tmux session {name}") await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) def _output_after_marker(capture: str, start_marker: str, end_marker: str) -> Tuple[str, bool]: lines = capture.splitlines() start_idx = -1 for idx, line in enumerate(lines): if line.strip() == start_marker: start_idx = idx if start_idx < 0: return capture, False end_idx = -1 for idx in range(start_idx + 1, len(lines)): if lines[idx].strip().startswith(end_marker): end_idx = idx if end_idx < 0: return "\n".join(lines[start_idx + 1:]), False return "\n".join(lines[start_idx + 1:end_idx]), True def _extract_marker_rc(capture: str, end_marker: str) -> int: for line in reversed(capture.splitlines()): stripped = line.strip() if stripped.startswith(end_marker): suffix = stripped[len(end_marker):].strip() if suffix.isdigit(): return int(suffix) return 0 async def _run_tmux_bash( content: str, *, session_id: str, cwd: str, env: Optional[dict], timeout: float, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, ) -> Tuple[str, str, Optional[int], bool]: name = _tmux_session_name(session_id) await _ensure_tmux_session(name, cwd, env) stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}" start_marker = f"__ODYSSEUS_CMD_START_{stamp}__" end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:" wrapped = ( f"printf '\\n{start_marker}\\n'\n" f"{content}\n" f"__ody_rc=$?\n" f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n" ) for line in wrapped.splitlines(): await _tmux_send_line(name, line) started = time.time() last_tail = "" while True: capture = await _tmux_capture(name) body, done = _output_after_marker(capture, start_marker, end_prefix) tail = "\n".join(body.splitlines()[-PROGRESS_TAIL_LINES:]) if progress_cb and tail != last_tail: last_tail = tail try: await progress_cb({ "elapsed_s": round(time.time() - started, 1), "tail": tail, "tmux_session": name, }) except Exception: pass if done: rc = _extract_marker_rc(capture, end_prefix) cleaned = _clean_tmux_command_output(body, wrapped) return cleaned, "", rc, False if time.time() - started > timeout: try: await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3) except Exception: pass cleaned = _clean_tmux_command_output(body, wrapped) return cleaned, "", 124, True await asyncio.sleep(0.5) def _clean_tmux_command_output(text: str, wrapped_command: str) -> str: lines = text.splitlines() wrapped_lines = {ln.rstrip() for ln in wrapped_command.splitlines() if ln.strip()} cleaned = [] for line in lines: raw = line.rstrip() stripped = raw.strip() if not stripped: cleaned.append(raw) continue if stripped in wrapped_lines: continue if stripped.startswith("__ody_rc=") or stripped.startswith("printf "): continue if re.fullmatch(r"(?:bash|sh)-[\d.]+\$ ?", stripped): continue if re.fullmatch(r"[\w.@:/~+-]+[#$] ?", stripped): continue cleaned.append(raw) return "\n".join(cleaned).strip() 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 if isinstance(content, dict): content = str(content.get("command") or content.get("cmd") or content.get("code") or "") progress_cb = ctx.get("progress_cb") _subproc_env = ctx.get("subproc_env") session_id = ctx.get("session_id") if session_id and shutil.which("tmux"): stdout, stderr, rc, timed_out = await _run_tmux_bash( content, session_id=str(session_id), cwd=agent_cwd(), env=_subproc_env, timeout=DEFAULT_BASH_TIMEOUT, progress_cb=progress_cb, ) if timed_out: return { "error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), "tmux_session": _tmux_session_name(str(session_id)), } output = stdout.rstrip() err = stderr.rstrip() if err: output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err return { "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", "exit_code": rc or 0, "tmux_session": _tmux_session_name(str(session_id)), } 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}