refactor: SandboxAgent + SDK Shell/Filesystem; agent-browser CLI; nuke FastAPI sidecar
Combined commits 2+3 of the migration plan because the FastAPI sidecar removal in commit 2 broke ``browser_action`` (which lived in the sidecar); they have to land together. Sandbox tool layer (commit 2 piece): - ``build_strix_agent`` now returns a ``SandboxAgent`` with ``capabilities=[Filesystem(), Shell()]``. The SDK runtime binds the capabilities to the live sandbox session per-run; agents get ``exec_command``, ``write_stdin``, ``apply_patch``, ``view_image`` function tools auto-merged into their tool list. Plain ``Agent`` short-circuits capability binding (``agents/sandbox/runtime.py:190``). - Drop ``Compaction`` from the default capability set — it's OpenAI-Responses-API-only and useless for our litellm-routed Anthropic setup. - Delete the entire custom in-container tool layer: - ``strix/tools/terminal/`` (5 files, 748 LoC libtmux) - ``strix/tools/file_edit/`` (3 files, 276 LoC) - ``strix/tools/python/`` (5 files, 459 LoC) - ``strix/runtime/tool_server.py`` (163 LoC FastAPI sidecar) - ``strix/tools/_sandbox_dispatch.py`` (117 LoC) - ``strix/tools/registry.py`` (109 LoC) - ``strix/tools/context.py`` (12 LoC) - Drop the corresponding TUI renderers (``terminal_renderer.py``, ``file_edit_renderer.py``, ``python_renderer.py``) and update ``interface/tool_components/__init__.py``. Browser → agent-browser CLI (commit 3 piece): - Install ``agent-browser@0.26.0`` globally in the Dockerfile right after the existing ``npm install -g`` block. Run ``agent-browser install --with-deps`` (apt, root) and ``agent-browser install`` (Chrome download, pentester) + ``agent-browser doctor --offline --quick`` smoke test. - Drop the explicit Playwright system-deps apt list (replaced by ``--with-deps``) and ``RUN .venv/bin/python -m playwright install chromium``. - Vendor ``agent-browser/skill-data/core/SKILL.md`` → ``strix/skills/tooling/agent_browser.md`` (476 lines). Adapt frontmatter to Strix format; strip the install/Quickstart and the ``agent-browser skills get electron|slack|...`` specialized-skills block; add the "Caido proxy is wired via env vars; do not pass ``--proxy``" note. - ``_resolve_skills`` now eagerly loads ``tooling/agent_browser`` for every agent (matches the previous unconditional ``browser_action`` in ``_BASE_TOOLS``). - Delete ``strix/tools/browser/`` (5 files, 1338 LoC) and the ``browser_renderer.py`` TUI render. Sandbox plumbing: - Drop ``bearer`` token, ``tool_server_host_port`` resolution + bundle keys, ``TOOL_SERVER_TOKEN``/``TOOL_SERVER_PORT``/ ``STRIX_SANDBOX_EXECUTION_TIMEOUT`` from the manifest env in ``session_manager.create_or_reuse``. Caido proxy env vars (``http_proxy``, ``https_proxy``, ``ALL_PROXY``) stay; manifest applies them to every ``docker exec``-spawned process. - Drop ``sandbox_token`` and ``tool_server_host_port`` params from ``make_agent_context`` and the ``create_agent`` graph tool. - Drop the tool-server health-check from ``entry.py`` (only Caido's ``wait_for_tcp_ready`` remains). - ``docker-entrypoint.sh``: delete the ~30 line ``Starting tool server...`` block (sudo + uvicorn launch + curl /health poll). Add ``NO_PROXY=localhost,127.0.0.1`` to ``/etc/profile.d/proxy.sh`` and ``/etc/environment`` so the agent-browser daemon's CDP traffic on localhost isn't routed through Caido. pyproject.toml: - ``[project.optional-dependencies] sandbox = []`` (every member of the previous list — fastapi, uvicorn, ipython, openhands-aci, playwright, libtmux — is gone with the sidecar). - Drop ``numpydoc.*``, ``IPython.*``, ``openhands_aci.*``, ``playwright.*``, ``uvicorn.*``, ``pyte.*``, ``libtmux.*`` from the missing-imports module list. - Drop the per-file ruff ignores for the deleted modules. Net delta: −5512 LoC. ruff drops to 3 errors (was 21 baseline). mypy falls to 69 errors over 3 files (was 84 over 8 — the drop comes from deleting the modules with the worst untyped-import problems). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-20
@@ -1,33 +1,17 @@
|
||||
"""Tool package.
|
||||
|
||||
Importing every sub-package triggers the ``@register_tool``
|
||||
decorations that populate ``strix.tools.registry.tools``. The
|
||||
in-container FastAPI tool server (:mod:`strix.runtime.tool_server`)
|
||||
dispatches against that registry.
|
||||
|
||||
Host-side SDK function tools live in ``<family>/tool[s].py`` and are
|
||||
imported directly by :mod:`strix.agents.factory` — they don't flow
|
||||
through this registry.
|
||||
imported directly by :mod:`strix.agents.factory`. The sandbox-bound
|
||||
shell + filesystem tools are emitted by the SDK's ``Shell`` and
|
||||
``Filesystem`` capabilities and bound to the live sandbox session
|
||||
per-run.
|
||||
"""
|
||||
|
||||
from .agents_graph import * # noqa: F403
|
||||
from .browser import * # noqa: F403
|
||||
from .file_edit import * # noqa: F403
|
||||
from .finish import * # noqa: F403
|
||||
from .notes import * # noqa: F403
|
||||
from .proxy import * # noqa: F403
|
||||
from .python import * # noqa: F403
|
||||
from .registry import get_tool_by_name, get_tool_names, register_tool, tools
|
||||
from .reporting import * # noqa: F403
|
||||
from .terminal import * # noqa: F403
|
||||
from .thinking import * # noqa: F403
|
||||
from .todo import * # noqa: F403
|
||||
from .web_search import * # noqa: F403
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_tool_by_name",
|
||||
"get_tool_names",
|
||||
"register_tool",
|
||||
"tools",
|
||||
]
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
"""``post_to_sandbox`` — host-to-container HTTP transport for sandbox tools.
|
||||
|
||||
Every Strix tool that runs inside the Kali container (browser,
|
||||
terminal, python, file_edit, the seven Caido tools) has the same wire
|
||||
shape: POST JSON to ``http://localhost:{tool_server_host_port}/execute``
|
||||
with a Bearer token and ``{"agent_id", "tool_name", "kwargs"}`` body.
|
||||
|
||||
The helper centralizes timeouts (``connect=10s`` / ``read=150s``), a
|
||||
50 MB response-size cap so a runaway tool can't OOM the host, and
|
||||
predictable error-string shaping so transport failures don't tear
|
||||
down the run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents import RunContextWrapper
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SANDBOX_TIMEOUT = httpx.Timeout(connect=10.0, read=150.0, write=150.0, pool=150.0)
|
||||
|
||||
# Cap so a runaway tool body never blows up the host heap.
|
||||
_MAX_RESPONSE_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
|
||||
def _ctx_dict(ctx: RunContextWrapper) -> dict[str, Any] | None:
|
||||
"""Return ``ctx.context`` if it's a dict, else ``None``.
|
||||
|
||||
Strix's runtime always passes a dict (``make_agent_context``); other
|
||||
callers might not. Be defensive so a sandbox tool never raises just
|
||||
because the context shape is wrong.
|
||||
"""
|
||||
inner = getattr(ctx, "context", None)
|
||||
return inner if isinstance(inner, dict) else None
|
||||
|
||||
|
||||
async def post_to_sandbox(
|
||||
ctx: RunContextWrapper,
|
||||
tool_name: str,
|
||||
kwargs: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""POST a tool invocation to the in-container FastAPI tool server.
|
||||
|
||||
Returns:
|
||||
On success: ``{"result": <whatever the tool returned>}``.
|
||||
On any failure: ``{"error": "<human-readable error string>"}``.
|
||||
|
||||
Never raises. Tool authors call this and pass the return value
|
||||
straight to the model (or extract ``result`` for further shaping).
|
||||
"""
|
||||
inner = _ctx_dict(ctx)
|
||||
if inner is None:
|
||||
return {"error": "Sandbox not initialized: context is missing or not a dict."}
|
||||
|
||||
port = inner.get("tool_server_host_port")
|
||||
token = inner.get("sandbox_token")
|
||||
agent_id = inner.get("agent_id", "unknown")
|
||||
|
||||
if not port or not token:
|
||||
return {"error": "Sandbox not initialized: tool server port or token missing."}
|
||||
|
||||
url = f"http://127.0.0.1:{port}/execute"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {"agent_id": agent_id, "tool_name": tool_name, "kwargs": kwargs}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_SANDBOX_TIMEOUT) as client:
|
||||
response = await client.post(url, json=body, headers=headers)
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"error": (f"Sandbox tool '{tool_name}' timed out after {_SANDBOX_TIMEOUT.read}s."),
|
||||
}
|
||||
except httpx.RequestError as e:
|
||||
# ConnectError, ReadError, NetworkError, etc.
|
||||
return {"error": f"Sandbox connection failed: {e!s}"[:300]}
|
||||
|
||||
if response.status_code == 401:
|
||||
return {"error": "Sandbox authorization failed (Bearer token invalid)."}
|
||||
if response.status_code >= 400:
|
||||
return {
|
||||
"error": (
|
||||
f"Sandbox tool '{tool_name}' failed with HTTP "
|
||||
f"{response.status_code}: {response.text[:300]}"
|
||||
),
|
||||
}
|
||||
|
||||
# Cap response size before parsing so a 1 GB rogue payload never lands
|
||||
# in our heap. Most legitimate tool responses are well under 100 KB.
|
||||
raw = response.content
|
||||
if len(raw) > _MAX_RESPONSE_BYTES:
|
||||
return {
|
||||
"error": (f"Sandbox response too large ({len(raw)} bytes; max {_MAX_RESPONSE_BYTES})."),
|
||||
}
|
||||
|
||||
try:
|
||||
data: Any = response.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"error": (f"Sandbox tool '{tool_name}' returned non-JSON: {response.text[:200]}"),
|
||||
}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"error": f"Sandbox tool '{tool_name}' returned non-object JSON."}
|
||||
|
||||
return data
|
||||
@@ -403,8 +403,6 @@ async def create_agent(
|
||||
bus=bus,
|
||||
sandbox_session=inner.get("sandbox_session"),
|
||||
sandbox_client=inner.get("sandbox_client"),
|
||||
sandbox_token=inner.get("sandbox_token"),
|
||||
tool_server_host_port=inner.get("tool_server_host_port"),
|
||||
caido_host_port=inner.get("caido_host_port"),
|
||||
caido_client=inner.get("caido_client"),
|
||||
agent_id=child_id,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from .browser_actions import browser_action
|
||||
|
||||
|
||||
__all__ = ["browser_action"]
|
||||
@@ -1,240 +0,0 @@
|
||||
from typing import TYPE_CHECKING, Any, Literal, NoReturn
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .tab_manager import BrowserTabManager
|
||||
|
||||
|
||||
BrowserAction = Literal[
|
||||
"launch",
|
||||
"goto",
|
||||
"click",
|
||||
"type",
|
||||
"scroll_down",
|
||||
"scroll_up",
|
||||
"back",
|
||||
"forward",
|
||||
"new_tab",
|
||||
"switch_tab",
|
||||
"close_tab",
|
||||
"wait",
|
||||
"execute_js",
|
||||
"double_click",
|
||||
"hover",
|
||||
"press_key",
|
||||
"save_pdf",
|
||||
"get_console_logs",
|
||||
"view_source",
|
||||
"close",
|
||||
"list_tabs",
|
||||
]
|
||||
|
||||
|
||||
def _validate_url(action_name: str, url: str | None) -> None:
|
||||
if not url:
|
||||
raise ValueError(f"url parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _validate_coordinate(action_name: str, coordinate: str | None) -> None:
|
||||
if not coordinate:
|
||||
raise ValueError(f"coordinate parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _validate_text(action_name: str, text: str | None) -> None:
|
||||
if not text:
|
||||
raise ValueError(f"text parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _validate_tab_id(action_name: str, tab_id: str | None) -> None:
|
||||
if not tab_id:
|
||||
raise ValueError(f"tab_id parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _validate_js_code(action_name: str, js_code: str | None) -> None:
|
||||
if not js_code:
|
||||
raise ValueError(f"js_code parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _validate_duration(action_name: str, duration: float | None) -> None:
|
||||
if duration is None:
|
||||
raise ValueError(f"duration parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _validate_key(action_name: str, key: str | None) -> None:
|
||||
if not key:
|
||||
raise ValueError(f"key parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _validate_file_path(action_name: str, file_path: str | None) -> None:
|
||||
if not file_path:
|
||||
raise ValueError(f"file_path parameter is required for {action_name} action")
|
||||
|
||||
|
||||
def _handle_navigation_actions(
|
||||
manager: "BrowserTabManager",
|
||||
action: str,
|
||||
url: str | None = None,
|
||||
tab_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if action == "launch":
|
||||
return manager.launch_browser(url)
|
||||
if action == "goto":
|
||||
_validate_url(action, url)
|
||||
assert url is not None
|
||||
return manager.goto_url(url, tab_id)
|
||||
if action == "back":
|
||||
return manager.back(tab_id)
|
||||
if action == "forward":
|
||||
return manager.forward(tab_id)
|
||||
raise ValueError(f"Unknown navigation action: {action}")
|
||||
|
||||
|
||||
def _handle_interaction_actions(
|
||||
manager: "BrowserTabManager",
|
||||
action: str,
|
||||
coordinate: str | None = None,
|
||||
text: str | None = None,
|
||||
key: str | None = None,
|
||||
tab_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if action in {"click", "double_click", "hover"}:
|
||||
_validate_coordinate(action, coordinate)
|
||||
assert coordinate is not None
|
||||
action_map = {
|
||||
"click": manager.click,
|
||||
"double_click": manager.double_click,
|
||||
"hover": manager.hover,
|
||||
}
|
||||
return action_map[action](coordinate, tab_id)
|
||||
|
||||
if action in {"scroll_down", "scroll_up"}:
|
||||
direction = "down" if action == "scroll_down" else "up"
|
||||
return manager.scroll(direction, tab_id)
|
||||
|
||||
if action == "type":
|
||||
_validate_text(action, text)
|
||||
assert text is not None
|
||||
return manager.type_text(text, tab_id)
|
||||
if action == "press_key":
|
||||
_validate_key(action, key)
|
||||
assert key is not None
|
||||
return manager.press_key(key, tab_id)
|
||||
|
||||
raise ValueError(f"Unknown interaction action: {action}")
|
||||
|
||||
|
||||
def _raise_unknown_action(action: str) -> NoReturn:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
|
||||
def _handle_tab_actions(
|
||||
manager: "BrowserTabManager",
|
||||
action: str,
|
||||
url: str | None = None,
|
||||
tab_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if action == "new_tab":
|
||||
return manager.new_tab(url)
|
||||
if action == "switch_tab":
|
||||
_validate_tab_id(action, tab_id)
|
||||
assert tab_id is not None
|
||||
return manager.switch_tab(tab_id)
|
||||
if action == "close_tab":
|
||||
_validate_tab_id(action, tab_id)
|
||||
assert tab_id is not None
|
||||
return manager.close_tab(tab_id)
|
||||
if action == "list_tabs":
|
||||
return manager.list_tabs()
|
||||
raise ValueError(f"Unknown tab action: {action}")
|
||||
|
||||
|
||||
def _handle_utility_actions(
|
||||
manager: "BrowserTabManager",
|
||||
action: str,
|
||||
duration: float | None = None,
|
||||
js_code: str | None = None,
|
||||
file_path: str | None = None,
|
||||
tab_id: str | None = None,
|
||||
clear: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if action == "wait":
|
||||
_validate_duration(action, duration)
|
||||
assert duration is not None
|
||||
return manager.wait_browser(duration, tab_id)
|
||||
if action == "execute_js":
|
||||
_validate_js_code(action, js_code)
|
||||
assert js_code is not None
|
||||
return manager.execute_js(js_code, tab_id)
|
||||
if action == "save_pdf":
|
||||
_validate_file_path(action, file_path)
|
||||
assert file_path is not None
|
||||
return manager.save_pdf(file_path, tab_id)
|
||||
if action == "get_console_logs":
|
||||
return manager.get_console_logs(tab_id, clear)
|
||||
if action == "view_source":
|
||||
return manager.view_source(tab_id)
|
||||
if action == "close":
|
||||
return manager.close_browser()
|
||||
raise ValueError(f"Unknown utility action: {action}")
|
||||
|
||||
|
||||
@register_tool(requires_browser_mode=True)
|
||||
def browser_action(
|
||||
action: BrowserAction,
|
||||
url: str | None = None,
|
||||
coordinate: str | None = None,
|
||||
text: str | None = None,
|
||||
tab_id: str | None = None,
|
||||
js_code: str | None = None,
|
||||
duration: float | None = None,
|
||||
key: str | None = None,
|
||||
file_path: str | None = None,
|
||||
clear: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from .tab_manager import get_browser_tab_manager
|
||||
|
||||
manager = get_browser_tab_manager()
|
||||
|
||||
try:
|
||||
navigation_actions = {"launch", "goto", "back", "forward"}
|
||||
interaction_actions = {
|
||||
"click",
|
||||
"type",
|
||||
"double_click",
|
||||
"hover",
|
||||
"press_key",
|
||||
"scroll_down",
|
||||
"scroll_up",
|
||||
}
|
||||
tab_actions = {"new_tab", "switch_tab", "close_tab", "list_tabs"}
|
||||
utility_actions = {
|
||||
"wait",
|
||||
"execute_js",
|
||||
"save_pdf",
|
||||
"get_console_logs",
|
||||
"view_source",
|
||||
"close",
|
||||
}
|
||||
|
||||
if action in navigation_actions:
|
||||
return _handle_navigation_actions(manager, action, url, tab_id)
|
||||
if action in interaction_actions:
|
||||
return _handle_interaction_actions(manager, action, coordinate, text, key, tab_id)
|
||||
if action in tab_actions:
|
||||
return _handle_tab_actions(manager, action, url, tab_id)
|
||||
if action in utility_actions:
|
||||
return _handle_utility_actions(
|
||||
manager, action, duration, js_code, file_path, tab_id, clear
|
||||
)
|
||||
|
||||
_raise_unknown_action(action)
|
||||
|
||||
except (ValueError, RuntimeError) as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"tab_id": tab_id,
|
||||
"screenshot": "",
|
||||
"is_running": False,
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PAGE_SOURCE_LENGTH = 20_000
|
||||
MAX_CONSOLE_LOG_LENGTH = 30_000
|
||||
MAX_INDIVIDUAL_LOG_LENGTH = 1_000
|
||||
MAX_CONSOLE_LOGS_COUNT = 200
|
||||
MAX_JS_RESULT_LENGTH = 5_000
|
||||
|
||||
|
||||
class _BrowserState:
|
||||
"""Singleton state for the shared browser instance."""
|
||||
|
||||
lock = threading.Lock()
|
||||
event_loop: asyncio.AbstractEventLoop | None = None
|
||||
event_loop_thread: threading.Thread | None = None
|
||||
playwright: Playwright | None = None
|
||||
browser: Browser | None = None
|
||||
|
||||
|
||||
_state = _BrowserState()
|
||||
|
||||
|
||||
def _ensure_event_loop() -> None:
|
||||
if _state.event_loop is not None:
|
||||
return
|
||||
|
||||
def run_loop() -> None:
|
||||
_state.event_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_state.event_loop)
|
||||
_state.event_loop.run_forever()
|
||||
|
||||
_state.event_loop_thread = threading.Thread(target=run_loop, daemon=True)
|
||||
_state.event_loop_thread.start()
|
||||
|
||||
while _state.event_loop is None:
|
||||
threading.Event().wait(0.01)
|
||||
|
||||
|
||||
async def _create_browser() -> Browser:
|
||||
if _state.browser is not None and _state.browser.is_connected():
|
||||
return _state.browser
|
||||
|
||||
if _state.browser is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await _state.browser.close()
|
||||
_state.browser = None
|
||||
if _state.playwright is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await _state.playwright.stop()
|
||||
_state.playwright = None
|
||||
|
||||
_state.playwright = await async_playwright().start()
|
||||
_state.browser = await _state.playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-web-security",
|
||||
],
|
||||
)
|
||||
return _state.browser
|
||||
|
||||
|
||||
def _get_browser() -> tuple[asyncio.AbstractEventLoop, Browser]:
|
||||
with _state.lock:
|
||||
_ensure_event_loop()
|
||||
assert _state.event_loop is not None
|
||||
|
||||
if _state.browser is None or not _state.browser.is_connected():
|
||||
future = asyncio.run_coroutine_threadsafe(_create_browser(), _state.event_loop)
|
||||
future.result(timeout=30)
|
||||
|
||||
assert _state.browser is not None
|
||||
return _state.event_loop, _state.browser
|
||||
|
||||
|
||||
class BrowserInstance:
|
||||
def __init__(self) -> None:
|
||||
self.is_running = True
|
||||
self._execution_lock = threading.Lock()
|
||||
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._browser: Browser | None = None
|
||||
|
||||
self.context: BrowserContext | None = None
|
||||
self.pages: dict[str, Page] = {}
|
||||
self.current_page_id: str | None = None
|
||||
self._next_tab_id = 1
|
||||
|
||||
self.console_logs: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def _run_async(self, coro: Any) -> dict[str, Any]:
|
||||
if not self._loop or not self.is_running:
|
||||
raise RuntimeError("Browser instance is not running")
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
return cast("dict[str, Any]", future.result(timeout=30)) # 30 second timeout
|
||||
|
||||
async def _setup_console_logging(self, page: Page, tab_id: str) -> None:
|
||||
self.console_logs[tab_id] = []
|
||||
|
||||
def handle_console(msg: Any) -> None:
|
||||
text = msg.text
|
||||
if len(text) > MAX_INDIVIDUAL_LOG_LENGTH:
|
||||
text = text[:MAX_INDIVIDUAL_LOG_LENGTH] + "... [TRUNCATED]"
|
||||
|
||||
log_entry = {
|
||||
"type": msg.type,
|
||||
"text": text,
|
||||
"location": msg.location,
|
||||
"timestamp": asyncio.get_event_loop().time(),
|
||||
}
|
||||
|
||||
self.console_logs[tab_id].append(log_entry)
|
||||
|
||||
if len(self.console_logs[tab_id]) > MAX_CONSOLE_LOGS_COUNT:
|
||||
self.console_logs[tab_id] = self.console_logs[tab_id][-MAX_CONSOLE_LOGS_COUNT:]
|
||||
|
||||
page.on("console", handle_console)
|
||||
|
||||
async def _create_context(self, url: str | None = None) -> dict[str, Any]:
|
||||
assert self._browser is not None
|
||||
|
||||
self.context = await self._browser.new_context(
|
||||
viewport={"width": 1280, "height": 720},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
),
|
||||
)
|
||||
|
||||
page = await self.context.new_page()
|
||||
tab_id = f"tab_{self._next_tab_id}"
|
||||
self._next_tab_id += 1
|
||||
self.pages[tab_id] = page
|
||||
self.current_page_id = tab_id
|
||||
|
||||
await self._setup_console_logging(page, tab_id)
|
||||
|
||||
if url:
|
||||
await page.goto(url, wait_until="domcontentloaded")
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
async def _get_page_state(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
screenshot_bytes = await page.screenshot(type="png", full_page=False)
|
||||
screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
||||
|
||||
url = page.url
|
||||
title = await page.title()
|
||||
viewport = page.viewport_size
|
||||
|
||||
all_tabs = {}
|
||||
for tid, tab_page in self.pages.items():
|
||||
all_tabs[tid] = {
|
||||
"url": tab_page.url,
|
||||
"title": await tab_page.title() if not tab_page.is_closed() else "Closed",
|
||||
}
|
||||
|
||||
return {
|
||||
"screenshot": screenshot_b64,
|
||||
"url": url,
|
||||
"title": title,
|
||||
"viewport": viewport,
|
||||
"tab_id": tab_id,
|
||||
"all_tabs": all_tabs,
|
||||
}
|
||||
|
||||
def launch(self, url: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
if self.context is not None:
|
||||
raise ValueError("Browser is already launched")
|
||||
|
||||
self._loop, self._browser = _get_browser()
|
||||
return self._run_async(self._create_context(url))
|
||||
|
||||
def goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._goto(url, tab_id))
|
||||
|
||||
async def _goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.goto(url, wait_until="domcontentloaded")
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._click(coordinate, tab_id))
|
||||
|
||||
async def _click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
try:
|
||||
x, y = map(int, coordinate.split(","))
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.mouse.click(x, y)
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._type_text(text, tab_id))
|
||||
|
||||
async def _type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.keyboard.type(text)
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._scroll(direction, tab_id))
|
||||
|
||||
async def _scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
|
||||
if direction == "down":
|
||||
await page.keyboard.press("PageDown")
|
||||
elif direction == "up":
|
||||
await page.keyboard.press("PageUp")
|
||||
else:
|
||||
raise ValueError(f"Invalid scroll direction: {direction}")
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def back(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._back(tab_id))
|
||||
|
||||
async def _back(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.go_back(wait_until="domcontentloaded")
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def forward(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._forward(tab_id))
|
||||
|
||||
async def _forward(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.go_forward(wait_until="domcontentloaded")
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def new_tab(self, url: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._new_tab(url))
|
||||
|
||||
async def _new_tab(self, url: str | None = None) -> dict[str, Any]:
|
||||
if not self.context:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
page = await self.context.new_page()
|
||||
tab_id = f"tab_{self._next_tab_id}"
|
||||
self._next_tab_id += 1
|
||||
self.pages[tab_id] = page
|
||||
self.current_page_id = tab_id
|
||||
|
||||
await self._setup_console_logging(page, tab_id)
|
||||
|
||||
if url:
|
||||
await page.goto(url, wait_until="domcontentloaded")
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def switch_tab(self, tab_id: str) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._switch_tab(tab_id))
|
||||
|
||||
async def _switch_tab(self, tab_id: str) -> dict[str, Any]:
|
||||
if tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
self.current_page_id = tab_id
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def close_tab(self, tab_id: str) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._close_tab(tab_id))
|
||||
|
||||
async def _close_tab(self, tab_id: str) -> dict[str, Any]:
|
||||
if tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
if len(self.pages) == 1:
|
||||
raise ValueError("Cannot close the last tab")
|
||||
|
||||
page = self.pages.pop(tab_id)
|
||||
await page.close()
|
||||
|
||||
if tab_id in self.console_logs:
|
||||
del self.console_logs[tab_id]
|
||||
|
||||
if self.current_page_id == tab_id:
|
||||
self.current_page_id = next(iter(self.pages.keys()))
|
||||
|
||||
return await self._get_page_state(self.current_page_id)
|
||||
|
||||
def wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._wait(duration, tab_id))
|
||||
|
||||
async def _wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
|
||||
await asyncio.sleep(duration)
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._execute_js(js_code, tab_id))
|
||||
|
||||
async def _execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
|
||||
try:
|
||||
result = await page.evaluate(js_code)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result = {
|
||||
"error": True,
|
||||
"error_type": type(e).__name__,
|
||||
"error_message": str(e),
|
||||
}
|
||||
|
||||
result_str = str(result)
|
||||
if len(result_str) > MAX_JS_RESULT_LENGTH:
|
||||
result = result_str[:MAX_JS_RESULT_LENGTH] + "... [JS result truncated at 5k chars]"
|
||||
|
||||
state = await self._get_page_state(tab_id)
|
||||
state["js_result"] = result
|
||||
return state
|
||||
|
||||
def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._get_console_logs(tab_id, clear))
|
||||
|
||||
async def _get_console_logs(
|
||||
self, tab_id: str | None = None, clear: bool = False
|
||||
) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
logs = self.console_logs.get(tab_id, [])
|
||||
|
||||
total_length = sum(len(str(log)) for log in logs)
|
||||
if total_length > MAX_CONSOLE_LOG_LENGTH:
|
||||
truncated_logs: list[dict[str, Any]] = []
|
||||
current_length = 0
|
||||
|
||||
for log in reversed(logs):
|
||||
log_length = len(str(log))
|
||||
if current_length + log_length <= MAX_CONSOLE_LOG_LENGTH:
|
||||
truncated_logs.insert(0, log)
|
||||
current_length += log_length
|
||||
else:
|
||||
break
|
||||
|
||||
if len(truncated_logs) < len(logs):
|
||||
truncation_notice = {
|
||||
"type": "info",
|
||||
"text": (
|
||||
f"[TRUNCATED: {len(logs) - len(truncated_logs)} older logs "
|
||||
f"removed to stay within {MAX_CONSOLE_LOG_LENGTH} character limit]"
|
||||
),
|
||||
"location": {},
|
||||
"timestamp": 0,
|
||||
}
|
||||
truncated_logs.insert(0, truncation_notice)
|
||||
|
||||
logs = truncated_logs
|
||||
|
||||
if clear:
|
||||
self.console_logs[tab_id] = []
|
||||
|
||||
state = await self._get_page_state(tab_id)
|
||||
state["console_logs"] = logs
|
||||
return state
|
||||
|
||||
def view_source(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._view_source(tab_id))
|
||||
|
||||
async def _view_source(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
source = await page.content()
|
||||
original_length = len(source)
|
||||
|
||||
if original_length > MAX_PAGE_SOURCE_LENGTH:
|
||||
truncation_message = (
|
||||
f"\n\n<!-- [TRUNCATED: {original_length - MAX_PAGE_SOURCE_LENGTH} "
|
||||
"characters removed] -->\n\n"
|
||||
)
|
||||
available_space = MAX_PAGE_SOURCE_LENGTH - len(truncation_message)
|
||||
truncate_point = available_space // 2
|
||||
|
||||
source = source[:truncate_point] + truncation_message + source[-truncate_point:]
|
||||
|
||||
state = await self._get_page_state(tab_id)
|
||||
state["page_source"] = source
|
||||
return state
|
||||
|
||||
def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._double_click(coordinate, tab_id))
|
||||
|
||||
async def _double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
try:
|
||||
x, y = map(int, coordinate.split(","))
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.mouse.dblclick(x, y)
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._hover(coordinate, tab_id))
|
||||
|
||||
async def _hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
try:
|
||||
x, y = map(int, coordinate.split(","))
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.mouse.move(x, y)
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._press_key(key, tab_id))
|
||||
|
||||
async def _press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.keyboard.press(key)
|
||||
|
||||
return await self._get_page_state(tab_id)
|
||||
|
||||
def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
with self._execution_lock:
|
||||
return self._run_async(self._save_pdf(file_path, tab_id))
|
||||
|
||||
async def _save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
if not tab_id:
|
||||
tab_id = self.current_page_id
|
||||
|
||||
if not tab_id or tab_id not in self.pages:
|
||||
raise ValueError(f"Tab '{tab_id}' not found")
|
||||
|
||||
if not Path(file_path).is_absolute():
|
||||
file_path = str(Path("/workspace") / file_path)
|
||||
|
||||
page = self.pages[tab_id]
|
||||
await page.pdf(path=file_path)
|
||||
|
||||
state = await self._get_page_state(tab_id)
|
||||
state["pdf_saved"] = file_path
|
||||
return state
|
||||
|
||||
def close(self) -> None:
|
||||
with self._execution_lock:
|
||||
self.is_running = False
|
||||
if self._loop and self.context:
|
||||
future = asyncio.run_coroutine_threadsafe(self._close_context(), self._loop)
|
||||
with contextlib.suppress(Exception):
|
||||
future.result(timeout=5)
|
||||
|
||||
self.pages.clear()
|
||||
self.console_logs.clear()
|
||||
self.current_page_id = None
|
||||
self.context = None
|
||||
|
||||
async def _close_context(self) -> None:
|
||||
try:
|
||||
if self.context:
|
||||
await self.context.close()
|
||||
except (OSError, RuntimeError) as e:
|
||||
logger.warning(f"Error closing context: {e}")
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return (
|
||||
self.is_running
|
||||
and self.context is not None
|
||||
and self._browser is not None
|
||||
and self._browser.is_connected()
|
||||
)
|
||||
@@ -1,361 +0,0 @@
|
||||
import atexit
|
||||
import contextlib
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.context import get_current_agent_id
|
||||
|
||||
from .browser_instance import BrowserInstance
|
||||
|
||||
|
||||
class BrowserTabManager:
|
||||
def __init__(self) -> None:
|
||||
self._browsers_by_agent: dict[str, BrowserInstance] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
self._register_cleanup_handlers()
|
||||
|
||||
def _get_agent_browser(self) -> BrowserInstance | None:
|
||||
agent_id = get_current_agent_id()
|
||||
with self._lock:
|
||||
return self._browsers_by_agent.get(agent_id)
|
||||
|
||||
def _set_agent_browser(self, browser: BrowserInstance | None) -> None:
|
||||
agent_id = get_current_agent_id()
|
||||
with self._lock:
|
||||
if browser is None:
|
||||
self._browsers_by_agent.pop(agent_id, None)
|
||||
else:
|
||||
self._browsers_by_agent[agent_id] = browser
|
||||
|
||||
def launch_browser(self, url: str | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
agent_id = get_current_agent_id()
|
||||
if agent_id in self._browsers_by_agent:
|
||||
raise ValueError("Browser is already launched")
|
||||
|
||||
try:
|
||||
browser = BrowserInstance()
|
||||
result = browser.launch(url)
|
||||
self._browsers_by_agent[agent_id] = browser
|
||||
result["message"] = "Browser launched successfully"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to launch browser: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def goto_url(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.goto(url, tab_id)
|
||||
result["message"] = f"Navigated to {url}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to navigate to URL: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.click(coordinate, tab_id)
|
||||
result["message"] = f"Clicked at {coordinate}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to click: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.type_text(text, tab_id)
|
||||
result["message"] = f"Typed text: {text[:50]}{'...' if len(text) > 50 else ''}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to type text: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.scroll(direction, tab_id)
|
||||
result["message"] = f"Scrolled {direction}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to scroll: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def back(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.back(tab_id)
|
||||
result["message"] = "Navigated back"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to go back: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def forward(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.forward(tab_id)
|
||||
result["message"] = "Navigated forward"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to go forward: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def new_tab(self, url: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.new_tab(url)
|
||||
result["message"] = f"Created new tab {result.get('tab_id', '')}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to create new tab: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def switch_tab(self, tab_id: str) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.switch_tab(tab_id)
|
||||
result["message"] = f"Switched to tab {tab_id}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to switch tab: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def close_tab(self, tab_id: str) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.close_tab(tab_id)
|
||||
result["message"] = f"Closed tab {tab_id}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to close tab: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def wait_browser(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.wait(duration, tab_id)
|
||||
result["message"] = f"Waited {duration}s"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to wait: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.execute_js(js_code, tab_id)
|
||||
result["message"] = "JavaScript executed successfully"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to execute JavaScript: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.double_click(coordinate, tab_id)
|
||||
result["message"] = f"Double clicked at {coordinate}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to double click: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.hover(coordinate, tab_id)
|
||||
result["message"] = f"Hovered at {coordinate}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to hover: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.press_key(key, tab_id)
|
||||
result["message"] = f"Pressed key {key}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to press key: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.save_pdf(file_path, tab_id)
|
||||
result["message"] = f"Page saved as PDF: {file_path}"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to save PDF: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.get_console_logs(tab_id, clear)
|
||||
action_text = "cleared and retrieved" if clear else "retrieved"
|
||||
|
||||
logs = result.get("console_logs", [])
|
||||
truncated = any(log.get("text", "").startswith("[TRUNCATED:") for log in logs)
|
||||
truncated_text = " (truncated)" if truncated else ""
|
||||
|
||||
result["message"] = (
|
||||
f"Console logs {action_text} for tab "
|
||||
f"{result.get('tab_id', 'current')}{truncated_text}"
|
||||
)
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to get console logs: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def view_source(self, tab_id: str | None = None) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
result = browser.view_source(tab_id)
|
||||
result["message"] = "Page source retrieved"
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to get page source: {e}") from e
|
||||
else:
|
||||
return result
|
||||
|
||||
def list_tabs(self) -> dict[str, Any]:
|
||||
browser = self._get_agent_browser()
|
||||
if browser is None:
|
||||
return {"tabs": {}, "total_count": 0, "current_tab": None}
|
||||
|
||||
try:
|
||||
tab_info = {}
|
||||
for tid, tab_page in browser.pages.items():
|
||||
try:
|
||||
tab_info[tid] = {
|
||||
"url": tab_page.url,
|
||||
"title": "Unknown" if tab_page.is_closed() else "Active",
|
||||
"is_current": tid == browser.current_page_id,
|
||||
}
|
||||
except (AttributeError, RuntimeError):
|
||||
tab_info[tid] = {
|
||||
"url": "Unknown",
|
||||
"title": "Closed",
|
||||
"is_current": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"tabs": tab_info,
|
||||
"total_count": len(tab_info),
|
||||
"current_tab": browser.current_page_id,
|
||||
}
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to list tabs: {e}") from e
|
||||
|
||||
def close_browser(self) -> dict[str, Any]:
|
||||
agent_id = get_current_agent_id()
|
||||
with self._lock:
|
||||
browser = self._browsers_by_agent.pop(agent_id, None)
|
||||
if browser is None:
|
||||
raise ValueError("Browser not launched")
|
||||
|
||||
try:
|
||||
browser.close()
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
raise RuntimeError(f"Failed to close browser: {e}") from e
|
||||
else:
|
||||
return {
|
||||
"message": "Browser closed successfully",
|
||||
"screenshot": "",
|
||||
"is_running": False,
|
||||
}
|
||||
|
||||
def cleanup_agent(self, agent_id: str) -> None:
|
||||
with self._lock:
|
||||
browser = self._browsers_by_agent.pop(agent_id, None)
|
||||
|
||||
if browser:
|
||||
with contextlib.suppress(Exception):
|
||||
browser.close()
|
||||
|
||||
def cleanup_dead_browser(self) -> None:
|
||||
with self._lock:
|
||||
dead_agents = []
|
||||
for agent_id, browser in self._browsers_by_agent.items():
|
||||
if not browser.is_alive():
|
||||
dead_agents.append(agent_id)
|
||||
|
||||
for agent_id in dead_agents:
|
||||
browser = self._browsers_by_agent.pop(agent_id)
|
||||
with contextlib.suppress(Exception):
|
||||
browser.close()
|
||||
|
||||
def close_all(self) -> None:
|
||||
with self._lock:
|
||||
browsers = list(self._browsers_by_agent.values())
|
||||
self._browsers_by_agent.clear()
|
||||
|
||||
for browser in browsers:
|
||||
with contextlib.suppress(Exception):
|
||||
browser.close()
|
||||
|
||||
def _register_cleanup_handlers(self) -> None:
|
||||
atexit.register(self.close_all)
|
||||
|
||||
|
||||
_browser_tab_manager = BrowserTabManager()
|
||||
|
||||
|
||||
def get_browser_tab_manager() -> BrowserTabManager:
|
||||
return _browser_tab_manager
|
||||
@@ -1,152 +0,0 @@
|
||||
"""SDK function-tool wrapper for the legacy ``browser_action`` tool.
|
||||
|
||||
The browser is fully sandbox-bound — the legacy implementation runs
|
||||
inside the container against a Playwright instance the tool server
|
||||
manages. We delegate every action verbatim to ``post_to_sandbox``.
|
||||
|
||||
The legacy ``browser_action`` is a single mega-tool dispatching 21
|
||||
discrete actions (launch, goto, click, scroll_*, new_tab, etc.). We
|
||||
preserve that shape for parity rather than fanning out into 21
|
||||
separate tools — that would balloon the system prompt and surprise
|
||||
the model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import dump_tool_result, strix_tool
|
||||
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||
|
||||
|
||||
BrowserAction = Literal[
|
||||
"launch",
|
||||
"goto",
|
||||
"click",
|
||||
"type",
|
||||
"scroll_down",
|
||||
"scroll_up",
|
||||
"back",
|
||||
"forward",
|
||||
"new_tab",
|
||||
"switch_tab",
|
||||
"close_tab",
|
||||
"wait",
|
||||
"execute_js",
|
||||
"double_click",
|
||||
"hover",
|
||||
"press_key",
|
||||
"save_pdf",
|
||||
"get_console_logs",
|
||||
"view_source",
|
||||
"close",
|
||||
"list_tabs",
|
||||
]
|
||||
|
||||
|
||||
# Browser actions can take time (page loads, navigation timeouts), so
|
||||
# match the sandbox dispatch read budget rather than capping shorter.
|
||||
@strix_tool(timeout=180)
|
||||
async def browser_action(
|
||||
ctx: RunContextWrapper,
|
||||
action: BrowserAction,
|
||||
url: str | None = None,
|
||||
coordinate: str | None = None,
|
||||
text: str | None = None,
|
||||
tab_id: str | None = None,
|
||||
js_code: str | None = None,
|
||||
duration: float | None = None,
|
||||
key: str | None = None,
|
||||
file_path: str | None = None,
|
||||
clear: bool = False,
|
||||
) -> str:
|
||||
"""Drive the sandboxed Playwright browser (Chromium, headless).
|
||||
|
||||
The browser is **persistent** — state survives across calls and tabs
|
||||
until you ``close``. Browser interaction must start with ``launch``
|
||||
and end with ``close``. Multiple tabs are supported; the first tab
|
||||
after ``launch`` is ``"tab_1"`` and new tabs are numbered
|
||||
sequentially.
|
||||
|
||||
**Click coordinates** — derive them from the most recent screenshot.
|
||||
Target the *center* of the element, not the edge. After clicking,
|
||||
verify success against the next screenshot. Bad coordinates are the
|
||||
most common reason clicks silently fail.
|
||||
|
||||
**JavaScript execution** (``execute_js``):
|
||||
|
||||
- Code runs in the page context with full DOM access.
|
||||
- The **last evaluated expression is auto-returned** — do not use
|
||||
``return`` (it breaks evaluation).
|
||||
- For an object literal as the final expression, wrap in parentheses:
|
||||
``({title: document.title, url: location.href})``.
|
||||
- ``await`` is supported: ``await fetch(location.href).then(r => r.status)``.
|
||||
- Variables from your tool context are NOT available — pass data
|
||||
via the URL or DOM if you need to thread it through.
|
||||
- The ``js_code`` parameter is executed as-is; no escaping needed,
|
||||
single- or multi-line both work.
|
||||
|
||||
**Form filling** — click the field first, then ``type`` the text.
|
||||
|
||||
**Tabs** — actions affect the currently active tab unless ``tab_id``
|
||||
is set. Always keep at least one tab open. Close tabs you don't need
|
||||
with ``close_tab``, and ``close`` the browser when you're fully done.
|
||||
|
||||
**Concurrency** — the browser session can run alongside terminal /
|
||||
python tool calls in subsequent turns; nothing in the browser is
|
||||
serialized against other tools.
|
||||
|
||||
Special keys for ``press_key``: single chars ``a``-``z`` / ``0``-``9``,
|
||||
``Enter`` / ``Escape`` / ``Tab`` / ``Space`` / ``ArrowLeft`` /
|
||||
``ArrowRight`` / ``ArrowUp`` / ``ArrowDown``, modifiers ``Shift`` /
|
||||
``Control`` / ``Alt`` / ``Meta``, function keys ``F1``-``F12``.
|
||||
|
||||
Returns: a JSON dict with ``screenshot`` (base64 PNG), ``url``,
|
||||
``title``, ``viewport``, ``tab_id``, ``all_tabs``. Per-action extras:
|
||||
``js_result`` for ``execute_js``, ``pdf_saved`` for ``save_pdf``,
|
||||
``console_logs`` (≤50 KB / ≤200 most recent) for ``get_console_logs``,
|
||||
``page_source`` (truncated to 100 KB) for ``view_source``.
|
||||
|
||||
Args:
|
||||
action: One of: ``launch``, ``goto``, ``click``, ``type``,
|
||||
``scroll_down``, ``scroll_up``, ``back``, ``forward``,
|
||||
``new_tab``, ``switch_tab``, ``close_tab``, ``list_tabs``,
|
||||
``wait``, ``execute_js``, ``double_click``, ``hover``,
|
||||
``press_key``, ``save_pdf``, ``get_console_logs``,
|
||||
``view_source``, ``close``.
|
||||
url: Required for ``launch`` / ``goto``; optional for
|
||||
``new_tab``. Must include the protocol (e.g.
|
||||
``https://...``, ``file://...``).
|
||||
coordinate: ``"x,y"`` pixel target for ``click`` / ``double_click``
|
||||
/ ``hover``. Format example: ``"432,321"``. Must be within
|
||||
viewport.
|
||||
text: Required for ``type``.
|
||||
tab_id: Required for ``switch_tab`` / ``close_tab``; optional
|
||||
elsewhere to target a specific tab.
|
||||
js_code: Required for ``execute_js``.
|
||||
duration: Seconds for ``wait`` (fractional OK, e.g. ``0.5``).
|
||||
key: Required for ``press_key``.
|
||||
file_path: Required for ``save_pdf``.
|
||||
clear: For ``get_console_logs``, clear logs after retrieval
|
||||
(default False).
|
||||
"""
|
||||
return dump_tool_result(
|
||||
await post_to_sandbox(
|
||||
ctx,
|
||||
"browser_action",
|
||||
{
|
||||
"action": action,
|
||||
"url": url,
|
||||
"coordinate": coordinate,
|
||||
"text": text,
|
||||
"tab_id": tab_id,
|
||||
"js_code": js_code,
|
||||
"duration": duration,
|
||||
"key": key,
|
||||
"file_path": file_path,
|
||||
"clear": clear,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
current_agent_id: ContextVar[str] = ContextVar("current_agent_id", default="default")
|
||||
|
||||
|
||||
def get_current_agent_id() -> str:
|
||||
return current_agent_id.get()
|
||||
|
||||
|
||||
def set_current_agent_id(agent_id: str) -> None:
|
||||
current_agent_id.set(agent_id)
|
||||
@@ -1,4 +0,0 @@
|
||||
from .file_edit_actions import list_files, search_files, str_replace_editor
|
||||
|
||||
|
||||
__all__ = ["list_files", "search_files", "str_replace_editor"]
|
||||
@@ -1,144 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
def _parse_file_editor_output(output: str) -> dict[str, Any]:
|
||||
try:
|
||||
pattern = r"<oh_aci_output_[^>]+>\n(.*?)\n</oh_aci_output_[^>]+>"
|
||||
match = re.search(pattern, output, re.DOTALL)
|
||||
|
||||
if match:
|
||||
json_str = match.group(1)
|
||||
data = json.loads(json_str)
|
||||
return cast("dict[str, Any]", data)
|
||||
return {"output": output, "error": None}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return {"output": output, "error": None}
|
||||
|
||||
|
||||
@register_tool
|
||||
def str_replace_editor(
|
||||
command: str,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from openhands_aci import file_editor
|
||||
|
||||
try:
|
||||
path_obj = Path(path)
|
||||
if not path_obj.is_absolute():
|
||||
path = str(Path("/workspace") / path_obj)
|
||||
|
||||
result = file_editor(
|
||||
command=command,
|
||||
path=path,
|
||||
file_text=file_text,
|
||||
view_range=view_range,
|
||||
old_str=old_str,
|
||||
new_str=new_str,
|
||||
insert_line=insert_line,
|
||||
)
|
||||
|
||||
parsed = _parse_file_editor_output(result)
|
||||
|
||||
if parsed.get("error"):
|
||||
return {"error": parsed["error"]}
|
||||
|
||||
return {"content": parsed.get("output", result)}
|
||||
|
||||
except (OSError, ValueError) as e:
|
||||
return {"error": f"Error in {command} operation: {e!s}"}
|
||||
|
||||
|
||||
@register_tool
|
||||
def list_files(
|
||||
path: str,
|
||||
recursive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from openhands_aci.utils.shell import run_shell_cmd
|
||||
|
||||
try:
|
||||
path_obj = Path(path)
|
||||
if not path_obj.is_absolute():
|
||||
path = str(Path("/workspace") / path_obj)
|
||||
path_obj = Path(path)
|
||||
|
||||
if not path_obj.exists():
|
||||
return {"error": f"Directory not found: {path}"}
|
||||
|
||||
if not path_obj.is_dir():
|
||||
return {"error": f"Path is not a directory: {path}"}
|
||||
|
||||
cmd = f"find '{path}' -type f -o -type d | head -500" if recursive else f"ls -1a '{path}'"
|
||||
|
||||
exit_code, stdout, stderr = run_shell_cmd(cmd)
|
||||
|
||||
if exit_code != 0:
|
||||
return {"error": f"Error listing directory: {stderr}"}
|
||||
|
||||
items = stdout.strip().split("\n") if stdout.strip() else []
|
||||
|
||||
files = []
|
||||
dirs = []
|
||||
|
||||
for item in items:
|
||||
item_path = item if recursive else str(Path(path) / item)
|
||||
item_path_obj = Path(item_path)
|
||||
|
||||
if item_path_obj.is_file():
|
||||
files.append(item)
|
||||
elif item_path_obj.is_dir():
|
||||
dirs.append(item)
|
||||
|
||||
return {
|
||||
"files": sorted(files),
|
||||
"directories": sorted(dirs),
|
||||
"total_files": len(files),
|
||||
"total_dirs": len(dirs),
|
||||
"path": path,
|
||||
"recursive": recursive,
|
||||
}
|
||||
|
||||
except (OSError, ValueError) as e:
|
||||
return {"error": f"Error listing directory: {e!s}"}
|
||||
|
||||
|
||||
@register_tool
|
||||
def search_files(
|
||||
path: str,
|
||||
regex: str,
|
||||
file_pattern: str = "*",
|
||||
) -> dict[str, Any]:
|
||||
from openhands_aci.utils.shell import run_shell_cmd
|
||||
|
||||
try:
|
||||
path_obj = Path(path)
|
||||
if not path_obj.is_absolute():
|
||||
path = str(Path("/workspace") / path_obj)
|
||||
|
||||
if not Path(path).exists():
|
||||
return {"error": f"Directory not found: {path}"}
|
||||
|
||||
escaped_regex = regex.replace("'", "'\"'\"'")
|
||||
|
||||
cmd = f"rg --line-number --glob '{file_pattern}' '{escaped_regex}' '{path}'"
|
||||
|
||||
exit_code, stdout, stderr = run_shell_cmd(cmd)
|
||||
|
||||
if exit_code not in {0, 1}:
|
||||
return {"error": f"Error searching files: {stderr}"}
|
||||
return {"output": stdout if stdout else "No matches found"}
|
||||
|
||||
except (OSError, ValueError) as e:
|
||||
return {"error": f"Error searching files: {e!s}"}
|
||||
|
||||
|
||||
# ruff: noqa: TRY300
|
||||
@@ -1,128 +0,0 @@
|
||||
"""SDK function-tool wrappers for the legacy ``file_edit`` tools.
|
||||
|
||||
These three tools (``str_replace_editor``, ``list_files``, ``search_files``)
|
||||
operate on files inside the sandbox container's ``/workspace`` filesystem.
|
||||
The legacy harness marks them ``sandbox_execution=True`` (default) so the
|
||||
executor POSTs them to the in-container tool server.
|
||||
|
||||
The host-side SDK wrappers therefore delegate to ``post_to_sandbox`` —
|
||||
the legacy implementations live in the container image and we don't
|
||||
import them on the host (they pull in ``openhands_aci``, which is a
|
||||
sandbox-only dependency).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import dump_tool_result, strix_tool
|
||||
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||
|
||||
|
||||
@strix_tool(timeout=180)
|
||||
async def str_replace_editor(
|
||||
ctx: RunContextWrapper,
|
||||
command: str,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
) -> str:
|
||||
"""View, create, or edit a file in the sandbox filesystem.
|
||||
|
||||
Commands:
|
||||
|
||||
- ``view`` — show file contents. Optionally restrict to a line range
|
||||
via ``view_range`` (1-indexed; ``[start, -1]`` for "from start to
|
||||
end of file").
|
||||
- ``create`` — write a new file with ``file_text``. Use this for
|
||||
exploit scripts, PoCs, helper modules, etc.
|
||||
- ``str_replace`` — find ``old_str`` in the file and replace with
|
||||
``new_str``. ``old_str`` must be unique in the file; include
|
||||
enough surrounding context to make it so.
|
||||
- ``insert`` — insert ``new_str`` after line ``insert_line``.
|
||||
- ``undo_edit`` — revert the most recent edit to ``path``.
|
||||
|
||||
Multi-line ``new_str`` / ``old_str`` / ``file_text`` use real
|
||||
newlines, not literal ``\\n``.
|
||||
|
||||
Args:
|
||||
command: ``view`` / ``create`` / ``str_replace`` / ``insert`` /
|
||||
``undo_edit``.
|
||||
path: File path. Relative paths anchor at ``/workspace``.
|
||||
file_text: Required for ``create``.
|
||||
view_range: Optional ``[start, end]`` (1-indexed) for ``view``.
|
||||
old_str: Required for ``str_replace`` — must be unique in file.
|
||||
new_str: Required for ``str_replace`` and ``insert``.
|
||||
insert_line: Required for ``insert``; new content goes AFTER
|
||||
this line.
|
||||
"""
|
||||
return dump_tool_result(
|
||||
await post_to_sandbox(
|
||||
ctx,
|
||||
"str_replace_editor",
|
||||
{
|
||||
"command": command,
|
||||
"path": path,
|
||||
"file_text": file_text,
|
||||
"view_range": view_range,
|
||||
"old_str": old_str,
|
||||
"new_str": new_str,
|
||||
"insert_line": insert_line,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@strix_tool(timeout=120)
|
||||
async def list_files(
|
||||
ctx: RunContextWrapper,
|
||||
path: str,
|
||||
recursive: bool = False,
|
||||
) -> str:
|
||||
"""List files and directories under a sandbox path.
|
||||
|
||||
Output is sorted alphabetically and capped at 500 entries to avoid
|
||||
flooding the model with huge directory trees.
|
||||
|
||||
Args:
|
||||
path: Directory path; relative paths anchor at ``/workspace``.
|
||||
recursive: When True, walks subdirectories.
|
||||
"""
|
||||
return dump_tool_result(
|
||||
await post_to_sandbox(
|
||||
ctx,
|
||||
"list_files",
|
||||
{"path": path, "recursive": recursive},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@strix_tool(timeout=120)
|
||||
async def search_files(
|
||||
ctx: RunContextWrapper,
|
||||
path: str,
|
||||
regex: str,
|
||||
file_pattern: str = "*",
|
||||
) -> str:
|
||||
"""Recursively regex-search files in the sandbox using ripgrep.
|
||||
|
||||
Fast — uses ``rg`` under the hood. Walks subdirectories. Use this
|
||||
for code-pattern hunts (``def\\s+authenticate``, ``API_KEY``,
|
||||
secrets, etc.) when you don't already know the file.
|
||||
|
||||
Args:
|
||||
path: Root path to search. Relative paths anchor at ``/workspace``.
|
||||
regex: Pattern to match (PCRE-style; passed straight to ``rg``).
|
||||
file_pattern: Glob filter (e.g. ``"*.py"``, ``"*.{js,ts}"``).
|
||||
Defaults to all files.
|
||||
"""
|
||||
return dump_tool_result(
|
||||
await post_to_sandbox(
|
||||
ctx,
|
||||
"search_files",
|
||||
{"path": path, "regex": regex, "file_pattern": file_pattern},
|
||||
),
|
||||
)
|
||||
@@ -383,8 +383,8 @@ async def repeat_request(
|
||||
|
||||
The standard pentesting workflow with this tool:
|
||||
|
||||
1. ``browser_action`` (or live target traffic) → request gets
|
||||
captured by Caido.
|
||||
1. ``agent-browser`` (via ``exec_command``) or live target traffic
|
||||
→ request gets captured by Caido.
|
||||
2. ``list_requests`` → find the request ID you want to manipulate.
|
||||
3. ``repeat_request`` → send a modified version (auth-bypass test,
|
||||
payload injection, parameter tampering).
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from .python_actions import python_action
|
||||
|
||||
|
||||
__all__ = ["python_action"]
|
||||
@@ -1,47 +0,0 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
PythonAction = Literal["new_session", "execute", "close", "list_sessions"]
|
||||
|
||||
|
||||
@register_tool
|
||||
def python_action(
|
||||
action: PythonAction,
|
||||
code: str | None = None,
|
||||
timeout: int = 30,
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from .python_manager import get_python_session_manager
|
||||
|
||||
def _validate_code(action_name: str, code: str | None) -> None:
|
||||
if not code:
|
||||
raise ValueError(f"code parameter is required for {action_name} action")
|
||||
|
||||
def _validate_action(action_name: str) -> None:
|
||||
raise ValueError(f"Unknown action: {action_name}")
|
||||
|
||||
manager = get_python_session_manager()
|
||||
|
||||
try:
|
||||
match action:
|
||||
case "new_session":
|
||||
return manager.create_session(session_id, code, timeout)
|
||||
|
||||
case "execute":
|
||||
_validate_code(action, code)
|
||||
assert code is not None
|
||||
return manager.execute_code(session_id, code, timeout)
|
||||
|
||||
case "close":
|
||||
return manager.close_session(session_id)
|
||||
|
||||
case "list_sessions":
|
||||
return manager.list_sessions()
|
||||
|
||||
case _:
|
||||
_validate_action(action) # type: ignore[unreachable]
|
||||
|
||||
except (ValueError, RuntimeError) as e:
|
||||
return {"stderr": str(e), "session_id": session_id, "stdout": "", "is_running": False}
|
||||
@@ -1,153 +0,0 @@
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from IPython.core.interactiveshell import InteractiveShell
|
||||
|
||||
|
||||
MAX_STDOUT_LENGTH = 10_000
|
||||
MAX_STDERR_LENGTH = 5_000
|
||||
|
||||
|
||||
class PythonInstance:
|
||||
def __init__(self, session_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
self.is_running = True
|
||||
self._execution_lock = threading.Lock()
|
||||
|
||||
import os
|
||||
|
||||
os.chdir("/workspace")
|
||||
|
||||
self.shell = InteractiveShell()
|
||||
self.shell.init_completer()
|
||||
self.shell.init_history()
|
||||
self.shell.init_logger()
|
||||
|
||||
def _validate_session(self) -> dict[str, Any] | None:
|
||||
if not self.is_running:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"stdout": "",
|
||||
"stderr": "Session is not running",
|
||||
"result": None,
|
||||
}
|
||||
return None
|
||||
|
||||
def _truncate_output(self, content: str, max_length: int, suffix: str) -> str:
|
||||
if len(content) > max_length:
|
||||
return content[:max_length] + suffix
|
||||
return content
|
||||
|
||||
def _format_execution_result(
|
||||
self, execution_result: Any, stdout_content: str, stderr_content: str
|
||||
) -> dict[str, Any]:
|
||||
stdout = self._truncate_output(
|
||||
stdout_content, MAX_STDOUT_LENGTH, "... [stdout truncated at 10k chars]"
|
||||
)
|
||||
|
||||
if execution_result.result is not None:
|
||||
if stdout and not stdout.endswith("\n"):
|
||||
stdout += "\n"
|
||||
result_repr = repr(execution_result.result)
|
||||
result_repr = self._truncate_output(
|
||||
result_repr, MAX_STDOUT_LENGTH, "... [result truncated at 10k chars]"
|
||||
)
|
||||
stdout += result_repr
|
||||
|
||||
stdout = self._truncate_output(
|
||||
stdout, MAX_STDOUT_LENGTH, "... [output truncated at 10k chars]"
|
||||
)
|
||||
|
||||
stderr_content = stderr_content if stderr_content else ""
|
||||
stderr_content = self._truncate_output(
|
||||
stderr_content, MAX_STDERR_LENGTH, "... [stderr truncated at 5k chars]"
|
||||
)
|
||||
|
||||
if (
|
||||
execution_result.error_before_exec or execution_result.error_in_exec
|
||||
) and not stderr_content:
|
||||
stderr_content = "Execution error occurred"
|
||||
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr_content,
|
||||
"result": repr(execution_result.result)
|
||||
if execution_result.result is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
def _handle_execution_error(self, error: BaseException) -> dict[str, Any]:
|
||||
error_msg = str(error)
|
||||
error_msg = self._truncate_output(
|
||||
error_msg, MAX_STDERR_LENGTH, "... [error truncated at 5k chars]"
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"stdout": "",
|
||||
"stderr": error_msg,
|
||||
"result": None,
|
||||
}
|
||||
|
||||
def execute_code(self, code: str, timeout: int = 30) -> dict[str, Any]:
|
||||
session_error = self._validate_session()
|
||||
if session_error:
|
||||
return session_error
|
||||
|
||||
with self._execution_lock:
|
||||
result_container: dict[str, Any] = {}
|
||||
stdout_capture = io.StringIO()
|
||||
stderr_capture = io.StringIO()
|
||||
cancelled = threading.Event()
|
||||
|
||||
old_stdout, old_stderr = sys.stdout, sys.stderr
|
||||
|
||||
def _run_code() -> None:
|
||||
try:
|
||||
sys.stdout = stdout_capture
|
||||
sys.stderr = stderr_capture
|
||||
execution_result = self.shell.run_cell(code, silent=False, store_history=True)
|
||||
result_container["execution_result"] = execution_result
|
||||
result_container["stdout"] = stdout_capture.getvalue()
|
||||
result_container["stderr"] = stderr_capture.getvalue()
|
||||
except (KeyboardInterrupt, SystemExit) as e:
|
||||
result_container["error"] = e
|
||||
except Exception as e: # noqa: BLE001
|
||||
result_container["error"] = e
|
||||
finally:
|
||||
if not cancelled.is_set():
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
exec_thread = threading.Thread(target=_run_code, daemon=True)
|
||||
exec_thread.start()
|
||||
exec_thread.join(timeout=timeout)
|
||||
|
||||
if exec_thread.is_alive():
|
||||
cancelled.set()
|
||||
sys.stdout, sys.stderr = old_stdout, old_stderr
|
||||
return self._handle_execution_error(
|
||||
TimeoutError(f"Code execution timed out after {timeout} seconds")
|
||||
)
|
||||
|
||||
if "error" in result_container:
|
||||
return self._handle_execution_error(result_container["error"])
|
||||
|
||||
if "execution_result" in result_container:
|
||||
return self._format_execution_result(
|
||||
result_container["execution_result"],
|
||||
result_container.get("stdout", ""),
|
||||
result_container.get("stderr", ""),
|
||||
)
|
||||
|
||||
return self._handle_execution_error(RuntimeError("Unknown execution error"))
|
||||
|
||||
def close(self) -> None:
|
||||
self.is_running = False
|
||||
self.shell.reset(new_session=False)
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self.is_running
|
||||
@@ -1,143 +0,0 @@
|
||||
import atexit
|
||||
import contextlib
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.context import get_current_agent_id
|
||||
|
||||
from .python_instance import PythonInstance
|
||||
|
||||
|
||||
class PythonSessionManager:
|
||||
def __init__(self) -> None:
|
||||
self._sessions_by_agent: dict[str, dict[str, PythonInstance]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self.default_session_id = "default"
|
||||
|
||||
self._register_cleanup_handlers()
|
||||
|
||||
def _get_agent_sessions(self) -> dict[str, PythonInstance]:
|
||||
agent_id = get_current_agent_id()
|
||||
with self._lock:
|
||||
if agent_id not in self._sessions_by_agent:
|
||||
self._sessions_by_agent[agent_id] = {}
|
||||
return self._sessions_by_agent[agent_id]
|
||||
|
||||
def create_session(
|
||||
self, session_id: str | None = None, initial_code: str | None = None, timeout: int = 30
|
||||
) -> dict[str, Any]:
|
||||
if session_id is None:
|
||||
session_id = self.default_session_id
|
||||
|
||||
sessions = self._get_agent_sessions()
|
||||
with self._lock:
|
||||
if session_id in sessions:
|
||||
raise ValueError(f"Python session '{session_id}' already exists")
|
||||
|
||||
session = PythonInstance(session_id)
|
||||
sessions[session_id] = session
|
||||
|
||||
if initial_code:
|
||||
result = session.execute_code(initial_code, timeout)
|
||||
result["message"] = (
|
||||
f"Python session '{session_id}' created successfully with initial code"
|
||||
)
|
||||
else:
|
||||
result = {
|
||||
"session_id": session_id,
|
||||
"message": f"Python session '{session_id}' created successfully",
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def execute_code(
|
||||
self, session_id: str | None = None, code: str | None = None, timeout: int = 30
|
||||
) -> dict[str, Any]:
|
||||
if session_id is None:
|
||||
session_id = self.default_session_id
|
||||
|
||||
if not code:
|
||||
raise ValueError("No code provided for execution")
|
||||
|
||||
sessions = self._get_agent_sessions()
|
||||
with self._lock:
|
||||
if session_id not in sessions:
|
||||
raise ValueError(f"Python session '{session_id}' not found")
|
||||
|
||||
session = sessions[session_id]
|
||||
|
||||
result = session.execute_code(code, timeout)
|
||||
result["message"] = f"Code executed in session '{session_id}'"
|
||||
return result
|
||||
|
||||
def close_session(self, session_id: str | None = None) -> dict[str, Any]:
|
||||
if session_id is None:
|
||||
session_id = self.default_session_id
|
||||
|
||||
sessions = self._get_agent_sessions()
|
||||
with self._lock:
|
||||
if session_id not in sessions:
|
||||
raise ValueError(f"Python session '{session_id}' not found")
|
||||
|
||||
session = sessions.pop(session_id)
|
||||
|
||||
session.close()
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"message": f"Python session '{session_id}' closed successfully",
|
||||
"is_running": False,
|
||||
}
|
||||
|
||||
def list_sessions(self) -> dict[str, Any]:
|
||||
sessions = self._get_agent_sessions()
|
||||
with self._lock:
|
||||
session_info = {}
|
||||
for sid, session in sessions.items():
|
||||
session_info[sid] = {
|
||||
"is_running": session.is_running,
|
||||
"is_alive": session.is_alive(),
|
||||
}
|
||||
|
||||
return {"sessions": session_info, "total_count": len(session_info)}
|
||||
|
||||
def cleanup_agent(self, agent_id: str) -> None:
|
||||
with self._lock:
|
||||
sessions = self._sessions_by_agent.pop(agent_id, {})
|
||||
|
||||
for session in sessions.values():
|
||||
with contextlib.suppress(Exception):
|
||||
session.close()
|
||||
|
||||
def cleanup_dead_sessions(self) -> None:
|
||||
with self._lock:
|
||||
for sessions in self._sessions_by_agent.values():
|
||||
dead_sessions = []
|
||||
for sid, session in sessions.items():
|
||||
if not session.is_alive():
|
||||
dead_sessions.append(sid)
|
||||
|
||||
for sid in dead_sessions:
|
||||
session = sessions.pop(sid)
|
||||
with contextlib.suppress(Exception):
|
||||
session.close()
|
||||
|
||||
def close_all_sessions(self) -> None:
|
||||
with self._lock:
|
||||
all_sessions: list[PythonInstance] = []
|
||||
for sessions in self._sessions_by_agent.values():
|
||||
all_sessions.extend(sessions.values())
|
||||
self._sessions_by_agent.clear()
|
||||
|
||||
for session in all_sessions:
|
||||
with contextlib.suppress(Exception):
|
||||
session.close()
|
||||
|
||||
def _register_cleanup_handlers(self) -> None:
|
||||
atexit.register(self.close_all_sessions)
|
||||
|
||||
|
||||
_python_session_manager = PythonSessionManager()
|
||||
|
||||
|
||||
def get_python_session_manager() -> PythonSessionManager:
|
||||
return _python_session_manager
|
||||
@@ -1,91 +0,0 @@
|
||||
"""SDK function-tool wrapper for the legacy ``python_action`` tool.
|
||||
|
||||
Sandbox-bound. The in-container manager keeps long-lived IPython
|
||||
sessions keyed by ``session_id`` so the model can build up state
|
||||
across multiple ``execute`` calls. Pure pass-through wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import dump_tool_result, strix_tool
|
||||
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||
|
||||
|
||||
PythonAction = Literal["new_session", "execute", "close", "list_sessions"]
|
||||
|
||||
|
||||
@strix_tool(timeout=180)
|
||||
async def python_action(
|
||||
ctx: RunContextWrapper,
|
||||
action: PythonAction,
|
||||
code: str | None = None,
|
||||
timeout: int = 30,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Run Python code in a long-lived IPython session — preferred for any
|
||||
Python work (payloads, exploit scripts, HTTP automation, log analysis,
|
||||
crypto, data processing).
|
||||
|
||||
Pick this over ``terminal_execute`` whenever the work is Python.
|
||||
Don't wrap Python in bash heredocs, ``python -c`` one-liners, or
|
||||
interactive REPL sessions in the terminal — the structured,
|
||||
persistent, debuggable execution lives here.
|
||||
|
||||
Sessions are **persistent** — variables, imports, and function
|
||||
definitions survive between ``execute`` calls within the same
|
||||
``session_id``. Each session has its own isolated namespace; multiple
|
||||
sessions can run concurrently. Sessions stay alive until explicitly
|
||||
``close``-d.
|
||||
|
||||
Caido proxy helpers are pre-imported into every session, so you can
|
||||
correlate captured HTTP requests with custom analysis without any
|
||||
setup: ``list_requests`` / ``view_request`` / ``send_request`` /
|
||||
``repeat_request`` / ``scope_rules`` / ``list_sitemap`` /
|
||||
``view_sitemap_entry`` are all available as bare names.
|
||||
|
||||
For large payload sprays / fuzzing loops, encapsulate the entire
|
||||
loop inside a single ``python_action`` ``execute`` call (e.g.,
|
||||
asyncio + aiohttp). Don't issue one tool call per payload — that
|
||||
burns turns and is dramatically slower.
|
||||
|
||||
Code execution notes:
|
||||
|
||||
- Both expressions and statements are supported. Expressions auto-
|
||||
return their result; ``print`` output is captured to stdout.
|
||||
- IPython magics work: ``%pip install ...``, ``%time``, ``%whos``,
|
||||
``%%writefile``, etc.
|
||||
- Use real newlines in multi-line ``code``, not literal ``\\n``.
|
||||
|
||||
Workflow:
|
||||
|
||||
1. ``new_session`` (always first per ``session_id``) — optionally
|
||||
pass ``code`` for an initial setup snippet (imports, helpers).
|
||||
2. ``execute`` — run code. Variables persist across calls.
|
||||
3. ``close`` — terminate the session and free memory.
|
||||
4. ``list_sessions`` — inspect what's currently alive.
|
||||
|
||||
Args:
|
||||
action: ``"new_session"`` / ``"execute"`` / ``"close"`` /
|
||||
``"list_sessions"``.
|
||||
code: Required for ``execute``; optional initial code for
|
||||
``new_session``.
|
||||
timeout: Per-call execution budget in seconds. Default 30.
|
||||
session_id: Required for ``execute`` / ``close``. Optional for
|
||||
``new_session`` (auto-generated when omitted).
|
||||
"""
|
||||
return dump_tool_result(
|
||||
await post_to_sandbox(
|
||||
ctx,
|
||||
"python_action",
|
||||
{
|
||||
"action": action,
|
||||
"code": code,
|
||||
"timeout": timeout,
|
||||
"session_id": session_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Minimal in-container tool registry.
|
||||
|
||||
Used inside the sandbox container by ``strix.runtime.tool_server`` to
|
||||
look up ``@register_tool``-decorated functions by name. Sandbox-bound
|
||||
tools (browser, terminal, python, file_edit, proxy) live as
|
||||
``*_actions.py`` modules with this decoration; the host POSTs to
|
||||
:func:`tool_server.execute_tool` which dispatches via
|
||||
:func:`get_tool_by_name`.
|
||||
|
||||
Host-side SDK function tools are wired through
|
||||
:mod:`strix.agents.factory` and don't touch this registry.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
tools: list[dict[str, Any]] = []
|
||||
_tools_by_name: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
|
||||
def _is_sandbox_mode() -> bool:
|
||||
return os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
|
||||
|
||||
|
||||
def _is_browser_disabled() -> bool:
|
||||
return os.getenv("STRIX_DISABLE_BROWSER", "").lower() == "true"
|
||||
|
||||
|
||||
def _has_perplexity_api() -> bool:
|
||||
return bool(os.getenv("PERPLEXITY_API_KEY"))
|
||||
|
||||
|
||||
def _should_register_tool(
|
||||
*,
|
||||
sandbox_execution: bool,
|
||||
requires_browser_mode: bool,
|
||||
requires_web_search_mode: bool,
|
||||
) -> bool:
|
||||
"""In-container side only registers sandbox-execution tools."""
|
||||
sandbox_mode = _is_sandbox_mode()
|
||||
|
||||
if sandbox_mode and not sandbox_execution:
|
||||
return False
|
||||
if requires_browser_mode and _is_browser_disabled():
|
||||
return False
|
||||
return not (requires_web_search_mode and not _has_perplexity_api())
|
||||
|
||||
|
||||
def register_tool(
|
||||
func: Callable[..., Any] | None = None,
|
||||
*,
|
||||
sandbox_execution: bool = True,
|
||||
requires_browser_mode: bool = False,
|
||||
requires_web_search_mode: bool = False,
|
||||
) -> Callable[..., Any]:
|
||||
"""Register a tool function for in-container dispatch.
|
||||
|
||||
Decorations are conditional on the env (``STRIX_SANDBOX_MODE``,
|
||||
``STRIX_DISABLE_BROWSER``, ``PERPLEXITY_API_KEY``) so the host
|
||||
side, which imports these modules but doesn't run sandbox-bound
|
||||
tools locally, doesn't accumulate dead registrations.
|
||||
"""
|
||||
|
||||
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
if not _should_register_tool(
|
||||
sandbox_execution=sandbox_execution,
|
||||
requires_browser_mode=requires_browser_mode,
|
||||
requires_web_search_mode=requires_web_search_mode,
|
||||
):
|
||||
return f
|
||||
|
||||
tools.append(
|
||||
{
|
||||
"name": f.__name__,
|
||||
"function": f,
|
||||
"sandbox_execution": sandbox_execution,
|
||||
},
|
||||
)
|
||||
_tools_by_name[f.__name__] = f
|
||||
|
||||
@wraps(f)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
if func is None:
|
||||
return decorator
|
||||
return decorator(func)
|
||||
|
||||
|
||||
def get_tool_by_name(name: str) -> Callable[..., Any] | None:
|
||||
return _tools_by_name.get(name)
|
||||
|
||||
|
||||
def get_tool_names() -> list[str]:
|
||||
return list(_tools_by_name.keys())
|
||||
|
||||
|
||||
def clear_registry() -> None:
|
||||
tools.clear()
|
||||
_tools_by_name.clear()
|
||||
@@ -1,4 +0,0 @@
|
||||
from .terminal_actions import terminal_execute
|
||||
|
||||
|
||||
__all__ = ["terminal_execute"]
|
||||
@@ -1,35 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
@register_tool
|
||||
def terminal_execute(
|
||||
command: str,
|
||||
is_input: bool = False,
|
||||
timeout: float | None = None,
|
||||
terminal_id: str | None = None,
|
||||
no_enter: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from .terminal_manager import get_terminal_manager
|
||||
|
||||
manager = get_terminal_manager()
|
||||
|
||||
try:
|
||||
return manager.execute_command(
|
||||
command=command,
|
||||
is_input=is_input,
|
||||
timeout=timeout,
|
||||
terminal_id=terminal_id,
|
||||
no_enter=no_enter,
|
||||
)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"command": command,
|
||||
"terminal_id": terminal_id or "default",
|
||||
"content": "",
|
||||
"status": "error",
|
||||
"exit_code": None,
|
||||
"working_dir": None,
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import atexit
|
||||
import contextlib
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.context import get_current_agent_id
|
||||
|
||||
from .terminal_session import TerminalSession
|
||||
|
||||
|
||||
class TerminalManager:
|
||||
def __init__(self) -> None:
|
||||
self._sessions_by_agent: dict[str, dict[str, TerminalSession]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self.default_terminal_id = "default"
|
||||
self.default_timeout = 30.0
|
||||
|
||||
self._register_cleanup_handlers()
|
||||
|
||||
def _get_agent_sessions(self) -> dict[str, TerminalSession]:
|
||||
agent_id = get_current_agent_id()
|
||||
with self._lock:
|
||||
if agent_id not in self._sessions_by_agent:
|
||||
self._sessions_by_agent[agent_id] = {}
|
||||
return self._sessions_by_agent[agent_id]
|
||||
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
is_input: bool = False,
|
||||
timeout: float | None = None,
|
||||
terminal_id: str | None = None,
|
||||
no_enter: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if terminal_id is None:
|
||||
terminal_id = self.default_terminal_id
|
||||
|
||||
session = self._get_or_create_session(terminal_id)
|
||||
|
||||
try:
|
||||
result = session.execute(command, is_input, timeout or self.default_timeout, no_enter)
|
||||
|
||||
return {
|
||||
"content": result["content"],
|
||||
"command": command,
|
||||
"terminal_id": terminal_id,
|
||||
"status": result["status"],
|
||||
"exit_code": result.get("exit_code"),
|
||||
"working_dir": result.get("working_dir"),
|
||||
}
|
||||
|
||||
except RuntimeError as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"command": command,
|
||||
"terminal_id": terminal_id,
|
||||
"content": "",
|
||||
"status": "error",
|
||||
"exit_code": None,
|
||||
"working_dir": None,
|
||||
}
|
||||
except OSError as e:
|
||||
return {
|
||||
"error": f"System error: {e}",
|
||||
"command": command,
|
||||
"terminal_id": terminal_id,
|
||||
"content": "",
|
||||
"status": "error",
|
||||
"exit_code": None,
|
||||
"working_dir": None,
|
||||
}
|
||||
|
||||
def _get_or_create_session(self, terminal_id: str) -> TerminalSession:
|
||||
sessions = self._get_agent_sessions()
|
||||
with self._lock:
|
||||
if terminal_id not in sessions:
|
||||
sessions[terminal_id] = TerminalSession(terminal_id)
|
||||
return sessions[terminal_id]
|
||||
|
||||
def close_session(self, terminal_id: str | None = None) -> dict[str, Any]:
|
||||
if terminal_id is None:
|
||||
terminal_id = self.default_terminal_id
|
||||
|
||||
sessions = self._get_agent_sessions()
|
||||
with self._lock:
|
||||
if terminal_id not in sessions:
|
||||
return {
|
||||
"terminal_id": terminal_id,
|
||||
"message": f"Terminal '{terminal_id}' not found",
|
||||
"status": "not_found",
|
||||
}
|
||||
|
||||
session = sessions.pop(terminal_id)
|
||||
|
||||
try:
|
||||
session.close()
|
||||
except (RuntimeError, OSError) as e:
|
||||
return {
|
||||
"terminal_id": terminal_id,
|
||||
"error": f"Failed to close terminal '{terminal_id}': {e}",
|
||||
"status": "error",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"terminal_id": terminal_id,
|
||||
"message": f"Terminal '{terminal_id}' closed successfully",
|
||||
"status": "closed",
|
||||
}
|
||||
|
||||
def list_sessions(self) -> dict[str, Any]:
|
||||
sessions = self._get_agent_sessions()
|
||||
with self._lock:
|
||||
session_info: dict[str, dict[str, Any]] = {}
|
||||
for tid, session in sessions.items():
|
||||
session_info[tid] = {
|
||||
"is_running": session.is_running(),
|
||||
"working_dir": session.get_working_dir(),
|
||||
}
|
||||
|
||||
return {"sessions": session_info, "total_count": len(session_info)}
|
||||
|
||||
def cleanup_agent(self, agent_id: str) -> None:
|
||||
with self._lock:
|
||||
sessions = self._sessions_by_agent.pop(agent_id, {})
|
||||
|
||||
for session in sessions.values():
|
||||
with contextlib.suppress(Exception):
|
||||
session.close()
|
||||
|
||||
def cleanup_dead_sessions(self) -> None:
|
||||
with self._lock:
|
||||
for sessions in self._sessions_by_agent.values():
|
||||
dead_sessions: list[str] = []
|
||||
for tid, session in sessions.items():
|
||||
if not session.is_running():
|
||||
dead_sessions.append(tid)
|
||||
|
||||
for tid in dead_sessions:
|
||||
session = sessions.pop(tid)
|
||||
with contextlib.suppress(Exception):
|
||||
session.close()
|
||||
|
||||
def close_all_sessions(self) -> None:
|
||||
with self._lock:
|
||||
all_sessions: list[TerminalSession] = []
|
||||
for sessions in self._sessions_by_agent.values():
|
||||
all_sessions.extend(sessions.values())
|
||||
self._sessions_by_agent.clear()
|
||||
|
||||
for session in all_sessions:
|
||||
with contextlib.suppress(Exception):
|
||||
session.close()
|
||||
|
||||
def _register_cleanup_handlers(self) -> None:
|
||||
atexit.register(self.close_all_sessions)
|
||||
|
||||
|
||||
_terminal_manager = TerminalManager()
|
||||
|
||||
|
||||
def get_terminal_manager() -> TerminalManager:
|
||||
return _terminal_manager
|
||||
@@ -1,447 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import libtmux
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BashCommandStatus(Enum):
|
||||
CONTINUE = "continue"
|
||||
COMPLETED = "completed"
|
||||
NO_CHANGE_TIMEOUT = "no_change_timeout"
|
||||
HARD_TIMEOUT = "hard_timeout"
|
||||
|
||||
|
||||
def _remove_command_prefix(command_output: str, command: str) -> str:
|
||||
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
POLL_INTERVAL = 0.5
|
||||
HISTORY_LIMIT = 10_000
|
||||
PS1_END = "]$ "
|
||||
|
||||
def __init__(self, session_id: str, work_dir: str = "/workspace") -> None:
|
||||
self.session_id = session_id
|
||||
self.work_dir = str(Path(work_dir).resolve())
|
||||
self._closed = False
|
||||
self._cwd = self.work_dir
|
||||
|
||||
self.server: libtmux.Server | None = None
|
||||
self.session: libtmux.Session | None = None
|
||||
self.window: libtmux.Window | None = None
|
||||
self.pane: libtmux.Pane | None = None
|
||||
|
||||
self.prev_status: BashCommandStatus | None = None
|
||||
self.prev_output: str = ""
|
||||
self._initialized = False
|
||||
|
||||
self.initialize()
|
||||
|
||||
@property
|
||||
def PS1(self) -> str: # noqa: N802
|
||||
return r"[STRIX_$?]$ "
|
||||
|
||||
@property
|
||||
def PS1_PATTERN(self) -> str: # noqa: N802
|
||||
return r"\[STRIX_(\d+)\]"
|
||||
|
||||
def initialize(self) -> None:
|
||||
self.server = libtmux.Server()
|
||||
|
||||
session_name = f"strix-{self.session_id}-{uuid.uuid4()}"
|
||||
self.session = self.server.new_session(
|
||||
session_name=session_name,
|
||||
start_directory=self.work_dir,
|
||||
kill_session=True,
|
||||
x=120,
|
||||
y=30,
|
||||
)
|
||||
|
||||
self.session.set_option("history-limit", str(self.HISTORY_LIMIT))
|
||||
self.session.history_limit = self.HISTORY_LIMIT
|
||||
|
||||
_initial_window = self.session.active_window
|
||||
self.window = self.session.new_window(
|
||||
window_name="bash",
|
||||
window_shell="/bin/bash",
|
||||
start_directory=self.work_dir,
|
||||
)
|
||||
self.pane = self.window.active_pane
|
||||
_initial_window.kill()
|
||||
|
||||
self.pane.send_keys(f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""')
|
||||
time.sleep(0.1)
|
||||
self._clear_screen()
|
||||
|
||||
self.prev_status = None
|
||||
self.prev_output = ""
|
||||
self._closed = False
|
||||
|
||||
self._cwd = str(Path(self.work_dir).resolve())
|
||||
self._initialized = True
|
||||
|
||||
assert self.server is not None
|
||||
assert self.session is not None
|
||||
assert self.window is not None
|
||||
assert self.pane is not None
|
||||
|
||||
def _get_pane_content(self) -> str:
|
||||
if not self.pane:
|
||||
raise RuntimeError("Terminal session not properly initialized")
|
||||
return "\n".join(
|
||||
line.rstrip() for line in self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout
|
||||
)
|
||||
|
||||
def _clear_screen(self) -> None:
|
||||
if not self.pane:
|
||||
raise RuntimeError("Terminal session not properly initialized")
|
||||
self.pane.send_keys("C-l", enter=False)
|
||||
time.sleep(0.1)
|
||||
self.pane.cmd("clear-history")
|
||||
|
||||
def _is_control_key(self, command: str) -> bool:
|
||||
return (
|
||||
(command.startswith("C-") and len(command) >= 3)
|
||||
or (command.startswith("^") and len(command) >= 2)
|
||||
or (command.startswith("S-") and len(command) >= 3)
|
||||
or (command.startswith("M-") and len(command) >= 3)
|
||||
)
|
||||
|
||||
def _is_function_key(self, command: str) -> bool:
|
||||
if not command.startswith("F") or len(command) > 3:
|
||||
return False
|
||||
try:
|
||||
num_part = command[1:]
|
||||
return num_part.isdigit() and 1 <= int(num_part) <= 12
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
|
||||
def _is_navigation_or_special_key(self, command: str) -> bool:
|
||||
navigation_keys = {"Up", "Down", "Left", "Right", "Home", "End"}
|
||||
special_keys = {"BSpace", "BTab", "DC", "Enter", "Escape", "IC", "Space", "Tab"}
|
||||
page_keys = {"NPage", "PageDown", "PgDn", "PPage", "PageUp", "PgUp"}
|
||||
|
||||
return command in navigation_keys or command in special_keys or command in page_keys
|
||||
|
||||
def _is_complex_modifier_key(self, command: str) -> bool:
|
||||
return "-" in command and any(
|
||||
command.startswith(prefix)
|
||||
for prefix in ["C-S-", "C-M-", "S-M-", "M-S-", "M-C-", "S-C-"]
|
||||
)
|
||||
|
||||
def _is_special_key(self, command: str) -> bool:
|
||||
_command = command.strip()
|
||||
|
||||
if not _command:
|
||||
return False
|
||||
|
||||
return (
|
||||
self._is_control_key(_command)
|
||||
or self._is_function_key(_command)
|
||||
or self._is_navigation_or_special_key(_command)
|
||||
or self._is_complex_modifier_key(_command)
|
||||
)
|
||||
|
||||
def _matches_ps1_metadata(self, content: str) -> list[re.Match[str]]:
|
||||
return list(re.finditer(self.PS1_PATTERN + r"\]\$ ", content))
|
||||
|
||||
def _get_command_output(
|
||||
self,
|
||||
command: str,
|
||||
raw_command_output: str,
|
||||
continue_prefix: str = "",
|
||||
) -> str:
|
||||
if self.prev_output:
|
||||
command_output = raw_command_output.removeprefix(self.prev_output)
|
||||
if continue_prefix:
|
||||
command_output = continue_prefix + command_output
|
||||
else:
|
||||
command_output = raw_command_output
|
||||
self.prev_output = raw_command_output
|
||||
command_output = _remove_command_prefix(command_output, command)
|
||||
return command_output.rstrip()
|
||||
|
||||
def _combine_outputs_between_matches(
|
||||
self,
|
||||
pane_content: str,
|
||||
ps1_matches: list[re.Match[str]],
|
||||
get_content_before_last_match: bool = False,
|
||||
) -> str:
|
||||
if len(ps1_matches) == 1:
|
||||
if get_content_before_last_match:
|
||||
return pane_content[: ps1_matches[0].start()]
|
||||
return pane_content[ps1_matches[0].end() + 1 :]
|
||||
if len(ps1_matches) == 0:
|
||||
return pane_content
|
||||
|
||||
combined_output = ""
|
||||
for i in range(len(ps1_matches) - 1):
|
||||
output_segment = pane_content[ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()]
|
||||
combined_output += output_segment + "\n"
|
||||
combined_output += pane_content[ps1_matches[-1].end() + 1 :]
|
||||
return combined_output
|
||||
|
||||
def _extract_exit_code_from_matches(self, ps1_matches: list[re.Match[str]]) -> int | None:
|
||||
if not ps1_matches:
|
||||
return None
|
||||
|
||||
last_match = ps1_matches[-1]
|
||||
try:
|
||||
return int(last_match.group(1))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
def _handle_empty_command(
|
||||
self,
|
||||
cur_pane_output: str,
|
||||
ps1_matches: list[re.Match[str]],
|
||||
is_command_running: bool,
|
||||
timeout: float,
|
||||
) -> dict[str, Any]:
|
||||
if not is_command_running:
|
||||
raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
|
||||
command_output = self._get_command_output("", raw_command_output)
|
||||
return {
|
||||
"content": command_output,
|
||||
"status": "completed",
|
||||
"exit_code": 0,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
start_time = time.time()
|
||||
last_pane_output = cur_pane_output
|
||||
|
||||
while True:
|
||||
cur_pane_output = self._get_pane_content()
|
||||
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
||||
|
||||
if cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0:
|
||||
exit_code = self._extract_exit_code_from_matches(ps1_matches)
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
cur_pane_output, ps1_matches
|
||||
)
|
||||
command_output = self._get_command_output("", raw_command_output)
|
||||
self.prev_status = BashCommandStatus.COMPLETED
|
||||
self.prev_output = ""
|
||||
self._ready_for_next_command()
|
||||
return {
|
||||
"content": command_output,
|
||||
"status": "completed",
|
||||
"exit_code": exit_code or 0,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
if elapsed_time >= timeout:
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
cur_pane_output, ps1_matches
|
||||
)
|
||||
command_output = self._get_command_output("", raw_command_output)
|
||||
return {
|
||||
"content": command_output
|
||||
+ f"\n[Command still running after {timeout}s - showing output so far]",
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
if cur_pane_output != last_pane_output:
|
||||
last_pane_output = cur_pane_output
|
||||
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
|
||||
def _handle_input_command(
|
||||
self, command: str, no_enter: bool, is_command_running: bool
|
||||
) -> dict[str, Any]:
|
||||
if not is_command_running:
|
||||
return {
|
||||
"content": "No command is currently running. Cannot send input.",
|
||||
"status": "error",
|
||||
"exit_code": None,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
if not self.pane:
|
||||
raise RuntimeError("Terminal session not properly initialized")
|
||||
|
||||
is_special_key = self._is_special_key(command)
|
||||
should_add_enter = not is_special_key and not no_enter
|
||||
self.pane.send_keys(command, enter=should_add_enter)
|
||||
|
||||
time.sleep(2)
|
||||
cur_pane_output = self._get_pane_content()
|
||||
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
||||
raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
|
||||
command_output = self._get_command_output(command, raw_command_output)
|
||||
|
||||
is_still_running = not (
|
||||
cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
|
||||
)
|
||||
|
||||
if is_still_running:
|
||||
return {
|
||||
"content": command_output,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
exit_code = self._extract_exit_code_from_matches(ps1_matches)
|
||||
self.prev_status = BashCommandStatus.COMPLETED
|
||||
self.prev_output = ""
|
||||
self._ready_for_next_command()
|
||||
return {
|
||||
"content": command_output,
|
||||
"status": "completed",
|
||||
"exit_code": exit_code or 0,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
def _execute_new_command(self, command: str, no_enter: bool, timeout: float) -> dict[str, Any]:
|
||||
if not self.pane:
|
||||
raise RuntimeError("Terminal session not properly initialized")
|
||||
|
||||
initial_pane_output = self._get_pane_content()
|
||||
initial_ps1_matches = self._matches_ps1_metadata(initial_pane_output)
|
||||
initial_ps1_count = len(initial_ps1_matches)
|
||||
|
||||
start_time = time.time()
|
||||
last_pane_output = initial_pane_output
|
||||
|
||||
is_special_key = self._is_special_key(command)
|
||||
should_add_enter = not is_special_key and not no_enter
|
||||
self.pane.send_keys(command, enter=should_add_enter)
|
||||
|
||||
while True:
|
||||
cur_pane_output = self._get_pane_content()
|
||||
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
||||
current_ps1_count = len(ps1_matches)
|
||||
|
||||
if cur_pane_output != last_pane_output:
|
||||
last_pane_output = cur_pane_output
|
||||
|
||||
if current_ps1_count > initial_ps1_count or cur_pane_output.rstrip().endswith(
|
||||
self.PS1_END.rstrip()
|
||||
):
|
||||
exit_code = self._extract_exit_code_from_matches(ps1_matches)
|
||||
|
||||
get_content_before_last_match = bool(len(ps1_matches) == 1)
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
cur_pane_output,
|
||||
ps1_matches,
|
||||
get_content_before_last_match=get_content_before_last_match,
|
||||
)
|
||||
|
||||
command_output = self._get_command_output(command, raw_command_output)
|
||||
self.prev_status = BashCommandStatus.COMPLETED
|
||||
self.prev_output = ""
|
||||
self._ready_for_next_command()
|
||||
|
||||
return {
|
||||
"content": command_output,
|
||||
"status": "completed",
|
||||
"exit_code": exit_code or 0,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
if elapsed_time >= timeout:
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
cur_pane_output, ps1_matches
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
raw_command_output,
|
||||
continue_prefix="[Below is the output of the previous command.]\n",
|
||||
)
|
||||
self.prev_status = BashCommandStatus.CONTINUE
|
||||
|
||||
timeout_msg = (
|
||||
f"\n[Command still running after {timeout}s - showing output so far. "
|
||||
"Use C-c to interrupt if needed.]"
|
||||
)
|
||||
return {
|
||||
"content": command_output + timeout_msg,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
|
||||
def execute(
|
||||
self, command: str, is_input: bool = False, timeout: float = 10.0, no_enter: bool = False
|
||||
) -> dict[str, Any]:
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Bash session is not initialized")
|
||||
|
||||
cur_pane_output = self._get_pane_content()
|
||||
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
||||
is_command_running = not (
|
||||
cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
|
||||
)
|
||||
|
||||
if command.strip() == "":
|
||||
return self._handle_empty_command(
|
||||
cur_pane_output, ps1_matches, is_command_running, timeout
|
||||
)
|
||||
|
||||
is_special_key = self._is_special_key(command)
|
||||
|
||||
if is_input:
|
||||
return self._handle_input_command(command, no_enter, is_command_running)
|
||||
|
||||
if is_special_key and is_command_running:
|
||||
return self._handle_input_command(command, no_enter, is_command_running)
|
||||
|
||||
if is_command_running:
|
||||
return {
|
||||
"content": (
|
||||
"A command is already running. Use is_input=true to send input to it, "
|
||||
"or interrupt it first (e.g., with C-c)."
|
||||
),
|
||||
"status": "error",
|
||||
"exit_code": None,
|
||||
"working_dir": self._cwd,
|
||||
}
|
||||
|
||||
return self._execute_new_command(command, no_enter, timeout)
|
||||
|
||||
def _ready_for_next_command(self) -> None:
|
||||
self._clear_screen()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
if self._closed or not self.session:
|
||||
return False
|
||||
try:
|
||||
return self.session.id in [s.id for s in self.server.sessions] if self.server else False
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Error checking if session is running: %s", e)
|
||||
return False
|
||||
|
||||
def get_working_dir(self) -> str:
|
||||
return self._cwd
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
if self.session:
|
||||
try:
|
||||
self.session.kill()
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Error closing terminal session: %s", e)
|
||||
|
||||
self._closed = True
|
||||
self.server = None
|
||||
self.session = None
|
||||
self.window = None
|
||||
self.pane = None
|
||||
@@ -1,100 +0,0 @@
|
||||
"""SDK function-tool wrapper for the legacy ``terminal_execute`` tool.
|
||||
|
||||
The terminal lives in the sandbox container — each persistent tmux
|
||||
session is keyed by ``terminal_id`` on the in-container manager. The
|
||||
host-side wrapper is a thin pass-through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import dump_tool_result, strix_tool
|
||||
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||
|
||||
|
||||
@strix_tool(timeout=180)
|
||||
async def terminal_execute(
|
||||
ctx: RunContextWrapper,
|
||||
command: str,
|
||||
is_input: bool = False,
|
||||
timeout: float | None = None,
|
||||
terminal_id: str | None = None,
|
||||
no_enter: bool = False,
|
||||
) -> str:
|
||||
"""Run a shell command in the sandboxed Kali tmux session.
|
||||
|
||||
The session is **persistent** — environment variables, current
|
||||
directory, and running processes carry across calls keyed by
|
||||
``terminal_id`` (default: ``"default"``). Use distinct ids to run
|
||||
multiple concurrent sessions.
|
||||
|
||||
When to use this vs ``python_action``:
|
||||
|
||||
- Shell work: CLI tools (nmap, sqlmap, ffuf, nuclei), package
|
||||
managers, file/system commands, services, process control. Use
|
||||
``terminal_execute``.
|
||||
- Python code, data processing, HTTP automation, iterative scripting:
|
||||
use ``python_action`` instead — it's more structured and easier to
|
||||
debug. Don't run embedded Python via ``python -c`` or heredocs
|
||||
here.
|
||||
|
||||
Avoid long pipelines and complex bash one-liners; prefer multiple
|
||||
simple calls for clarity and debugging. For multi-step shell work,
|
||||
separate tool calls beat ``&& ; |``-chained commands.
|
||||
|
||||
Long-running commands:
|
||||
|
||||
- Commands are **never** killed automatically — they keep running
|
||||
after the timeout fires.
|
||||
- ``timeout`` (max 60s, capped) only controls how long to wait for
|
||||
output before returning. On timeout the call returns
|
||||
``status="running"``; on completion ``status="completed"``.
|
||||
- For daemons / very long jobs, append ``&`` to background.
|
||||
- Use an **empty command** to poll for new output from a running
|
||||
process (the call waits ``timeout`` seconds collecting output).
|
||||
- Use ``C-c`` / ``C-d`` / ``C-z`` to interrupt — special keys work
|
||||
automatically without setting ``is_input``.
|
||||
|
||||
Interactive processes:
|
||||
|
||||
- ``is_input=True`` sends the command as input to a running foreground
|
||||
process (REPL prompts, ``apt install`` y/n, etc.).
|
||||
- ``no_enter=True`` sends keystrokes without a trailing newline —
|
||||
useful for vim navigation (``gg``, ``5j``, ``i``), passwords, or
|
||||
multi-step keybindings.
|
||||
|
||||
Special key support (tmux key names): ``C-c``, ``C-d``, ``Up``,
|
||||
``Down``, ``F1``-``F12``, ``Enter``, ``Escape``, ``Tab``, ``Space``,
|
||||
``BSpace``, ``M-f`` (alt), ``S-Tab`` (shift), and combinations like
|
||||
``C-S-key``. Note: ``BSpace`` not ``Backspace``, ``Escape`` not
|
||||
``Esc``.
|
||||
|
||||
Working directory is tracked across calls and returned in the
|
||||
response. Large outputs are auto-truncated.
|
||||
|
||||
Args:
|
||||
command: Shell command, special key (``C-c``), or empty string
|
||||
to poll a running process.
|
||||
is_input: Treat ``command`` as input to a running foreground
|
||||
process. Special keys auto-detect; you only need this for
|
||||
regular text input.
|
||||
timeout: Seconds to wait before returning partial output. Capped
|
||||
at 60s. Defaults to 30s.
|
||||
terminal_id: Persistent session selector. Use distinct ids for
|
||||
concurrent sessions.
|
||||
no_enter: When True, sends keystrokes without a trailing return.
|
||||
"""
|
||||
return dump_tool_result(
|
||||
await post_to_sandbox(
|
||||
ctx,
|
||||
"terminal_execute",
|
||||
{
|
||||
"command": command,
|
||||
"is_input": is_input,
|
||||
"timeout": timeout,
|
||||
"terminal_id": terminal_id,
|
||||
"no_enter": no_enter,
|
||||
},
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user