Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions astrbot/core/computer/booters/shipyard_neo.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import asyncio
import os
import shlex
import time
from typing import Any, cast

from astrbot.api import logger
from astrbot.core.tools.computer_tools.util import format_exception_message

from ..olayer import (
BrowserComponent,
Expand Down Expand Up @@ -36,6 +38,13 @@ def _maybe_model_dump(value: Any) -> dict[str, Any]:
return {}


def _format_shell_result_detail(result: dict[str, Any]) -> str:
exit_code = result.get("exit_code")
stderr = str(result.get("stderr", "") or "").strip()[:300]
stdout = str(result.get("stdout", "") or "").strip()[-200:]
return f"exit_code={exit_code}, stderr={stderr!r}, stdout_tail={stdout!r}"


def _slice_content_by_lines(
content: str,
*,
Expand Down Expand Up @@ -348,6 +357,9 @@ class ShipyardNeoBooter(ComputerBooter):

AUTO_SENTINEL = "__auto__"
DEFAULT_PROFILE = "python-default"
READY_CHECK_TIMEOUT_SECONDS = 90
READY_CHECK_INTERVAL_SECONDS = 2
READY_CHECK_COMMAND_TIMEOUT_SECONDS = 5

def __init__(
self,
Expand Down Expand Up @@ -449,6 +461,15 @@ async def boot(self, session_id: str) -> None:
self._python = NeoPythonComponent(self._sandbox)

caps = self.capabilities or ()
if "shell" in caps:
await self._wait_until_shell_ready(resolved_profile)
else:
logger.warning(
"[Computer] Shipyard Neo sandbox profile %s has no shell capability; "
"skill sync and shell tools may fail.",
resolved_profile,
)

self._browser = (
NeoBrowserComponent(self._sandbox) if "browser" in caps else None
)
Expand Down Expand Up @@ -533,6 +554,58 @@ async def _wait_until_ready(self, sandbox: Sandbox) -> None:
)
await asyncio.sleep(POLL_INTERVAL)

async def _wait_until_shell_ready(self, profile: str) -> None:
if self._sandbox is None or self._shell is None:
raise RuntimeError("ShipyardNeoBooter is not initialized.")

sandbox_id = getattr(self._sandbox, "id", "unknown")
deadline = time.monotonic() + self.READY_CHECK_TIMEOUT_SECONDS
attempt = 0
last_detail = "not checked"
logger.info(
"[Computer] Waiting for Shipyard Neo sandbox shell readiness: "
"id=%s, profile=%s, timeout=%ss",
sandbox_id,
profile,
self.READY_CHECK_TIMEOUT_SECONDS,
)

while True:
attempt += 1
try:
result = await self._shell.exec(
"true",
timeout=self.READY_CHECK_COMMAND_TIMEOUT_SECONDS,
)
if result.get("success", False):
logger.info(
"[Computer] Shipyard Neo sandbox shell is ready: "
"id=%s, attempts=%d",
sandbox_id,
attempt,
)
return
last_detail = _format_shell_result_detail(result)
except Exception as exc:
last_detail = format_exception_message(exc)

remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(
"Shipyard Neo sandbox shell was not ready within "
f"{self.READY_CHECK_TIMEOUT_SECONDS}s "
f"(id={sandbox_id}, profile={profile}, last_error={last_detail})"
)

logger.debug(
"[Computer] Shipyard Neo sandbox shell not ready yet: "
"id=%s, attempt=%d, last_error=%s",
sandbox_id,
attempt,
last_detail,
)
await asyncio.sleep(min(self.READY_CHECK_INTERVAL_SECONDS, remaining))

async def _resolve_profile(self, client: Any) -> str:
"""Pick the best profile for this session.

Expand Down
5 changes: 3 additions & 2 deletions astrbot/core/tools/computer_tools/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..registry import builtin_tool
from .util import (
check_admin_permission,
format_exception_message,
workspace_root_for_context,
)

Expand Down Expand Up @@ -110,7 +111,7 @@ async def call(
)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
return f"Error executing code: {format_exception_message(e)}"


@builtin_tool(config=_LOCAL_PYTHON_TOOL_CONFIG)
Expand Down Expand Up @@ -150,4 +151,4 @@ async def call(
)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
return f"Error executing code: {format_exception_message(e)}"
4 changes: 2 additions & 2 deletions astrbot/core/tools/computer_tools/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ..registry import builtin_tool
from .util import (
check_admin_permission,
format_exception_message,
is_local_runtime,
workspace_root_for_context,
)
Expand Down Expand Up @@ -136,8 +137,7 @@ async def call(
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
detail = str(e) or type(e).__name__
return f"Error executing command: {detail}"
return f"Error executing command: {format_exception_message(e)}"


def _is_self_detached_command(command: str) -> bool:
Expand Down
13 changes: 9 additions & 4 deletions astrbot/core/tools/computer_tools/shipyard_neo/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
format_exception_message,
)
from astrbot.core.tools.registry import builtin_tool

_SHIPYARD_NEO_TOOL_CONFIG = {
Expand Down Expand Up @@ -89,7 +92,7 @@ async def call(
)
return _to_json(result)
except Exception as e:
return f"Error executing browser command: {str(e)}"
return f"Error executing browser command: {format_exception_message(e)}"


@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
Expand Down Expand Up @@ -154,7 +157,9 @@ async def call(
)
return _to_json(result)
except Exception as e:
return f"Error executing browser batch command: {str(e)}"
return (
f"Error executing browser batch command: {format_exception_message(e)}"
)


@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
Expand Down Expand Up @@ -201,4 +206,4 @@ async def call(
)
return _to_json(result)
except Exception as e:
return f"Error running browser skill: {str(e)}"
return f"Error running browser skill: {format_exception_message(e)}"
5 changes: 5 additions & 0 deletions astrbot/core/tools/computer_tools/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ async def workspace_root_for_context(
return workspace_root(umo)


def format_exception_message(exc: BaseException) -> str:
message = str(exc).strip()
return message or exc.__class__.__name__


def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool:
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
Expand Down
111 changes: 111 additions & 0 deletions tests/test_profile_aware_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,114 @@ def test_browser_default_none(self):

booter = ComputerBooter()
assert booter.browser is None


# ShipyardNeoBooter shell readiness


class TestShipyardNeoBooterReadiness:
@pytest.mark.asyncio
async def test_boot_retries_until_shell_is_ready(self, monkeypatch):
from astrbot.core.computer.booters import shipyard_neo as module

calls = {"shell": 0, "sleeps": []}

class _FakeShellApi:
async def exec(self, command, timeout=30, cwd=None):
_ = timeout, cwd
calls["shell"] += 1
assert command == "true"
if calls["shell"] == 1:
raise TimeoutError()
return {"success": True, "output": "", "error": "", "exit_code": 0}

class _FakeSandbox:
id = "sandbox-ready"
profile = "browser-python"
status = type("_Status", (), {"value": "ready"})()
capabilities = ["shell", "python", "filesystem"]

def __init__(self):
self.shell = _FakeShellApi()

async def refresh(self):
return None

class _FakeBayClient:
def __init__(self, **kwargs):
self.kwargs = kwargs

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return None

async def create_sandbox(self, **kwargs):
self.create_kwargs = kwargs
return _FakeSandbox()

async def _fake_sleep(delay):
calls["sleeps"].append(delay)

monkeypatch.setattr(module, "BayClient", _FakeBayClient, raising=False)
monkeypatch.setattr(module.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(module.ShipyardNeoBooter, "READY_CHECK_TIMEOUT_SECONDS", 5)
monkeypatch.setattr(module.ShipyardNeoBooter, "READY_CHECK_INTERVAL_SECONDS", 0)

booter = module.ShipyardNeoBooter(
endpoint_url="http://bay:8114",
access_token="sk-bay-test",
profile="browser-python",
)
await booter.boot("session-1")

assert calls["shell"] == 2
assert calls["sleeps"] == [0]
assert booter.capabilities == ("shell", "python", "filesystem")

@pytest.mark.asyncio
async def test_boot_timeout_reports_empty_exception_type(self, monkeypatch):
from astrbot.core.computer.booters import shipyard_neo as module

class _FakeShellApi:
async def exec(self, command, timeout=30, cwd=None):
_ = command, timeout, cwd
raise TimeoutError()

class _FakeSandbox:
id = "sandbox-timeout"
profile = "browser-python"
status = type("_Status", (), {"value": "ready"})()
capabilities = ["shell", "python", "filesystem"]

def __init__(self):
self.shell = _FakeShellApi()

async def refresh(self):
return None

class _FakeBayClient:
def __init__(self, **kwargs):
self.kwargs = kwargs

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return None

async def create_sandbox(self, **kwargs):
self.create_kwargs = kwargs
return _FakeSandbox()

monkeypatch.setattr(module, "BayClient", _FakeBayClient, raising=False)
monkeypatch.setattr(module.ShipyardNeoBooter, "READY_CHECK_TIMEOUT_SECONDS", 0)

booter = module.ShipyardNeoBooter(
endpoint_url="http://bay:8114",
access_token="sk-bay-test",
profile="browser-python",
)
with pytest.raises(TimeoutError, match="last_error=TimeoutError"):
await booter.boot("session-1")
Loading