mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* fix(view-image): read remote sandbox images from sandbox * docs(tools): clarify view_image sandbox behavior * fix(view-image): address sandbox lifecycle review * fix(view-image): preserve image provenance across sandbox replacement * fix(view-image): address provider recovery review * fix(view-image): drain cancelled tool reads
135 lines
4.1 KiB
Python
135 lines
4.1 KiB
Python
import asyncio
|
|
import base64
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from langchain.tools import ToolRuntime
|
|
|
|
from deerflow.sandbox.lease import SandboxLeaseManager
|
|
from deerflow.tools.builtins.view_image_tool import view_image_tool
|
|
|
|
PNG_BYTES = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
|
|
|
|
|
class _BlockingRemoteSandbox:
|
|
id = "remote-1"
|
|
|
|
def __init__(self) -> None:
|
|
self.download_started = threading.Event()
|
|
self.allow_download = threading.Event()
|
|
self.downloads: list[str] = []
|
|
self.released_scopes: list[str] = []
|
|
|
|
def download_file(self, path: str) -> bytes:
|
|
self.downloads.append(path)
|
|
self.download_started.set()
|
|
assert self.allow_download.wait(timeout=5)
|
|
return PNG_BYTES
|
|
|
|
def release_command_scope(self, scope_id: str) -> None:
|
|
self.released_scopes.append(scope_id)
|
|
|
|
|
|
class _Provider:
|
|
def __init__(self, sandbox: _BlockingRemoteSandbox) -> None:
|
|
self.sandbox = sandbox
|
|
self.release_calls: list[str] = []
|
|
|
|
def get(self, sandbox_id: str):
|
|
return self.sandbox if sandbox_id == self.sandbox.id else None
|
|
|
|
def release(self, sandbox_id: str) -> None:
|
|
self.release_calls.append(sandbox_id)
|
|
|
|
|
|
def _thread_data(tmp_path: Path) -> dict[str, str]:
|
|
user_data = tmp_path / "threads" / "thread-1" / "user-data"
|
|
workspace = user_data / "workspace"
|
|
uploads = user_data / "uploads"
|
|
outputs = user_data / "outputs"
|
|
for directory in (workspace, uploads, outputs):
|
|
directory.mkdir(parents=True)
|
|
return {
|
|
"workspace_path": str(workspace),
|
|
"uploads_path": str(uploads),
|
|
"outputs_path": str(outputs),
|
|
}
|
|
|
|
|
|
def _runtime(tmp_path: Path, sandbox_id: str) -> ToolRuntime:
|
|
return ToolRuntime(
|
|
state={
|
|
"thread_data": _thread_data(tmp_path),
|
|
"sandbox": {"sandbox_id": sandbox_id},
|
|
},
|
|
context={"thread_id": "thread-1"},
|
|
config={"configurable": {"thread_id": "thread-1"}},
|
|
stream_writer=lambda _: None,
|
|
tools=[],
|
|
tool_call_id="tc-tool-cancel",
|
|
store=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_view_image_ainvoke_drains_download_before_lease_release(tmp_path, monkeypatch):
|
|
sandbox = _BlockingRemoteSandbox()
|
|
provider = _Provider(sandbox)
|
|
manager = SandboxLeaseManager(provider)
|
|
manager.retain(
|
|
"run-owner",
|
|
sandbox.id,
|
|
thread_id="thread-1",
|
|
user_id="user-1",
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
|
lambda: provider,
|
|
)
|
|
runtime = _runtime(tmp_path, sandbox.id)
|
|
|
|
async def invoke_under_lease():
|
|
try:
|
|
return await view_image_tool.ainvoke(
|
|
{
|
|
"args": {
|
|
"runtime": runtime,
|
|
"image_path": "/mnt/user-data/outputs/plot.png",
|
|
},
|
|
"name": "view_image",
|
|
"type": "tool_call",
|
|
"id": "tc-tool-cancel",
|
|
}
|
|
)
|
|
finally:
|
|
await manager.release_async("run-owner")
|
|
|
|
task = asyncio.create_task(invoke_under_lease())
|
|
try:
|
|
assert await asyncio.to_thread(sandbox.download_started.wait, 1)
|
|
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
assert not task.done()
|
|
assert provider.release_calls == []
|
|
assert sandbox.released_scopes == []
|
|
|
|
sandbox.allow_download.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
|
assert provider.release_calls == [sandbox.id]
|
|
assert sandbox.released_scopes == ["run-owner"]
|
|
finally:
|
|
sandbox.allow_download.set()
|
|
if not task.done():
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
if manager.binding_for("run-owner") is not None:
|
|
await manager.release_async("run-owner")
|
|
manager.close()
|