mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
c1674fc2aa
* refactor(tools): implement strict cohesive class coordinator pattern per #2917 * test: update edit_file tests to use EditFileTool class * fix(tools): restore tool_policy param and security backstop in coordinator * refactor(tools): migrate domain tools to agent_tools package per #2917 * test: update test imports for new agent_tools package * fix: resolve circular import between tool_execution and agent_tools * fix: remove leftover git conflict markers * fix(tools): resolve pytest failure and document _apply method * fix(tools): clean up whitespace and remove dead _tool_python helper --------- Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
156 lines
5.4 KiB
Python
156 lines
5.4 KiB
Python
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 # 1 hour
|
|
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_WORKDIR, _truncate
|
|
progress_cb = ctx.get("progress_cb")
|
|
workspace = ctx.get("workspace")
|
|
_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=workspace or _AGENT_WORKDIR,
|
|
)
|
|
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_WORKDIR, _truncate
|
|
progress_cb = ctx.get("progress_cb")
|
|
workspace = ctx.get("workspace")
|
|
_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=workspace or _AGENT_WORKDIR,
|
|
)
|
|
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}
|