mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 07:26:50 +00:00
fix(sandbox): avoid blocking sandbox readiness polling (#2822)
* fix(sandbox): offload async sandbox acquisition Run blocking sandbox provider acquisition through the async provider hook so eager sandbox setup does not stall the event loop. * fix(sandbox): add async readiness polling Introduce an async sandbox readiness poller using httpx and asyncio.sleep while preserving the existing synchronous API. * test(sandbox): cover async readiness polling Lock in non-blocking readiness behavior so the async helper does not regress to requests.get or time.sleep. * fix(sandbox): allow anonymous backend creation * fix(sandbox): use async readiness in provider acquisition * fix(sandbox): use async acquisition for lazy tools * test(sandbox): cover anonymous remote creation * fix(sandbox): clamp async readiness timeout budget * fix(sandbox): offload async lock file handling * fix(sandbox): delegate async middleware fallthrough * docs(sandbox): document async acquisition path * fix(sandbox): offload async sandbox release * docs(sandbox): mention async release hook * fix(sandbox): address async lock review Reduce duplicate sync/async sandbox acquisition state handling and move async thread-lock waits onto a dedicated executor with cancellation-safe cleanup. * chore: retrigger ci Retrigger GitHub Actions after upstream main fixed the stale PR merge lint failure. * test(sandbox): sync backend unit fixtures --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Tests for AioSandboxProvider mount helpers."""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -140,6 +141,182 @@ def test_discover_or_create_only_unlocks_when_lock_succeeds(tmp_path, monkeypatc
|
||||
assert unlock_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acquire_async_uses_async_readiness_polling(monkeypatch):
|
||||
"""AioSandboxProvider async creation must not use sync readiness polling."""
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
provider = _make_provider(None)
|
||||
provider._config = {"replicas": 3}
|
||||
provider._thread_locks = {}
|
||||
provider._warm_pool = {}
|
||||
provider._sandbox_infos = {}
|
||||
provider._thread_sandboxes = {}
|
||||
provider._last_activity = {}
|
||||
provider._lock = aio_mod.threading.Lock()
|
||||
provider._backend = SimpleNamespace(
|
||||
create=MagicMock(return_value=aio_mod.SandboxInfo(sandbox_id="sandbox-async", sandbox_url="http://sandbox")),
|
||||
destroy=MagicMock(),
|
||||
discover=MagicMock(return_value=None),
|
||||
)
|
||||
|
||||
async_readiness_calls: list[tuple[str, int]] = []
|
||||
|
||||
async def fake_wait_for_sandbox_ready_async(sandbox_url: str, timeout: int = 30, poll_interval: float = 1.0) -> bool:
|
||||
async_readiness_calls.append((sandbox_url, timeout))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready_async", fake_wait_for_sandbox_ready_async)
|
||||
monkeypatch.setattr(
|
||||
aio_mod,
|
||||
"wait_for_sandbox_ready",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("sync readiness should not be used")),
|
||||
)
|
||||
|
||||
sandbox_id = await provider._create_sandbox_async("thread-async", "sandbox-async")
|
||||
|
||||
assert sandbox_id == "sandbox-async"
|
||||
assert async_readiness_calls == [("http://sandbox", 60)]
|
||||
assert provider._backend.destroy.call_count == 0
|
||||
assert provider._thread_sandboxes["thread-async"] == "sandbox-async"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_discover_or_create_with_lock_async_offloads_lock_file_open_and_close(tmp_path, monkeypatch):
|
||||
"""Async lock path must not open or close lock files on the event loop."""
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
provider = _make_provider(tmp_path)
|
||||
provider._discover_or_create_with_lock_async = aio_mod.AioSandboxProvider._discover_or_create_with_lock_async.__get__(
|
||||
provider,
|
||||
aio_mod.AioSandboxProvider,
|
||||
)
|
||||
provider._thread_locks = {}
|
||||
provider._warm_pool = {}
|
||||
provider._sandbox_infos = {}
|
||||
provider._thread_sandboxes = {"thread-async-lock": "sandbox-async-lock"}
|
||||
provider._sandboxes = {"sandbox-async-lock": aio_mod.AioSandbox(id="sandbox-async-lock", base_url="http://sandbox")}
|
||||
provider._last_activity = {}
|
||||
provider._lock = aio_mod.threading.Lock()
|
||||
provider._backend = SimpleNamespace(discover=MagicMock(return_value=None))
|
||||
|
||||
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
||||
|
||||
to_thread_calls: list[object] = []
|
||||
|
||||
async def fake_to_thread(func, /, *args, **kwargs):
|
||||
to_thread_calls.append(func)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(aio_mod.asyncio, "to_thread", fake_to_thread)
|
||||
|
||||
sandbox_id = await provider._discover_or_create_with_lock_async("thread-async-lock", "sandbox-async-lock")
|
||||
|
||||
assert sandbox_id == "sandbox-async-lock"
|
||||
assert aio_mod._open_lock_file in to_thread_calls
|
||||
assert any(getattr(func, "__name__", "") == "close" for func in to_thread_calls)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acquire_thread_lock_async_uses_dedicated_executor(monkeypatch):
|
||||
"""Per-thread lock waits should not consume the default asyncio.to_thread pool."""
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
lock = aio_mod.threading.Lock()
|
||||
|
||||
async def fail_to_thread(*_args, **_kwargs):
|
||||
raise AssertionError("thread-lock acquisition must not use asyncio.to_thread")
|
||||
|
||||
monkeypatch.setattr(aio_mod.asyncio, "to_thread", fail_to_thread)
|
||||
|
||||
await aio_mod._acquire_thread_lock_async(lock)
|
||||
try:
|
||||
assert not lock.acquire(blocking=False)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acquire_async_cancellation_does_not_leak_thread_lock(tmp_path):
|
||||
"""Cancelled async lock waiters must not leave the per-thread lock held."""
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
provider = _make_provider(tmp_path)
|
||||
provider._thread_locks = {}
|
||||
provider._warm_pool = {}
|
||||
provider._sandbox_infos = {}
|
||||
provider._thread_sandboxes = {}
|
||||
provider._last_activity = {}
|
||||
provider._lock = aio_mod.threading.Lock()
|
||||
|
||||
thread_id = "thread-cancel-lock"
|
||||
thread_lock = provider._get_thread_lock(thread_id)
|
||||
thread_lock.acquire()
|
||||
|
||||
task = asyncio.create_task(provider.acquire_async(thread_id))
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
thread_lock.release()
|
||||
deadline = asyncio.get_running_loop().time() + 1
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
acquired = thread_lock.acquire(blocking=False)
|
||||
if acquired:
|
||||
thread_lock.release()
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
pytest.fail("provider thread lock was leaked after cancelling acquire_async")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acquire_async_cancelled_waiter_does_not_block_successor(tmp_path, monkeypatch):
|
||||
"""A cancelled waiter must not prevent the next live waiter from acquiring."""
|
||||
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
||||
provider = _make_provider(tmp_path)
|
||||
provider._thread_locks = {}
|
||||
provider._warm_pool = {}
|
||||
provider._sandbox_infos = {}
|
||||
provider._thread_sandboxes = {}
|
||||
provider._last_activity = {}
|
||||
provider._lock = aio_mod.threading.Lock()
|
||||
|
||||
async def fake_acquire_internal_async(thread_id: str | None) -> str:
|
||||
assert thread_id == "thread-successor-lock"
|
||||
await asyncio.sleep(0)
|
||||
return "sandbox-successor"
|
||||
|
||||
monkeypatch.setattr(provider, "_acquire_internal_async", fake_acquire_internal_async)
|
||||
|
||||
thread_id = "thread-successor-lock"
|
||||
thread_lock = provider._get_thread_lock(thread_id)
|
||||
thread_lock.acquire()
|
||||
|
||||
cancelled_waiter = asyncio.create_task(provider.acquire_async(thread_id))
|
||||
await asyncio.sleep(0.05)
|
||||
cancelled_waiter.cancel()
|
||||
try:
|
||||
await cancelled_waiter
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
live_waiter = asyncio.create_task(provider.acquire_async(thread_id))
|
||||
thread_lock.release()
|
||||
|
||||
assert await asyncio.wait_for(live_waiter, timeout=1) == "sandbox-successor"
|
||||
|
||||
deadline = asyncio.get_running_loop().time() + 1
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
acquired = thread_lock.acquire(blocking=False)
|
||||
if acquired:
|
||||
thread_lock.release()
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
pytest.fail("provider thread lock was not released after successor acquire_async")
|
||||
|
||||
|
||||
def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
|
||||
"""Provisioner mode must receive user_id so PVC subPath matches user isolation."""
|
||||
remote_mod = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.community.aio_sandbox import backend as readiness
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *, responses: list[object], calls: list[str], timeout: float, request_timeouts: list[float] | None = None) -> None:
|
||||
self._responses = responses
|
||||
self._calls = calls
|
||||
self._timeout = timeout
|
||||
self._request_timeouts = request_timeouts
|
||||
|
||||
async def __aenter__(self) -> _FakeAsyncClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, url: str, *, timeout: float):
|
||||
self._calls.append(url)
|
||||
if self._request_timeouts is not None:
|
||||
self._request_timeouts.append(timeout)
|
||||
response = self._responses.pop(0)
|
||||
if isinstance(response, BaseException):
|
||||
raise response
|
||||
return response
|
||||
|
||||
|
||||
class _FakeLoop:
|
||||
def __init__(self, times: list[float]) -> None:
|
||||
self._times = times
|
||||
self._index = 0
|
||||
|
||||
def time(self) -> float:
|
||||
value = self._times[self._index]
|
||||
self._index += 1
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
sleeps: list[float] = []
|
||||
|
||||
def fake_client(*, timeout: float):
|
||||
return _FakeAsyncClient(
|
||||
responses=[SimpleNamespace(status_code=503), SimpleNamespace(status_code=200)],
|
||||
calls=calls,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(readiness.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(readiness.asyncio, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(readiness.requests, "get", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("requests.get should not be used")))
|
||||
monkeypatch.setattr(readiness.time, "sleep", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("time.sleep should not be used")))
|
||||
|
||||
assert await readiness.wait_for_sandbox_ready_async("http://sandbox", timeout=5, poll_interval=0.05) is True
|
||||
|
||||
assert calls == ["http://sandbox/v1/sandbox", "http://sandbox/v1/sandbox"]
|
||||
assert sleeps == [0.05]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wait_for_sandbox_ready_async_retries_request_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
sleeps: list[float] = []
|
||||
|
||||
def fake_client(*, timeout: float):
|
||||
return _FakeAsyncClient(
|
||||
responses=[readiness.httpx.ConnectError("not ready"), SimpleNamespace(status_code=200)],
|
||||
calls=calls,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(readiness.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(readiness.asyncio, "sleep", fake_sleep)
|
||||
|
||||
assert await readiness.wait_for_sandbox_ready_async("http://sandbox", timeout=5, poll_interval=0.01) is True
|
||||
|
||||
assert len(calls) == 2
|
||||
assert sleeps == [0.01]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wait_for_sandbox_ready_async_clamps_request_and_sleep_to_deadline(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
request_timeouts: list[float] = []
|
||||
sleeps: list[float] = []
|
||||
|
||||
def fake_client(*, timeout: float):
|
||||
return _FakeAsyncClient(
|
||||
responses=[SimpleNamespace(status_code=503)],
|
||||
calls=calls,
|
||||
timeout=timeout,
|
||||
request_timeouts=request_timeouts,
|
||||
)
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(readiness.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(readiness.asyncio, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(readiness.asyncio, "get_running_loop", lambda: _FakeLoop([100.0, 100.5, 101.75, 102.0]))
|
||||
|
||||
assert await readiness.wait_for_sandbox_ready_async("http://sandbox", timeout=2, poll_interval=1.0) is False
|
||||
|
||||
assert calls == ["http://sandbox/v1/sandbox"]
|
||||
assert request_timeouts == [1.5]
|
||||
assert sleeps == [0.25]
|
||||
@@ -159,6 +159,26 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
|
||||
assert info.sandbox_url == "http://k3s:31001"
|
||||
|
||||
|
||||
def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
|
||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
||||
|
||||
def mock_post(url: str, json: dict, timeout: int):
|
||||
assert url == "http://provisioner:8002/api/sandboxes"
|
||||
assert json == {
|
||||
"sandbox_id": "anon123",
|
||||
"thread_id": None,
|
||||
"user_id": "test-user-autouse",
|
||||
}
|
||||
assert timeout == 30
|
||||
return _StubResponse(payload={"sandbox_id": "anon123", "sandbox_url": "http://k3s:31002"})
|
||||
|
||||
monkeypatch.setattr(requests, "post", mock_post)
|
||||
|
||||
info = backend.create(None, "anon123")
|
||||
assert info.sandbox_id == "anon123"
|
||||
assert info.sandbox_url == "http://k3s:31002"
|
||||
|
||||
|
||||
def test_provisioner_create_raises_runtime_error_on_request_exception(monkeypatch):
|
||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.tools import ToolRuntime
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.sandbox.middleware import SandboxMiddleware
|
||||
from deerflow.sandbox.sandbox import Sandbox
|
||||
from deerflow.sandbox.sandbox_provider import SandboxProvider, reset_sandbox_provider, set_sandbox_provider
|
||||
from deerflow.sandbox.search import GrepMatch
|
||||
from deerflow.sandbox.tools import ls_tool
|
||||
|
||||
|
||||
class _SyncProvider(SandboxProvider):
|
||||
def __init__(self) -> None:
|
||||
self.thread_ids: list[str | None] = []
|
||||
|
||||
def acquire(self, thread_id: str | None = None) -> str:
|
||||
self.thread_ids.append(thread_id)
|
||||
return "sync-sandbox"
|
||||
|
||||
def get(self, sandbox_id: str) -> Sandbox | None:
|
||||
return None
|
||||
|
||||
def release(self, sandbox_id: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _SandboxStub(Sandbox):
|
||||
def execute_command(self, command: str) -> str:
|
||||
return "OK"
|
||||
|
||||
def read_file(self, path: str) -> str:
|
||||
return "content"
|
||||
|
||||
def download_file(self, path: str) -> bytes:
|
||||
return b"content"
|
||||
|
||||
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
|
||||
return ["/mnt/user-data/workspace/file.txt"]
|
||||
|
||||
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||
return None
|
||||
|
||||
def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
|
||||
return [], False
|
||||
|
||||
def grep(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
*,
|
||||
glob: str | None = None,
|
||||
literal: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
max_results: int = 100,
|
||||
) -> tuple[list[GrepMatch], bool]:
|
||||
return [], False
|
||||
|
||||
def update_file(self, path: str, content: bytes) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _AsyncOnlyProvider(SandboxProvider):
|
||||
def __init__(self) -> None:
|
||||
self.thread_ids: list[str | None] = []
|
||||
self.released_ids: list[str] = []
|
||||
self.sandbox = _SandboxStub("async-sandbox")
|
||||
|
||||
def acquire(self, thread_id: str | None = None) -> str:
|
||||
raise AssertionError("async middleware should not call sync acquire")
|
||||
|
||||
async def acquire_async(self, thread_id: str | None = None) -> str:
|
||||
self.thread_ids.append(thread_id)
|
||||
return "async-sandbox"
|
||||
|
||||
def get(self, sandbox_id: str) -> Sandbox | None:
|
||||
if sandbox_id == "async-sandbox":
|
||||
return self.sandbox
|
||||
return None
|
||||
|
||||
def release(self, sandbox_id: str) -> None:
|
||||
self.released_ids.append(sandbox_id)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_provider_default_acquire_async_offloads_sync_acquire(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = _SyncProvider()
|
||||
calls: list[tuple[object, tuple[object, ...]]] = []
|
||||
|
||||
async def fake_to_thread(func, /, *args):
|
||||
calls.append((func, args))
|
||||
return func(*args)
|
||||
|
||||
monkeypatch.setattr(asyncio, "to_thread", fake_to_thread)
|
||||
|
||||
sandbox_id = await provider.acquire_async("thread-1")
|
||||
|
||||
assert sandbox_id == "sync-sandbox"
|
||||
assert provider.thread_ids == ["thread-1"]
|
||||
assert calls == [(provider.acquire, ("thread-1",))]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_abefore_agent_uses_async_provider_acquire() -> None:
|
||||
provider = _AsyncOnlyProvider()
|
||||
set_sandbox_provider(provider)
|
||||
try:
|
||||
middleware = SandboxMiddleware(lazy_init=False)
|
||||
|
||||
result = await middleware.abefore_agent({}, Runtime(context={"thread_id": "thread-2"}))
|
||||
finally:
|
||||
reset_sandbox_provider()
|
||||
|
||||
assert result == {"sandbox": {"sandbox_id": "async-sandbox"}}
|
||||
assert provider.thread_ids == ["thread-2"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("middleware", "state", "runtime"),
|
||||
[
|
||||
(SandboxMiddleware(lazy_init=True), {}, Runtime(context={"thread_id": "thread-lazy"})),
|
||||
(SandboxMiddleware(lazy_init=False), {}, Runtime(context={})),
|
||||
(SandboxMiddleware(lazy_init=False), {"sandbox": {"sandbox_id": "existing"}}, Runtime(context={"thread_id": "thread-existing"})),
|
||||
],
|
||||
)
|
||||
async def test_abefore_agent_delegates_to_super_when_not_acquiring(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
middleware: SandboxMiddleware,
|
||||
state: dict,
|
||||
runtime: Runtime,
|
||||
) -> None:
|
||||
calls: list[tuple[dict, Runtime]] = []
|
||||
|
||||
async def fake_super_abefore_agent(self, state_arg, runtime_arg):
|
||||
calls.append((state_arg, runtime_arg))
|
||||
return {"delegated": True}
|
||||
|
||||
monkeypatch.setattr(AgentMiddleware, "abefore_agent", fake_super_abefore_agent)
|
||||
|
||||
result = await middleware.abefore_agent(state, runtime)
|
||||
|
||||
assert result == {"delegated": True}
|
||||
assert calls == [(state, runtime)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_lazy_tool_acquisition_uses_async_provider() -> None:
|
||||
provider = _AsyncOnlyProvider()
|
||||
set_sandbox_provider(provider)
|
||||
try:
|
||||
runtime = ToolRuntime(
|
||||
state={},
|
||||
context={"thread_id": "thread-lazy"},
|
||||
config={"configurable": {}},
|
||||
stream_writer=lambda _: None,
|
||||
tools=[],
|
||||
tool_call_id="call-1",
|
||||
store=None,
|
||||
)
|
||||
|
||||
result = await ls_tool.ainvoke({"runtime": runtime, "description": "list workspace", "path": "/mnt/user-data/workspace"})
|
||||
finally:
|
||||
reset_sandbox_provider()
|
||||
|
||||
assert result == "/mnt/user-data/workspace/file.txt"
|
||||
assert provider.thread_ids == ["thread-lazy"]
|
||||
assert runtime.state["sandbox"] == {"sandbox_id": "async-sandbox"}
|
||||
assert runtime.context["sandbox_id"] == "async-sandbox"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("state", "runtime", "expected_sandbox_id"),
|
||||
[
|
||||
({"sandbox": {"sandbox_id": "state-sandbox"}}, Runtime(context={}), "state-sandbox"),
|
||||
({}, Runtime(context={"sandbox_id": "context-sandbox"}), "context-sandbox"),
|
||||
],
|
||||
)
|
||||
async def test_aafter_agent_releases_sandbox_off_thread(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
state: dict,
|
||||
runtime: Runtime,
|
||||
expected_sandbox_id: str,
|
||||
) -> None:
|
||||
provider = _AsyncOnlyProvider()
|
||||
to_thread_calls: list[tuple[object, tuple[object, ...]]] = []
|
||||
|
||||
async def fake_to_thread(func, /, *args):
|
||||
to_thread_calls.append((func, args))
|
||||
return func(*args)
|
||||
|
||||
monkeypatch.setattr(asyncio, "to_thread", fake_to_thread)
|
||||
set_sandbox_provider(provider)
|
||||
try:
|
||||
result = await SandboxMiddleware().aafter_agent(state, runtime)
|
||||
finally:
|
||||
reset_sandbox_provider()
|
||||
|
||||
assert result is None
|
||||
assert provider.released_ids == [expected_sandbox_id]
|
||||
assert to_thread_calls == [(provider.release, (expected_sandbox_id,))]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_aafter_agent_delegates_to_super_when_no_sandbox(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[dict, Runtime]] = []
|
||||
|
||||
async def fake_super_aafter_agent(self, state_arg, runtime_arg):
|
||||
calls.append((state_arg, runtime_arg))
|
||||
return {"delegated": True}
|
||||
|
||||
monkeypatch.setattr(AgentMiddleware, "aafter_agent", fake_super_aafter_agent)
|
||||
|
||||
state = {}
|
||||
runtime = Runtime(context={})
|
||||
result = await SandboxMiddleware().aafter_agent(state, runtime)
|
||||
|
||||
assert result == {"delegated": True}
|
||||
assert calls == [(state, runtime)]
|
||||
Reference in New Issue
Block a user