mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 23:42:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -1,48 +1,264 @@
|
||||
import asyncio
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import collections
|
||||
import json
|
||||
from typing import Optional, Callable, Awaitable, Tuple, Dict
|
||||
from core.platform_compat import IS_WINDOWS, find_bash
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from src.constants import MAX_OUTPUT_CHARS
|
||||
|
||||
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
|
||||
# Agent shell calls must fail fast enough for the loop to recover and choose a
|
||||
# better tool. A one-hour default can pin an entire benchmark worker on an
|
||||
# accidental recursive scan, even though ordinary artifact commands complete
|
||||
# in seconds. Long-running work belongs in manage_bg_jobs.
|
||||
DEFAULT_BASH_TIMEOUT = 120
|
||||
DEFAULT_PYTHON_TIMEOUT = 60 * 60
|
||||
|
||||
PROGRESS_INTERVAL_S = 2.0
|
||||
PROGRESS_TAIL_LINES = 12
|
||||
TMUX_CAPTURE_LINES = 2000
|
||||
_HOST_SHELL_BRIDGE_HOSTS = {"127.0.0.1", "localhost", "::1", "host.docker.internal"}
|
||||
IS_WINDOWS = sys.platform.startswith("win")
|
||||
_HOST_SHELL_CANCEL_TASKS: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
async def _create_bash_subprocess(command: str, **kwargs):
|
||||
"""Start the agent shell with Bash semantics on every supported OS.
|
||||
def _ffmpeg_unicode_drawtext_needs_fontfile(command: str) -> bool:
|
||||
"""Require a deliberate font for non-ASCII text rendered by ffmpeg.
|
||||
|
||||
``asyncio.create_subprocess_shell`` delegates to ``cmd.exe`` on native
|
||||
Windows. That contradicts the Bash tool contract and makes POSIX commands
|
||||
such as ``pwd``, ``ls -la``, and ``cat`` unreliable even when the launcher
|
||||
has found Git Bash. Pass the selected workspace as a structural ``cwd``
|
||||
argument; Git Bash inherits that native Windows directory and exposes it
|
||||
using its normal ``/c/...`` representation.
|
||||
Fontconfig's fallback is platform-dependent and commonly resolves to a
|
||||
font without the requested glyphs. An explicit ``fontfile`` makes the
|
||||
rendered artifact portable and prevents successful commands that produce
|
||||
tofu boxes instead of text.
|
||||
"""
|
||||
text = str(command or "")
|
||||
lowered = text.lower()
|
||||
return (
|
||||
bool(re.search(r"\bffmpeg\b", lowered))
|
||||
and "drawtext" in lowered
|
||||
and "fontfile" not in lowered
|
||||
and any(ord(char) > 127 for char in text)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_fontfile_for_text(text: str) -> str:
|
||||
"""Resolve a host font covering the first requested non-ASCII codepoint."""
|
||||
codepoint = next((ord(char) for char in str(text or "") if ord(char) > 127), None)
|
||||
matcher = shutil.which("fc-match")
|
||||
if codepoint is None or not matcher:
|
||||
return ""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[matcher, "-f", "%{file}", f":charset={codepoint:04x}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
candidate = str(completed.stdout or "").strip().splitlines()[0:1]
|
||||
if completed.returncode != 0 or not candidate:
|
||||
return ""
|
||||
path = candidate[0].strip()
|
||||
return path if os.path.isfile(path) else ""
|
||||
|
||||
|
||||
async def _cancel_host_shell_bridge_request(
|
||||
url: str, token: str, request_id: str,
|
||||
) -> None:
|
||||
base = url.rsplit("/", 1)[0]
|
||||
try:
|
||||
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
await client.post(
|
||||
f"{base}/cancel",
|
||||
json={"request_id": request_id},
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def find_bash() -> Optional[str]:
|
||||
"""Find a real Bash executable for native Windows agent runs."""
|
||||
candidates = [
|
||||
shutil.which("bash"),
|
||||
r"C:\Program Files\Git\bin\bash.exe",
|
||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
||||
]
|
||||
return next((path for path in candidates if path and os.path.isfile(path)), None)
|
||||
|
||||
|
||||
async def _create_bash_subprocess(
|
||||
command: str,
|
||||
*,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[dict] = None,
|
||||
):
|
||||
"""Create Bash structurally, avoiding cmd.exe and stray Windows tmux."""
|
||||
if IS_WINDOWS:
|
||||
bash = find_bash()
|
||||
if not bash:
|
||||
raise RuntimeError(
|
||||
"Git Bash is required for the Bash tool on Windows; "
|
||||
"install Git for Windows and restart Odysseus"
|
||||
"Git Bash is required for the Bash tool on Windows; install Git for Windows."
|
||||
)
|
||||
return await asyncio.create_subprocess_exec(bash, "-c", command, **kwargs)
|
||||
return await asyncio.create_subprocess_exec(
|
||||
bash,
|
||||
"-c",
|
||||
str(command or ""),
|
||||
cwd=cwd,
|
||||
)
|
||||
kwargs = {"cwd": cwd} if cwd is not None else {}
|
||||
return await asyncio.create_subprocess_shell(command, **kwargs)
|
||||
|
||||
|
||||
def _host_shell_requires_detach(command: str) -> bool:
|
||||
"""Recognize commands that must not block an interactive agent turn.
|
||||
|
||||
Models occasionally omit ``detach`` even after the host-shell contract
|
||||
tells them to poll long jobs. Keep the normal synchronous path for short
|
||||
commands, but make explicit background markers and clearly long sleeps
|
||||
deterministic so the bridge returns a job id instead of holding the SSE
|
||||
stream open.
|
||||
"""
|
||||
text = str(command or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
first = next((line.strip().lower() for line in text.splitlines() if line.strip()), "")
|
||||
if first in {"#!bg", "#bg", "# bg", "#background", "# background", "@background", "# @background"}:
|
||||
return True
|
||||
match = re.search(r"\bsleep\s+(\d+(?:\.\d+)?)\b", text, re.IGNORECASE)
|
||||
if match:
|
||||
try:
|
||||
return float(match.group(1)) >= 20
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _host_shell_should_auto_poll(command: str) -> bool:
|
||||
"""Poll implicit long-sleep jobs so a false completion cannot escape."""
|
||||
text = str(command or "").lower()
|
||||
if not _host_shell_requires_detach(command):
|
||||
return False
|
||||
return not any(
|
||||
marker in text
|
||||
for marker in ("#!bg", "#bg", "# bg", "#background", "# background", "@background")
|
||||
)
|
||||
|
||||
|
||||
def _docker_default_gateway_ips() -> set[str]:
|
||||
gateways: set[str] = set()
|
||||
try:
|
||||
with open("/proc/net/route", "r", encoding="utf-8", errors="ignore") as fh:
|
||||
for line in fh.readlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) < 3 or parts[1] != "00000000":
|
||||
continue
|
||||
raw = parts[2]
|
||||
if len(raw) != 8:
|
||||
continue
|
||||
octets = [str(int(raw[i:i + 2], 16)) for i in range(6, -1, -2)]
|
||||
gateways.add(".".join(octets))
|
||||
except Exception:
|
||||
return set()
|
||||
return gateways
|
||||
|
||||
|
||||
def _is_private_bridge_ip(host: str) -> bool:
|
||||
"""LAN + CGNAT/Tailscale (100.64.0.0/10) literal IPs — the ranges a remote
|
||||
TUI legitimately advertises when the backend is reachable over the LAN or
|
||||
Tailscale. The 172.16/12 docker-private range is deliberately EXCLUDED:
|
||||
on a container host those addresses are neighboring containers, not the
|
||||
TUI — only the actual default gateway (checked separately) is trusted."""
|
||||
parts = host.split(".")
|
||||
if len(parts) != 4 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
||||
return False
|
||||
a, b = int(parts[0]), int(parts[1])
|
||||
if a == 10:
|
||||
return True
|
||||
if a == 192 and b == 168:
|
||||
return True
|
||||
if a == 100 and 64 <= b <= 127:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_host_shell_bridge_url_allowed(url: str) -> bool:
|
||||
parsed = urlparse(str(url or "").strip())
|
||||
host = (parsed.hostname or "").strip().lower().rstrip(".")
|
||||
if parsed.scheme != "http" or not parsed.netloc or parsed.username or parsed.password:
|
||||
return False
|
||||
if (
|
||||
host not in _HOST_SHELL_BRIDGE_HOSTS
|
||||
and host not in _docker_default_gateway_ips()
|
||||
and not _is_private_bridge_ip(host)
|
||||
):
|
||||
return False
|
||||
if parsed.path not in ("", "/run"):
|
||||
return False
|
||||
if parsed.query or parsed.fragment:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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'}"
|
||||
|
||||
|
||||
def _replace_workspace_alias(content: str, cwd: str) -> str:
|
||||
"""Map virtual /workspace paths without corrupting absolute host paths."""
|
||||
return re.sub(
|
||||
r"(^|[\s'\"=:(\[,])/workspace(?=$|[/\s'\"`),;\]])",
|
||||
lambda match: match.group(1) + cwd,
|
||||
str(content or ""),
|
||||
)
|
||||
|
||||
|
||||
def _wrap_workspace_namespace(
|
||||
content: str,
|
||||
cwd: str,
|
||||
*,
|
||||
chdir: str = "/workspace",
|
||||
) -> str | None:
|
||||
"""Run a shell command with the active workspace mounted at /workspace.
|
||||
|
||||
Rewriting the command line alone is insufficient when a generated Python
|
||||
script itself contains paths such as ``/workspace/chart.png``. A small
|
||||
bubblewrap namespace preserves that public contract for each concurrent
|
||||
agent without creating a process-global /workspace symlink.
|
||||
"""
|
||||
if IS_WINDOWS or not shutil.which("bwrap"):
|
||||
return None
|
||||
args = [
|
||||
"bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/",
|
||||
"--dir", "/usr", "--ro-bind", "/usr", "/usr",
|
||||
"--symlink", "usr/bin", "/bin",
|
||||
"--symlink", "usr/lib", "/lib",
|
||||
"--symlink", "usr/lib64", "/lib64",
|
||||
"--symlink", "usr/bin", "/sbin",
|
||||
"--dir", "/etc", "--ro-bind", "/etc", "/etc",
|
||||
"--dir", "/home", "--bind", "/home", "/home",
|
||||
"--dir", "/mnt", "--bind", "/mnt", "/mnt",
|
||||
"--dir", "/tmp", "--tmpfs", "/tmp",
|
||||
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
|
||||
"--dir", "/workspace", "--bind", cwd, "/workspace",
|
||||
"--chdir", chdir, "/bin/bash", "-lc", content,
|
||||
]
|
||||
return shlex.join(args)
|
||||
|
||||
|
||||
async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
@@ -84,12 +300,30 @@ async def _tmux_send_line(name: str, line: str) -> None:
|
||||
|
||||
|
||||
async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None:
|
||||
# tmux creates child panes from the long-lived server environment, not
|
||||
# necessarily from the app process that issued ``new-session``. On hosts
|
||||
# where tmux predates the Odysseus virtualenv this silently resolves
|
||||
# ``python`` to the system interpreter, losing plotting/PDF dependencies
|
||||
# and prompting futile pip-install loops. Reassert the small execution
|
||||
# environment on both new and reused panes.
|
||||
forwarded_env = {
|
||||
key: str(env[key])
|
||||
for key in ("PATH", "VIRTUAL_ENV", "HOME", "TMPDIR")
|
||||
if env and env.get(key)
|
||||
}
|
||||
if await _tmux_has_session(name):
|
||||
if forwarded_env:
|
||||
exports = " ".join(
|
||||
f"{key}={shlex.quote(value)}" for key, value in forwarded_env.items()
|
||||
)
|
||||
await _tmux_send_line(name, f"export {exports}")
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
|
||||
return
|
||||
env_args = [f"{key}={value}" for key, value in forwarded_env.items()]
|
||||
await _run_exec(
|
||||
"tmux", "new-session", "-d", "-s", name, "-c", cwd,
|
||||
"env",
|
||||
*env_args,
|
||||
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'}",
|
||||
@@ -145,9 +379,14 @@ async def _run_tmux_bash(
|
||||
stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}"
|
||||
start_marker = f"__ODYSSEUS_CMD_START_{stamp}__"
|
||||
end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:"
|
||||
# Execute each tool call in a non-interactive child shell. The tmux pane
|
||||
# is deliberately persistent, but handing its terminal stdin to commands
|
||||
# lets programs such as ffmpeg block forever on overwrite prompts. EOF is
|
||||
# the deterministic behavior expected from an agent tool invocation.
|
||||
child_command = f"/bin/bash -lc {shlex.quote(content)} </dev/null"
|
||||
wrapped = (
|
||||
f"printf '\\n{start_marker}\\n'\n"
|
||||
f"{content}\n"
|
||||
f"{child_command}\n"
|
||||
f"__ody_rc=$?\n"
|
||||
f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n"
|
||||
)
|
||||
@@ -179,6 +418,14 @@ async def _run_tmux_bash(
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
# Ctrl-C targets the pane's foreground process group, but a child
|
||||
# can outlive its wrapper shell and become an orphan. Destroy this
|
||||
# task-scoped session as the timeout boundary; the next tool call
|
||||
# recreates it through _ensure_tmux_session.
|
||||
try:
|
||||
await _run_exec("tmux", "kill-session", "-t", name, timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
cleaned = _clean_tmux_command_output(body, wrapped)
|
||||
return cleaned, "", 124, True
|
||||
await asyncio.sleep(0.5)
|
||||
@@ -299,13 +546,58 @@ class BashTool:
|
||||
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 "")
|
||||
content = str(content or "").strip()
|
||||
if not content:
|
||||
return {
|
||||
"error": "bash: command is required; no command was executed",
|
||||
"exit_code": 1,
|
||||
}
|
||||
if re.search(r"(?:^|[;&|]\s*)sudo\b|^\s*sudo\b", content, re.IGNORECASE):
|
||||
return {
|
||||
"error": "bash: sudo/privilege escalation is unavailable in agent execution",
|
||||
"exit_code": 1,
|
||||
}
|
||||
if re.search(r"\b(?:curl|wget)\b[^\n]*https?://", content, re.IGNORECASE):
|
||||
return {
|
||||
"error": (
|
||||
"bash: ad-hoc HTTP downloads are disabled when native web tools are "
|
||||
"available. Use pdf_extract for online PDFs, web_fetch for a concrete "
|
||||
"page, or web_search for discovery. For PDF extraction "
|
||||
"tasks, treat pdf_extract as the download+scan step: extract the "
|
||||
"requested values, then create the requested output artifacts directly "
|
||||
"from that evidence instead of trying curl/wget again."
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
if _ffmpeg_unicode_drawtext_needs_fontfile(content):
|
||||
resolved_font = _resolve_fontfile_for_text(content)
|
||||
resolved_hint = (
|
||||
f" Host fontconfig resolved a covering font at `{resolved_font}`; "
|
||||
f"pass `fontfile={resolved_font}`."
|
||||
if resolved_font
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"error": (
|
||||
"bash: ffmpeg drawtext with non-ASCII text requires an explicit "
|
||||
"fontfile to avoid missing-glyph boxes."
|
||||
+ resolved_hint
|
||||
+ " If needed, resolve another suitable installed font with "
|
||||
"`fc-match -f '%{file}' ':charset=<hex-codepoint>'`, then pass that "
|
||||
"path as `drawtext=fontfile=...` and rerun the command."
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
isolated_tmp = os.path.join(agent_cwd(), ".tmp")
|
||||
if "/tmp/" in content:
|
||||
os.makedirs(isolated_tmp, exist_ok=True)
|
||||
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
||||
namespaced = _wrap_workspace_namespace(content, agent_cwd())
|
||||
content = namespaced or _replace_workspace_alias(content, agent_cwd())
|
||||
progress_cb = ctx.get("progress_cb")
|
||||
_subproc_env = ctx.get("subproc_env")
|
||||
session_id = ctx.get("session_id")
|
||||
# tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on
|
||||
# native Windows must not bypass the Git Bash launcher below: the tmux
|
||||
# setup hard-codes /bin/bash and cannot safely consume a native cwd.
|
||||
if session_id and not IS_WINDOWS and shutil.which("tmux"):
|
||||
if not IS_WINDOWS and session_id and shutil.which("tmux"):
|
||||
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
||||
content,
|
||||
session_id=str(session_id),
|
||||
@@ -316,7 +608,7 @@ class BashTool:
|
||||
)
|
||||
if timed_out:
|
||||
return {
|
||||
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session",
|
||||
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — terminated task shell session",
|
||||
"exit_code": 124,
|
||||
"stdout": _truncate(stdout, MAX_OUTPUT_CHARS),
|
||||
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
|
||||
@@ -333,15 +625,25 @@ class BashTool:
|
||||
}
|
||||
|
||||
try:
|
||||
proc = await _create_bash_subprocess(
|
||||
content,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
except RuntimeError as e:
|
||||
return {"error": f"bash: {e}", "exit_code": 1}
|
||||
if IS_WINDOWS:
|
||||
proc = await _create_bash_subprocess(
|
||||
content,
|
||||
cwd=agent_cwd(),
|
||||
env=_subproc_env,
|
||||
)
|
||||
else:
|
||||
# Preserve the existing captured POSIX path; the structural
|
||||
# helper is primarily needed to avoid cmd.exe on Windows.
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
content,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc), "exit_code": 1}
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
timeout=DEFAULT_BASH_TIMEOUT,
|
||||
@@ -356,18 +658,302 @@ class BashTool:
|
||||
output = _truncate(output, MAX_OUTPUT_CHARS)
|
||||
return {"output": output or "(no output)", "exit_code": rc or 0}
|
||||
|
||||
class HostShellTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _truncate
|
||||
|
||||
try:
|
||||
args = json.loads(content) if str(content or "").strip().startswith("{") else {}
|
||||
except Exception:
|
||||
args = {}
|
||||
command = str(
|
||||
args.get("command")
|
||||
or args.get("cmd")
|
||||
or (content if not args else "")
|
||||
or ""
|
||||
).strip()
|
||||
runtime = ctx.get("client_runtime_context")
|
||||
if not isinstance(runtime, dict):
|
||||
return {"error": "host_shell: no TUI host bridge advertised", "exit_code": 1}
|
||||
bridge = runtime.get("host_shell_bridge") or runtime.get("hostShellBridge")
|
||||
if not isinstance(bridge, dict):
|
||||
return {"error": "host_shell: no TUI host bridge advertised", "exit_code": 1}
|
||||
|
||||
url = str(bridge.get("url") or "").strip()
|
||||
token = str(bridge.get("token") or "").strip()
|
||||
parsed = urlparse(url)
|
||||
if not is_host_shell_bridge_url_allowed(url):
|
||||
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
|
||||
if not token:
|
||||
return {"error": "host_shell: bridge token missing", "exit_code": 1}
|
||||
|
||||
job_id = str(args.get("job_id") or "").strip()
|
||||
if not command and not job_id:
|
||||
return {"error": "host_shell: command or job_id required", "exit_code": 1}
|
||||
|
||||
try:
|
||||
requested_timeout = int(args.get("timeout") or 30)
|
||||
except Exception:
|
||||
requested_timeout = 30
|
||||
timeout = max(1, min(requested_timeout, 120))
|
||||
|
||||
request_body: dict[str, object] = {"timeout": timeout}
|
||||
request_id = ""
|
||||
if job_id:
|
||||
request_body["job_id"] = job_id
|
||||
else:
|
||||
request_body["command"] = command
|
||||
if bool(args.get("detach")) or _host_shell_requires_detach(command):
|
||||
request_body["detach"] = True
|
||||
else:
|
||||
request_id = secrets.token_urlsafe(18)
|
||||
request_body["request_id"] = request_id
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout + 5) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=request_body,
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
return {
|
||||
"error": f"host_shell: bridge returned HTTP {resp.status_code}",
|
||||
"exit_code": 1,
|
||||
}
|
||||
data = resp.json()
|
||||
|
||||
# A long command may be detached even when the model omitted
|
||||
# the flag. Complete that implicit job at the transport layer
|
||||
# so the model cannot report success from a mere start ack.
|
||||
if (
|
||||
not job_id
|
||||
and _host_shell_should_auto_poll(command)
|
||||
and isinstance(data, dict)
|
||||
and data.get("job_id")
|
||||
and data.get("status") == "running"
|
||||
):
|
||||
auto_job_id = str(data["job_id"])
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.25)
|
||||
poll = await client.post(
|
||||
url,
|
||||
json={"job_id": auto_job_id},
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
if poll.status_code >= 400:
|
||||
return {
|
||||
"error": f"host_shell: bridge returned HTTP {poll.status_code}",
|
||||
"exit_code": 1,
|
||||
}
|
||||
data = poll.json()
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
# A bridge may briefly lose the job record while its
|
||||
# detached worker is being registered. Keep polling;
|
||||
# do not turn that transient state into exit code 1.
|
||||
if data.get("status") in {"running", "unknown"}:
|
||||
continue
|
||||
if data.get("status") != "running":
|
||||
break
|
||||
if isinstance(data, dict) and data.get("status") in {"running", "unknown"}:
|
||||
data = {
|
||||
**data,
|
||||
"status": "running",
|
||||
"detached": True,
|
||||
"job_id": auto_job_id,
|
||||
"output": "host job still running; poll the returned job_id",
|
||||
"exit_code": 0,
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
if request_id:
|
||||
task = asyncio.create_task(
|
||||
_cancel_host_shell_bridge_request(url, token, request_id),
|
||||
name=f"cancel-host-shell-{request_id[:24]}",
|
||||
)
|
||||
_HOST_SHELL_CANCEL_TASKS.add(task)
|
||||
task.add_done_callback(_HOST_SHELL_CANCEL_TASKS.discard)
|
||||
raise
|
||||
except Exception as e:
|
||||
return {"error": f"host_shell: bridge call failed: {e}", "exit_code": 1}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"error": "host_shell: bridge returned invalid payload", "exit_code": 1}
|
||||
if data.get("error"):
|
||||
return {
|
||||
"error": _truncate(str(data["error"]), MAX_OUTPUT_CHARS),
|
||||
"exit_code": 1,
|
||||
"host_bridge": "tui",
|
||||
}
|
||||
stdout = str(data.get("stdout") or data.get("output") or "")
|
||||
stderr = str(data.get("stderr") or "")
|
||||
raw_exit_code = data.get("exit_code")
|
||||
if raw_exit_code is None:
|
||||
raw_exit_code = data.get("returncode")
|
||||
if raw_exit_code is None:
|
||||
raw_exit_code = 0
|
||||
if isinstance(raw_exit_code, bool) or not isinstance(raw_exit_code, int):
|
||||
return {
|
||||
"error": "host_shell: bridge returned an invalid exit_code",
|
||||
"exit_code": 1,
|
||||
"host_bridge": "tui",
|
||||
}
|
||||
exit_code = raw_exit_code
|
||||
output = stdout.rstrip()
|
||||
if stderr.strip():
|
||||
output = (output + "\nSTDERR: " + stderr.strip()).strip() if output else "STDERR: " + stderr.strip()
|
||||
result = {
|
||||
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
|
||||
"exit_code": exit_code,
|
||||
"host_bridge": "tui",
|
||||
}
|
||||
for key in ("detached", "job_id", "status", "running", "finished", "cwd"):
|
||||
if key in data:
|
||||
result[key] = data[key]
|
||||
return result
|
||||
|
||||
def _python_child_runtime_failure(stdout: str, stderr: str, returncode: int) -> str:
|
||||
"""Return an unmistakable nested-runtime failure hidden by Python exit 0.
|
||||
|
||||
Libraries such as Pillow may spawn a viewer and then return normally even
|
||||
when that child cannot display anything. Keep this deliberately narrow:
|
||||
arbitrary stderr is often a warning and must not turn a successful data
|
||||
transformation into a failed tool call.
|
||||
"""
|
||||
if returncode != 0 or str(stdout or "").strip():
|
||||
return ""
|
||||
err = str(stderr or "").strip()
|
||||
if re.search(r"(?im)^xdg-open: no method available for opening\b", err):
|
||||
return err
|
||||
return ""
|
||||
|
||||
|
||||
def _python_with_visible_final_expression(content: str) -> str:
|
||||
"""Give the Python tool REPL-like visibility for one final bare value.
|
||||
|
||||
The code still runs once as a normal script. Only a final expression is
|
||||
assigned and rendered; explicit print calls and statement-only programs
|
||||
retain their historical behavior.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError:
|
||||
return content
|
||||
if not tree.body or not isinstance(tree.body[-1], ast.Expr):
|
||||
return content
|
||||
final = tree.body[-1]
|
||||
if (
|
||||
isinstance(final.value, ast.Call)
|
||||
and isinstance(final.value.func, ast.Name)
|
||||
and final.value.func.id == "print"
|
||||
):
|
||||
return content
|
||||
result_name = "__odysseus_final_expression_value__"
|
||||
tree.body[-1:] = [
|
||||
ast.Assign(targets=[ast.Name(id=result_name, ctx=ast.Store())], value=final.value),
|
||||
ast.If(
|
||||
test=ast.Compare(
|
||||
left=ast.Name(id=result_name, ctx=ast.Load()),
|
||||
ops=[ast.IsNot()],
|
||||
comparators=[ast.Constant(value=None)],
|
||||
),
|
||||
body=[ast.Expr(value=ast.Call(
|
||||
func=ast.Name(id="print", ctx=ast.Load()),
|
||||
args=[ast.Call(
|
||||
func=ast.Name(id="repr", ctx=ast.Load()),
|
||||
args=[ast.Name(id=result_name, ctx=ast.Load())],
|
||||
keywords=[],
|
||||
)],
|
||||
keywords=[],
|
||||
))],
|
||||
orelse=[],
|
||||
),
|
||||
]
|
||||
ast.fix_missing_locations(tree)
|
||||
return ast.unparse(tree)
|
||||
|
||||
|
||||
class PythonTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import agent_cwd, _truncate
|
||||
if re.search(
|
||||
r"\b(?:requests\.(?:get|post|put|delete|request)|urllib\.request(?:\.\w+)?|httpx\.(?:get|post|request))\s*\(",
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
) and re.search(r"https?://", content, re.IGNORECASE) or (
|
||||
re.search(r"[\"'](?:curl|wget)[\"']", content, re.IGNORECASE)
|
||||
and re.search(r"https?://", content, re.IGNORECASE)
|
||||
):
|
||||
return {
|
||||
"error": (
|
||||
"python: ad-hoc HTTP access is disabled when native web tools are "
|
||||
"available. Use pdf_extract for online PDFs, web_fetch for a concrete "
|
||||
"page, or web_search for discovery. For PDF extraction "
|
||||
"tasks, treat pdf_extract as the download+scan step: extract the "
|
||||
"requested values, then create the requested output artifacts directly "
|
||||
"from that evidence instead of trying requests/urllib again."
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
# Only create a mount namespace when the submitted code actually
|
||||
# relies on the public virtual path. Ordinary Python probes and
|
||||
# scripts should retain the real workspace as os.getcwd(); wrapping
|
||||
# every invocation would make that stable contract appear as
|
||||
# ``/workspace`` instead.
|
||||
needs_virtual_namespace = bool(
|
||||
"/workspace" in content
|
||||
or re.search(r"\b(?:runpy\.run_path|exec\s*\(|importlib\.)", content)
|
||||
)
|
||||
isolated_tmp = os.path.join(agent_cwd(), ".tmp")
|
||||
if "/tmp/" in content:
|
||||
os.makedirs(isolated_tmp, exist_ok=True)
|
||||
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
||||
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(),
|
||||
# Generated scripts commonly contain the public `/workspace/...`
|
||||
# paths shown in the tool contract. Rewriting the inline `-c` body
|
||||
# cannot repair paths embedded in a script loaded via `runpy`, and a
|
||||
# process-global `/workspace` symlink would break concurrent tasks.
|
||||
# Give Python the same per-task namespace Bash receives so both inline
|
||||
# code and loaded scripts see the stable virtual workspace root.
|
||||
namespaced_content = _python_with_visible_final_expression(content)
|
||||
python_command = shlex.join((sys.executable or "python", "-I", "-c", namespaced_content))
|
||||
# Code that explicitly uses the public /workspace path runs inside a
|
||||
# namespace whose stable cwd is that same bind. Host workspaces under
|
||||
# /tmp or another unbound parent are intentionally invisible by their
|
||||
# real path inside the namespace; trying to chdir there makes otherwise
|
||||
# valid native Python fail before execution.
|
||||
namespaced = (
|
||||
_wrap_workspace_namespace(
|
||||
python_command,
|
||||
agent_cwd(),
|
||||
chdir="/workspace",
|
||||
)
|
||||
if needs_virtual_namespace
|
||||
else None
|
||||
)
|
||||
if namespaced:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"/bin/bash", "-lc", namespaced,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
else:
|
||||
# Platforms without a usable namespace still receive the same
|
||||
# alias contract through a conservative source rewrite.
|
||||
content = _python_with_visible_final_expression(
|
||||
_replace_workspace_alias(content, agent_cwd())
|
||||
)
|
||||
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,
|
||||
@@ -375,6 +961,17 @@ class PythonTool:
|
||||
)
|
||||
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)}
|
||||
child_failure = _python_child_runtime_failure(stdout, stderr, rc)
|
||||
if child_failure:
|
||||
return {
|
||||
"error": _truncate(
|
||||
"python: a child operation failed despite a zero Python exit "
|
||||
"status:\n" + child_failure,
|
||||
MAX_OUTPUT_CHARS,
|
||||
),
|
||||
"exit_code": 1,
|
||||
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
|
||||
}
|
||||
output = stdout.rstrip()
|
||||
err = stderr.rstrip()
|
||||
if err:
|
||||
|
||||
Reference in New Issue
Block a user