Skip to content
Merged
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
68 changes: 59 additions & 9 deletions agentrun/integration/builtin/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ class PlaywrightError(Exception): # type: ignore[no-redef]
pass


try:
from greenlet import error as GreenletError
except ImportError:

class GreenletError(Exception): # type: ignore[no-redef]
"""Fallback greenlet error used when greenlet is not installed."""

pass


class SandboxToolSet(CommonToolSet):
"""沙箱工具集基类

Expand Down Expand Up @@ -727,24 +737,47 @@ def __init__(
polar_fs_config=polar_fs_config,
)
self._playwright_sync: Optional["BrowserPlaywrightSync"] = None
self._playwright_thread: Optional[threading.Thread] = None

def _get_playwright(self, sb: BrowserSandbox) -> "BrowserPlaywrightSync":
"""获取或创建 Playwright 连接 / Get or create Playwright connection

复用已有连接以减少连接建立开销和瞬态错误。
使用双重检查锁定避免并发调用时创建多个连接导致资源泄漏。
当创建连接的线程已退出时,自动重建连接(Playwright greenlet 绑定到创建它的线程)。

Reuses existing connection to reduce connection overhead and transient errors.
Uses double-checked locking to avoid leaking connections under concurrent calls.
Automatically recreates the connection when the thread that created it has exited,
because Playwright's internal greenlet is bound to the thread that created it.
"""
if self._playwright_sync is not None:
return self._playwright_sync
if self._playwright_sync is not None and self._playwright_thread is not None:
current_thread = threading.current_thread()
creator_thread = self._playwright_thread
if not creator_thread.is_alive() or current_thread is not creator_thread:
if not creator_thread.is_alive():
logger.debug(
"Playwright creating thread (id=%s) has exited, recreating"
" connection",
creator_thread.ident,
)
else:
logger.debug(
"Playwright creating thread (id=%s) differs from current"
" thread (id=%s), recreating connection",
creator_thread.ident,
current_thread.ident,
)
self._reset_playwright()

with self.lock:
if self._playwright_sync is None:
playwright_sync = sb.sync_playwright()
playwright_sync.open()
self._playwright_sync = playwright_sync
return self._playwright_sync
if self._playwright_sync is None:
with self.lock:
if self._playwright_sync is None:
playwright_sync = sb.sync_playwright()
playwright_sync.open()
self._playwright_sync = playwright_sync
self._playwright_thread = threading.current_thread()
return self._playwright_sync

def _reset_playwright(self) -> None:
"""重置 Playwright 连接 / Reset Playwright connection
Expand All @@ -763,6 +796,7 @@ def _reset_playwright(self) -> None:
exc_info=True,
)
self._playwright_sync = None
self._playwright_thread = None

def _run_in_sandbox(self, callback: Callable[[Sandbox], Any]) -> Any:
"""在沙箱中执行操作,智能区分错误类型 / Execute in sandbox with smart error handling
Expand Down Expand Up @@ -812,6 +846,22 @@ def _run_in_sandbox(self, callback: Callable[[Sandbox], Any]) -> Any:
"Browser tool-level error (no sandbox rebuild): %s", e
)
return {"error": f"{e!s}"}
except GreenletError as e:
logger.debug(
"Greenlet thread-binding error, resetting Playwright: %s",
e,
)
# Keep the existing sandbox (it is still healthy); only the
# Playwright connection needs to be recreated on this thread.
try:
self._reset_playwright()
return callback(sb)
except Exception as e2:
logger.debug(
"Retry after Playwright reset failed: %s",
e2,
)
return {"error": f"{e!s}"}
except Exception as e:
logger.debug("Unexpected error in browser sandbox: %s", e)
return {"error": f"{e!s}"}
Expand Down Expand Up @@ -881,7 +931,7 @@ def inner(sb: Sandbox):
def browser_navigate(
self,
url: str,
wait_until: str = "load",
wait_until: str = "domcontentloaded",
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""导航到 URL / Navigate to URL"""
Expand Down
10 changes: 5 additions & 5 deletions tests/unittests/integration/test_agentscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from .scenarios import Scenarios


class TestToolSet(CommonToolSet):
class SampleToolSet(CommonToolSet):
"""测试用工具集"""

def __init__(self, timezone: str = "UTC"):
Expand Down Expand Up @@ -150,9 +150,9 @@ def mocked_model(
return model("mock-model")

@pytest.fixture
def mocked_toolset(self) -> TestToolSet:
def mocked_toolset(self) -> SampleToolSet:
"""创建 mock 的工具集"""
return TestToolSet(timezone="UTC")
return SampleToolSet(timezone="UTC")

# =========================================================================
# 测试:简单对话(无工具调用)
Expand Down Expand Up @@ -194,7 +194,7 @@ async def test_multi_tool_calls(
self,
mock_server: MockLLMServer,
mocked_model: CommonModel,
mocked_toolset: TestToolSet,
mocked_toolset: SampleToolSet,
):
"""测试多工具同时调用"""
# 使用默认的多工具场景
Expand Down Expand Up @@ -223,7 +223,7 @@ async def test_stream_options_validation(
self,
mock_server: MockLLMServer,
mocked_model: CommonModel,
mocked_toolset: TestToolSet,
mocked_toolset: SampleToolSet,
):
"""测试 stream_options 在请求中的正确性"""
# 使用默认场景
Expand Down
Loading