mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 06:28:58 +00:00
fix(view-image): read active sandbox images from sandbox (#5306)
* 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
This commit is contained in:
parent
69f0f483eb
commit
3a6e681dee
@ -1,7 +1,7 @@
|
|||||||
"""Middleware for injecting image details into the model request."""
|
"""Middleware for injecting image details into the model request."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -14,12 +14,13 @@ from langchain.agents.middleware.types import ModelCallResult, ModelRequest, Mod
|
|||||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
|
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
|
from deerflow.sandbox.lease import run_sync_lifecycle_operation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Mirror the tool-side size cap as a defense-in-depth check. The tool
|
# Mirror the tool-side size cap as a defense-in-depth check. The tool
|
||||||
# enforces this at write time; the middleware re-checks at read time in
|
# enforces this at write time; the middleware re-checks at read time in
|
||||||
# case the file grew on disk between view and injection.
|
# case the file grew between view and injection.
|
||||||
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||||
_IMAGE_CONTEXT_MESSAGE_ID_PREFIX = "view-image-context:"
|
_IMAGE_CONTEXT_MESSAGE_ID_PREFIX = "view-image-context:"
|
||||||
_IMAGE_CONTEXT_MESSAGE_MARKER_KEY = "deerflow_view_image_context"
|
_IMAGE_CONTEXT_MESSAGE_MARKER_KEY = "deerflow_view_image_context"
|
||||||
@ -117,41 +118,153 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
|||||||
return tool_call_ids.issubset(completed_tool_ids)
|
return tool_call_ids.issubset(completed_tool_ids)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_image_as_data_url(actual_path: str, mime_type: str, expected_size: int) -> str | None:
|
def _encode_image_bytes(
|
||||||
"""Read image file and return a `data:` URL, or None on failure.
|
image_bytes: bytes,
|
||||||
|
mime_type: str,
|
||||||
|
expected_size: int,
|
||||||
|
expected_sha256: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate image bytes against recorded metadata and return a data URL."""
|
||||||
|
current_size = len(image_bytes)
|
||||||
|
if current_size != expected_size or current_size > _MAX_IMAGE_BYTES:
|
||||||
|
return None
|
||||||
|
if expected_sha256 is not None and hashlib.sha256(image_bytes).hexdigest() != expected_sha256:
|
||||||
|
return None
|
||||||
|
base64_data = base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
return f"data:{mime_type};base64,{base64_data}"
|
||||||
|
|
||||||
Trust assumption: ``actual_path`` is set by ``view_image_tool``
|
@classmethod
|
||||||
(server-side, validated against the allowed virtual roots at write
|
def _read_host_image_as_data_url(
|
||||||
time) and held in LangGraph-controlled state. Client input cannot
|
cls,
|
||||||
reach this field, so the read scope is trusted. We still re-check
|
actual_path: str,
|
||||||
size at read time to defend against TOCTOU growth and skip files
|
mime_type: str,
|
||||||
exceeding ``_MAX_IMAGE_BYTES``.
|
expected_size: int,
|
||||||
|
expected_sha256: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Read a validated host mirror and return a data URL, or None on failure.
|
||||||
|
|
||||||
|
``actual_path`` is server-set by ``view_image_tool`` and held in
|
||||||
|
LangGraph-controlled state. The host path remains the compatibility path
|
||||||
|
for local execution and older checkpoints. Provenance-aware checkpoints
|
||||||
|
additionally verify the exact SHA-256 before a synchronized host copy can
|
||||||
|
stand in for bytes from an earlier sandbox generation.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
file_path = Path(actual_path)
|
file_path = Path(actual_path)
|
||||||
if not file_path.exists() or not file_path.is_file():
|
if not file_path.exists() or not file_path.is_file():
|
||||||
return None
|
return None
|
||||||
current_size = file_path.stat().st_size
|
current_size = file_path.stat().st_size
|
||||||
if current_size != expected_size:
|
if current_size != expected_size or current_size > _MAX_IMAGE_BYTES:
|
||||||
# File changed between view and inject - skip.
|
|
||||||
return None
|
|
||||||
if current_size > _MAX_IMAGE_BYTES:
|
|
||||||
return None
|
return None
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
image_bytes = f.read()
|
image_bytes = f.read()
|
||||||
base64_data = base64.b64encode(image_bytes).decode("utf-8")
|
return cls._encode_image_bytes(
|
||||||
return f"data:{mime_type};base64,{base64_data}"
|
image_bytes,
|
||||||
|
mime_type,
|
||||||
|
expected_size,
|
||||||
|
expected_sha256,
|
||||||
|
)
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _read_image_as_data_url(
|
||||||
|
cls,
|
||||||
|
state: ViewImageMiddlewareState,
|
||||||
|
image_path: str,
|
||||||
|
actual_path: str,
|
||||||
|
mime_type: str,
|
||||||
|
expected_size: int,
|
||||||
|
expected_sha256: str | None,
|
||||||
|
source_sandbox_id: str | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Read the exact image bytes represented by ``viewed_images`` metadata.
|
||||||
|
|
||||||
|
A live sandbox is authoritative only for metadata recorded from that same
|
||||||
|
sandbox generation. If the thread now points at a replacement sandbox,
|
||||||
|
the previous image can be reconstructed from the synchronized host mirror
|
||||||
|
only when its SHA-256 exactly matches the bytes that ``view_image`` saw.
|
||||||
|
Legacy metadata without a digest never authorizes this cross-generation
|
||||||
|
fallback. When no live sandbox exists, the historical host compatibility
|
||||||
|
path remains available (digest-checked when present).
|
||||||
|
"""
|
||||||
|
from deerflow.sandbox.overwrite import unwrap_sandbox
|
||||||
|
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
||||||
|
|
||||||
|
sandbox_state, _ = unwrap_sandbox(state.get("sandbox"))
|
||||||
|
sandbox_id = sandbox_state.get("sandbox_id") if isinstance(sandbox_state, dict) else None
|
||||||
|
sandbox = get_sandbox_provider().get(sandbox_id) if sandbox_id else None
|
||||||
|
|
||||||
|
if sandbox is not None:
|
||||||
|
provenance_matches_live = source_sandbox_id == sandbox_id
|
||||||
|
provenance_identifies_other_source = expected_sha256 is not None and source_sandbox_id != sandbox_id
|
||||||
|
|
||||||
|
if provenance_identifies_other_source:
|
||||||
|
# The current client belongs to a different generation (or the
|
||||||
|
# image was originally read from the host). Reproduce the exact
|
||||||
|
# historical bytes rather than letting an unrelated same-path
|
||||||
|
# file in the replacement sandbox win.
|
||||||
|
if actual_path:
|
||||||
|
host_data_url = cls._read_host_image_as_data_url(
|
||||||
|
actual_path,
|
||||||
|
mime_type,
|
||||||
|
expected_size,
|
||||||
|
expected_sha256,
|
||||||
|
)
|
||||||
|
if host_data_url is not None:
|
||||||
|
return host_data_url
|
||||||
|
try:
|
||||||
|
image_bytes = sandbox.download_file(image_path)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to recover viewed image %s from replacement sandbox %s",
|
||||||
|
image_path,
|
||||||
|
sandbox_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return cls._encode_image_bytes(
|
||||||
|
image_bytes,
|
||||||
|
mime_type,
|
||||||
|
expected_size,
|
||||||
|
expected_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not provenance_matches_live and expected_sha256 is None:
|
||||||
|
# A legacy checkpoint cannot prove which sandbox generation
|
||||||
|
# supplied these bytes. Do not silently reinterpret historical
|
||||||
|
# image context through a newly active remote filesystem.
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_bytes = sandbox.download_file(image_path)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to read viewed image %s from sandbox %s", image_path, sandbox_id, exc_info=True)
|
||||||
|
return None
|
||||||
|
return cls._encode_image_bytes(
|
||||||
|
image_bytes,
|
||||||
|
mime_type,
|
||||||
|
expected_size,
|
||||||
|
expected_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not actual_path:
|
||||||
|
return None
|
||||||
|
return cls._read_host_image_as_data_url(
|
||||||
|
actual_path,
|
||||||
|
mime_type,
|
||||||
|
expected_size,
|
||||||
|
expected_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
def _create_image_details_message(self, state: ViewImageMiddlewareState) -> list[str | dict]:
|
def _create_image_details_message(self, state: ViewImageMiddlewareState) -> list[str | dict]:
|
||||||
"""Create a formatted message with all viewed image details.
|
"""Create a formatted message with all viewed image details.
|
||||||
|
|
||||||
Reads image files from disk on-demand and encodes them as base64
|
Reads image files on-demand from the active sandbox when available and
|
||||||
for the model. The base64 data is NOT persisted in state -- only
|
encodes them as base64 for the model. The base64 data is NOT persisted in
|
||||||
lightweight metadata (path, mime_type, size) is stored in
|
state -- only lightweight metadata (path, mime_type, size, digest, and
|
||||||
``viewed_images``, avoiding large duplicate payloads across every
|
source sandbox id when applicable) is stored in ``viewed_images``,
|
||||||
checkpoint (see #4138).
|
avoiding large duplicate payloads across every checkpoint (see #4138).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state: Current state containing viewed_images
|
state: Current state containing viewed_images
|
||||||
@ -171,22 +284,31 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
|||||||
mime_type = image_data.get("mime_type", "unknown")
|
mime_type = image_data.get("mime_type", "unknown")
|
||||||
actual_path = image_data.get("actual_path", "")
|
actual_path = image_data.get("actual_path", "")
|
||||||
expected_size = image_data.get("size", 0)
|
expected_size = image_data.get("size", 0)
|
||||||
|
expected_sha256 = image_data.get("sha256")
|
||||||
|
source_sandbox_id = image_data.get("source_sandbox_id")
|
||||||
|
|
||||||
# Add text description
|
# Add text description
|
||||||
content_blocks.append({"type": "text", "text": f"\n- **{image_path}** ({mime_type})"})
|
content_blocks.append({"type": "text", "text": f"\n- **{image_path}** ({mime_type})"})
|
||||||
|
|
||||||
# Read the image file on-demand and encode as base64 for the model
|
# Read the image file on-demand and encode as base64 for the model
|
||||||
if actual_path:
|
data_url = self._read_image_as_data_url(
|
||||||
data_url = self._read_image_as_data_url(actual_path, mime_type, expected_size)
|
state,
|
||||||
if data_url:
|
image_path,
|
||||||
content_blocks.append(
|
actual_path,
|
||||||
{
|
mime_type,
|
||||||
"type": "image_url",
|
expected_size,
|
||||||
"image_url": {"url": data_url},
|
expected_sha256 if isinstance(expected_sha256, str) else None,
|
||||||
}
|
source_sandbox_id if isinstance(source_sandbox_id, str) else None,
|
||||||
)
|
)
|
||||||
else:
|
if data_url:
|
||||||
content_blocks.append({"type": "text", "text": f" (file unavailable or changed on disk: {actual_path})"})
|
content_blocks.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": data_url},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content_blocks.append({"type": "text", "text": f" (file unavailable or changed: {image_path})"})
|
||||||
|
|
||||||
return content_blocks
|
return content_blocks
|
||||||
|
|
||||||
@ -284,6 +406,9 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
|||||||
request: ModelRequest,
|
request: ModelRequest,
|
||||||
handler: Callable[[ModelRequest], ModelResponse],
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
) -> ModelCallResult:
|
) -> ModelCallResult:
|
||||||
|
# Sync injection executes inline on this call stack. There is no detached
|
||||||
|
# worker to drain: an outer sandbox lease cannot reach its finally/release
|
||||||
|
# boundary until this blocking read returns or raises.
|
||||||
return handler(self._inject(request))
|
return handler(self._inject(request))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -293,5 +418,8 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
|||||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
) -> ModelCallResult:
|
) -> ModelCallResult:
|
||||||
# Image reads + base64 encoding can be slow (up to 20MB), so offload the
|
# Image reads + base64 encoding can be slow (up to 20MB), so offload the
|
||||||
# blocking work to a thread rather than stalling the event loop.
|
# blocking work without allowing cancellation to outlive a sandbox
|
||||||
return await handler(await asyncio.to_thread(self._inject, request))
|
# client operation. The outer run lease may release the client as soon as
|
||||||
|
# cancellation propagates.
|
||||||
|
injected_request = await run_sync_lifecycle_operation(self._inject, request)
|
||||||
|
return await handler(injected_request)
|
||||||
|
|||||||
@ -52,15 +52,17 @@ class BackgroundTaskState(TypedDict):
|
|||||||
class ViewedImageData(TypedDict):
|
class ViewedImageData(TypedDict):
|
||||||
"""Metadata for a viewed image file.
|
"""Metadata for a viewed image file.
|
||||||
|
|
||||||
Only lightweight metadata is persisted in checkpoint state; the actual
|
Only lightweight metadata is persisted in checkpoint state. Image bytes are
|
||||||
image bytes are read on-demand from disk when the model needs them.
|
read on-demand from the active sandbox or from a synchronized host copy whose
|
||||||
This avoids duplicating large base64 payloads across every checkpoint
|
size and SHA-256 match the previously viewed bytes. This avoids duplicating
|
||||||
(see #4138).
|
large base64 payloads across every checkpoint (see #4138).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mime_type: str
|
mime_type: str
|
||||||
size: int
|
size: int
|
||||||
actual_path: str
|
actual_path: str
|
||||||
|
sha256: str
|
||||||
|
source_sandbox_id: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
def merge_sandbox(existing: SandboxState | None, new: SandboxState | None) -> SandboxState | None:
|
def merge_sandbox(existing: SandboxState | None, new: SandboxState | None) -> SandboxState | None:
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
3. **Built-in tools**:
|
3. **Built-in tools**:
|
||||||
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`); virtual paths use `resolve_runtime_user_id(runtime)` so validation resolves the same user-scoped outputs directory established by `ThreadDataMiddleware`
|
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`); virtual paths use `resolve_runtime_user_id(runtime)` so validation resolves the same user-scoped outputs directory established by `ThreadDataMiddleware`
|
||||||
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary
|
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary
|
||||||
- `view_image` - Read image as base64 (added only if model supports vision)
|
- `view_image` - Read image bytes for vision-capable models; live sandbox bytes win for the same sandbox generation, replacement-sandbox recovery uses only SHA-256-verified synchronized host bytes, and async tool invocation drains blocking reads before cancellation may release the sandbox lease
|
||||||
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.
|
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.
|
||||||
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
|
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
|
||||||
4. **Subagent tool** (if enabled):
|
4. **Subagent tool** (if enabled):
|
||||||
@ -42,4 +42,4 @@ E2B output sync records remote file versions and actual host file metadata in a
|
|||||||
- MiniMax Code speaks ACP directly: configure `command: mcode` with `args: ["acp"]`. It receives DeerFlow's enabled MCP servers and uses the per-thread ACP workspace; the Gateway process must have an authenticated `mcode` executable on `PATH`
|
- MiniMax Code speaks ACP directly: configure `command: mcode` with `args: ["acp"]`. It receives DeerFlow's enabled MCP servers and uses the per-thread ACP workspace; the Gateway process must have an authenticated `mcode` executable on `PATH`
|
||||||
- ACP results collect only `agent_message_chunk` text. Thought chunks remain internal and must not be concatenated into the tool result
|
- ACP results collect only `agent_message_chunk` text. Thought chunks remain internal and must not be concatenated into the tool result
|
||||||
- Missing ACP executables now return an actionable error message instead of a raw `[Errno 2]`
|
- Missing ACP executables now return an actionable error message instead of a raw `[Errno 2]`
|
||||||
- Each ACP agent uses a per-thread workspace at `{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace/`. The workspace is accessible to the lead agent via the virtual path `/mnt/acp-workspace/` (read-only). In docker sandbox mode, the directory is volume-mounted into the container at `/mnt/acp-workspace` (read-only); in local sandbox mode, path translation is handled by `tools.py`
|
- Each ACP agent uses a per-thread workspace at `{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace/`. The workspace is accessible to the lead agent via the virtual path `/mnt/acp-workspace/` (read-only). In docker sandbox mode, the directory is volume-mounted into the container at `/mnt/acp-workspace` (read-only); in local sandbox mode, path translation is handled by `tools.py`
|
||||||
@ -1,9 +1,11 @@
|
|||||||
|
import hashlib
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain.tools import InjectedToolCallId, tool
|
from langchain.tools import InjectedToolCallId
|
||||||
from langchain_core.messages import ToolMessage
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import StructuredTool
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadDataState
|
from deerflow.agents.thread_state import ThreadDataState
|
||||||
@ -48,8 +50,60 @@ def _sanitize_image_error(error: Exception, thread_data: ThreadDataState | None)
|
|||||||
return mask_local_paths_in_output(f"{type(error).__name__}: {error}", thread_data)
|
return mask_local_paths_in_output(f"{type(error).__name__}: {error}", thread_data)
|
||||||
|
|
||||||
|
|
||||||
@tool("view_image", parse_docstring=True)
|
def _is_file_not_found_error(error: BaseException) -> bool:
|
||||||
def view_image_tool(
|
"""Recognize an explicit missing-file signal through provider wrappers.
|
||||||
|
|
||||||
|
``Sandbox.download_file`` promises ``OSError`` for read failures, while
|
||||||
|
remote SDKs expose missing paths in different explicit forms: builtin or
|
||||||
|
provider-defined ``FileNotFoundError`` types, E2B's
|
||||||
|
``FileNotFoundException``, and HTTP-style exceptions carrying
|
||||||
|
``status_code == 404``. Walk only explicit ``raise ... from`` causes so an
|
||||||
|
unrelated exception being handled when a transport failure is raised cannot
|
||||||
|
accidentally authorize historical host recovery. Error-message strings are
|
||||||
|
deliberately never parsed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
current: BaseException | None = error
|
||||||
|
seen: set[int] = set()
|
||||||
|
while current is not None and id(current) not in seen:
|
||||||
|
seen.add(id(current))
|
||||||
|
error_type = type(current)
|
||||||
|
if isinstance(current, FileNotFoundError) or error_type.__name__ == "FileNotFoundError":
|
||||||
|
return True
|
||||||
|
if error_type.__name__ == "FileNotFoundException" and error_type.__module__.split(".", 1)[0] == "e2b":
|
||||||
|
return True
|
||||||
|
if getattr(current, "status_code", None) == 404:
|
||||||
|
return True
|
||||||
|
current = current.__cause__
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_verified_host_copy(
|
||||||
|
actual_path: str | Path,
|
||||||
|
*,
|
||||||
|
expected_size: int,
|
||||||
|
expected_sha256: str,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Read a synchronized host image only when it matches prior metadata."""
|
||||||
|
|
||||||
|
path = Path(actual_path)
|
||||||
|
try:
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
return None
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size != expected_size or size > _MAX_IMAGE_BYTES:
|
||||||
|
return None
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if len(data) != size:
|
||||||
|
return None
|
||||||
|
if hashlib.sha256(data).hexdigest() != expected_sha256:
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _view_image(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
image_path: str,
|
image_path: str,
|
||||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||||
@ -69,6 +123,8 @@ def view_image_tool(
|
|||||||
image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp, gif.
|
image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp, gif.
|
||||||
"""
|
"""
|
||||||
from deerflow.sandbox.exceptions import SandboxRuntimeError
|
from deerflow.sandbox.exceptions import SandboxRuntimeError
|
||||||
|
from deerflow.sandbox.overwrite import unwrap_sandbox
|
||||||
|
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
||||||
from deerflow.sandbox.tools import (
|
from deerflow.sandbox.tools import (
|
||||||
get_thread_data,
|
get_thread_data,
|
||||||
resolve_and_validate_user_data_path,
|
resolve_and_validate_user_data_path,
|
||||||
@ -97,58 +153,108 @@ def view_image_tool(
|
|||||||
update={"messages": [ToolMessage(f"Error: {str(e)}", tool_call_id=tool_call_id)]},
|
update={"messages": [ToolMessage(f"Error: {str(e)}", tool_call_id=tool_call_id)]},
|
||||||
)
|
)
|
||||||
|
|
||||||
path = Path(actual_path)
|
image_suffix = Path(image_path).suffix.lower()
|
||||||
|
expected_mime_type = _EXTENSION_TO_MIME.get(image_suffix)
|
||||||
# Validate that the file exists
|
|
||||||
if not path.exists():
|
|
||||||
return Command(
|
|
||||||
update={"messages": [ToolMessage(f"Error: Image file not found: {image_path}", tool_call_id=tool_call_id)]},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate that it's a file (not a directory)
|
|
||||||
if not path.is_file():
|
|
||||||
return Command(
|
|
||||||
update={"messages": [ToolMessage(f"Error: Path is not a file: {image_path}", tool_call_id=tool_call_id)]},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate image extension
|
|
||||||
expected_mime_type = _EXTENSION_TO_MIME.get(path.suffix.lower())
|
|
||||||
if expected_mime_type is None:
|
if expected_mime_type is None:
|
||||||
return Command(
|
return Command(
|
||||||
update={"messages": [ToolMessage(f"Error: Unsupported image format: {path.suffix}. Supported formats: {', '.join(_EXTENSION_TO_MIME)}", tool_call_id=tool_call_id)]},
|
update={"messages": [ToolMessage(f"Error: Unsupported image format: {image_suffix}. Supported formats: {', '.join(_EXTENSION_TO_MIME)}", tool_call_id=tool_call_id)]},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Detect MIME type from file extension
|
mime_type, _ = mimetypes.guess_type(image_path)
|
||||||
mime_type, _ = mimetypes.guess_type(actual_path)
|
|
||||||
if mime_type is None:
|
if mime_type is None:
|
||||||
mime_type = expected_mime_type
|
mime_type = expected_mime_type
|
||||||
|
|
||||||
try:
|
state = runtime.state or {}
|
||||||
image_size = path.stat().st_size
|
sandbox_state, _ = unwrap_sandbox(state.get("sandbox"))
|
||||||
except OSError as e:
|
sandbox_id = sandbox_state.get("sandbox_id") if isinstance(sandbox_state, dict) else None
|
||||||
return Command(
|
sandbox = get_sandbox_provider().get(sandbox_id) if sandbox_id else None
|
||||||
update={"messages": [ToolMessage(f"Error reading image metadata: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
|
viewed_images = state.get("viewed_images")
|
||||||
)
|
previous_view = viewed_images.get(image_path) if isinstance(viewed_images, dict) else None
|
||||||
|
previous_source_id = previous_view.get("source_sandbox_id") if isinstance(previous_view, dict) else None
|
||||||
|
read_source_sandbox_id: str | None = None
|
||||||
|
|
||||||
|
if sandbox is not None:
|
||||||
|
try:
|
||||||
|
image_data = sandbox.download_file(image_path)
|
||||||
|
read_source_sandbox_id = sandbox_id
|
||||||
|
except IsADirectoryError:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error: Path is not a file: {image_path}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# A replacement sandbox may be live without containing files from
|
||||||
|
# the earlier generation. Recover only from an explicitly missing
|
||||||
|
# file and only when the synchronized host copy matches the exact
|
||||||
|
# metadata of the previously viewed image. Other live-client
|
||||||
|
# failures stay fail-closed so a stale mirror cannot mask them.
|
||||||
|
if _is_file_not_found_error(e) and isinstance(previous_view, dict) and previous_source_id != sandbox_id:
|
||||||
|
previous_size = previous_view.get("size")
|
||||||
|
previous_sha256 = previous_view.get("sha256")
|
||||||
|
if isinstance(previous_size, int) and isinstance(previous_sha256, str):
|
||||||
|
recovered = _read_verified_host_copy(
|
||||||
|
actual_path,
|
||||||
|
expected_size=previous_size,
|
||||||
|
expected_sha256=previous_sha256,
|
||||||
|
)
|
||||||
|
if recovered is not None:
|
||||||
|
image_data = recovered
|
||||||
|
else:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error: Image file not found: {image_path}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error: Image file not found: {image_path}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
elif _is_file_not_found_error(e):
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error: Image file not found: {image_path}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error reading image file: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
image_size = len(image_data)
|
||||||
|
else:
|
||||||
|
path = Path(actual_path)
|
||||||
|
if not path.exists():
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error: Image file not found: {image_path}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
if not path.is_file():
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error: Path is not a file: {image_path}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_size = path.stat().st_size
|
||||||
|
except OSError as e:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error reading image metadata: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
if image_size > _MAX_IMAGE_BYTES:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error: Image file is too large: {image_size} bytes. Maximum supported size is {_MAX_IMAGE_BYTES} bytes", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(actual_path, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage(f"Error reading image file: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(image_data) != image_size:
|
||||||
|
return Command(
|
||||||
|
update={"messages": [ToolMessage("Error: Image file changed during read", tool_call_id=tool_call_id)]},
|
||||||
|
)
|
||||||
|
|
||||||
if image_size > _MAX_IMAGE_BYTES:
|
if image_size > _MAX_IMAGE_BYTES:
|
||||||
return Command(
|
return Command(
|
||||||
update={"messages": [ToolMessage(f"Error: Image file is too large: {image_size} bytes. Maximum supported size is {_MAX_IMAGE_BYTES} bytes", tool_call_id=tool_call_id)]},
|
update={"messages": [ToolMessage(f"Error: Image file is too large: {image_size} bytes. Maximum supported size is {_MAX_IMAGE_BYTES} bytes", tool_call_id=tool_call_id)]},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read image file to validate contents (magic bytes + size)
|
|
||||||
try:
|
|
||||||
with open(actual_path, "rb") as f:
|
|
||||||
image_data = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
return Command(
|
|
||||||
update={"messages": [ToolMessage(f"Error reading image file: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(image_data) != image_size:
|
|
||||||
# File changed between stat() and read() - reject for safety.
|
|
||||||
return Command(
|
|
||||||
update={"messages": [ToolMessage("Error: Image file changed during read", tool_call_id=tool_call_id)]},
|
|
||||||
)
|
|
||||||
|
|
||||||
detected_mime_type = _detect_image_mime(image_data)
|
detected_mime_type = _detect_image_mime(image_data)
|
||||||
if detected_mime_type is None:
|
if detected_mime_type is None:
|
||||||
return Command(
|
return Command(
|
||||||
@ -160,17 +266,40 @@ def view_image_tool(
|
|||||||
)
|
)
|
||||||
mime_type = detected_mime_type
|
mime_type = detected_mime_type
|
||||||
|
|
||||||
# Store only lightweight metadata in state (not base64) to avoid
|
image_metadata = {
|
||||||
# duplicating large payloads across every checkpoint (see #4138).
|
"mime_type": mime_type,
|
||||||
# The middleware reads the file on-demand when the model needs it.
|
"size": image_size,
|
||||||
new_viewed_images = {
|
"actual_path": str(actual_path),
|
||||||
image_path: {
|
"sha256": hashlib.sha256(image_data).hexdigest(),
|
||||||
"mime_type": mime_type,
|
|
||||||
"size": image_size,
|
|
||||||
"actual_path": str(actual_path),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if read_source_sandbox_id is not None:
|
||||||
|
image_metadata["source_sandbox_id"] = read_source_sandbox_id
|
||||||
|
new_viewed_images = {image_path: image_metadata}
|
||||||
|
|
||||||
return Command(
|
return Command(
|
||||||
update={"viewed_images": new_viewed_images, "messages": [ToolMessage("Successfully read image", tool_call_id=tool_call_id)]},
|
update={"viewed_images": new_viewed_images, "messages": [ToolMessage("Successfully read image", tool_call_id=tool_call_id)]},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _aview_image(
|
||||||
|
runtime: Runtime,
|
||||||
|
image_path: str,
|
||||||
|
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||||
|
) -> Command:
|
||||||
|
"""Run the blocking image read without letting cancellation outlive it."""
|
||||||
|
from deerflow.sandbox.lease import run_sync_lifecycle_operation
|
||||||
|
|
||||||
|
return await run_sync_lifecycle_operation(
|
||||||
|
_view_image,
|
||||||
|
runtime,
|
||||||
|
image_path,
|
||||||
|
tool_call_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
view_image_tool = StructuredTool.from_function(
|
||||||
|
func=_view_image,
|
||||||
|
coroutine=_aview_image,
|
||||||
|
name="view_image",
|
||||||
|
parse_docstring=True,
|
||||||
|
)
|
||||||
|
|||||||
130
backend/tests/test_view_image_provider_error_classification.py
Normal file
130
backend/tests/test_view_image_provider_error_classification.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from deerflow.agents.thread_state import ViewedImageData
|
||||||
|
from deerflow.tools.builtins.view_image_tool import _is_file_not_found_error, view_image_tool
|
||||||
|
|
||||||
|
PNG_BYTES = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
||||||
|
|
||||||
|
|
||||||
|
class _HttpNotFoundError(Exception):
|
||||||
|
status_code = 404
|
||||||
|
|
||||||
|
|
||||||
|
class _ProviderFileNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_ProviderFileNotFoundError.__name__ = "FileNotFoundError"
|
||||||
|
|
||||||
|
|
||||||
|
class _HttpMissingSandbox:
|
||||||
|
id = "remote-new"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.downloads: list[str] = []
|
||||||
|
|
||||||
|
def download_file(self, path: str) -> bytes:
|
||||||
|
self.downloads.append(path)
|
||||||
|
try:
|
||||||
|
raise _HttpNotFoundError("not found")
|
||||||
|
except _HttpNotFoundError as error:
|
||||||
|
raise OSError(f"cannot read '{path}' from remote provider") from error
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def __init__(self, sandbox: _HttpMissingSandbox) -> None:
|
||||||
|
self.sandbox = sandbox
|
||||||
|
|
||||||
|
def get(self, sandbox_id: str):
|
||||||
|
return self.sandbox if sandbox_id == self.sandbox.id else None
|
||||||
|
|
||||||
|
|
||||||
|
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 test_classifier_accepts_explicit_http_404_cause():
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
raise _HttpNotFoundError("not found")
|
||||||
|
except _HttpNotFoundError as cause:
|
||||||
|
raise OSError("wrapped remote read failure") from cause
|
||||||
|
except OSError as error:
|
||||||
|
assert _is_file_not_found_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_accepts_provider_defined_file_not_found_type():
|
||||||
|
try:
|
||||||
|
raise _ProviderFileNotFoundError("not found")
|
||||||
|
except _ProviderFileNotFoundError as error:
|
||||||
|
assert _is_file_not_found_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_ignores_implicit_exception_context():
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
raise FileNotFoundError("unrelated cleanup miss")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise OSError("transport timeout")
|
||||||
|
except OSError as error:
|
||||||
|
assert error.__cause__ is None
|
||||||
|
assert isinstance(error.__context__, FileNotFoundError)
|
||||||
|
assert not _is_file_not_found_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_404_replacement_sandbox_recovers_verified_host_copy(tmp_path, monkeypatch):
|
||||||
|
thread_data = _thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
sandbox = _HttpMissingSandbox()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: _Provider(sandbox),
|
||||||
|
)
|
||||||
|
runtime = SimpleNamespace(
|
||||||
|
state={
|
||||||
|
"thread_data": thread_data,
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": {
|
||||||
|
"mime_type": "image/png",
|
||||||
|
"size": len(PNG_BYTES),
|
||||||
|
"actual_path": str(host_path),
|
||||||
|
"sha256": hashlib.sha256(PNG_BYTES).hexdigest(),
|
||||||
|
"source_sandbox_id": "remote-old",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context={"thread_id": "thread-1"},
|
||||||
|
config={},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-http-not-found",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.update["messages"][0].content == "Successfully read image"
|
||||||
|
viewed = result.update["viewed_images"]["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert viewed["sha256"] == hashlib.sha256(PNG_BYTES).hexdigest()
|
||||||
|
assert "source_sandbox_id" not in viewed
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewed_image_state_contract_includes_provenance_keys():
|
||||||
|
assert "sha256" in ViewedImageData.__required_keys__
|
||||||
|
assert "source_sandbox_id" in ViewedImageData.__optional_keys__
|
||||||
704
backend/tests/test_view_image_remote_sandbox.py
Normal file
704
backend/tests/test_view_image_remote_sandbox.py
Normal file
@ -0,0 +1,704 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain.agents.middleware.types import ModelRequest
|
||||||
|
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||||
|
from langchain_core.messages import AIMessage, ToolMessage
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||||
|
from deerflow.sandbox.lease import SandboxLeaseManager
|
||||||
|
from deerflow.tools.builtins.view_image_tool import view_image_tool
|
||||||
|
|
||||||
|
PNG_BYTES = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
||||||
|
STALE_SAME_SIZE_PNG_BYTES = PNG_BYTES[:-1] + bytes([PNG_BYTES[-1] ^ 1])
|
||||||
|
_E2BFileNotFound = type(
|
||||||
|
"FileNotFoundException",
|
||||||
|
(Exception,),
|
||||||
|
{"__module__": "e2b.filesystem.filesystem"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RemoteSandbox:
|
||||||
|
def __init__(self, image_bytes: bytes, *, sandbox_id: str = "remote-1") -> None:
|
||||||
|
self.id = sandbox_id
|
||||||
|
self.image_bytes = image_bytes
|
||||||
|
self.downloads: list[str] = []
|
||||||
|
self.released_scopes: list[str] = []
|
||||||
|
|
||||||
|
def download_file(self, path: str) -> bytes:
|
||||||
|
self.downloads.append(path)
|
||||||
|
return self.image_bytes
|
||||||
|
|
||||||
|
def release_command_scope(self, scope_id: str) -> None:
|
||||||
|
self.released_scopes.append(scope_id)
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingRemoteSandbox(_RemoteSandbox):
|
||||||
|
def download_file(self, path: str) -> bytes:
|
||||||
|
self.downloads.append(path)
|
||||||
|
raise OSError("remote download failed")
|
||||||
|
|
||||||
|
|
||||||
|
class _MissingRemoteSandbox(_RemoteSandbox):
|
||||||
|
def download_file(self, path: str) -> bytes:
|
||||||
|
self.downloads.append(path)
|
||||||
|
try:
|
||||||
|
raise _E2BFileNotFound("not found")
|
||||||
|
except _E2BFileNotFound as error:
|
||||||
|
raise OSError(f"Failed to download file '{path}' from sandbox: not found") from error
|
||||||
|
|
||||||
|
|
||||||
|
class _BlockingRemoteSandbox(_RemoteSandbox):
|
||||||
|
def __init__(self, image_bytes: bytes, *, sandbox_id: str = "remote-1") -> None:
|
||||||
|
super().__init__(image_bytes, sandbox_id=sandbox_id)
|
||||||
|
self.download_started = threading.Event()
|
||||||
|
self.allow_download = threading.Event()
|
||||||
|
|
||||||
|
def download_file(self, path: str) -> bytes:
|
||||||
|
self.downloads.append(path)
|
||||||
|
self.download_started.set()
|
||||||
|
assert self.allow_download.wait(timeout=5)
|
||||||
|
return self.image_bytes
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def __init__(self, sandbox: _RemoteSandbox | None) -> None:
|
||||||
|
self.sandbox = sandbox
|
||||||
|
self.acquire_calls: list[tuple[str | None, str | None]] = []
|
||||||
|
self.release_calls: list[str] = []
|
||||||
|
|
||||||
|
def acquire(self, thread_id=None, *, user_id=None):
|
||||||
|
self.acquire_calls.append((thread_id, user_id))
|
||||||
|
raise AssertionError("view_image must not acquire a replacement sandbox")
|
||||||
|
|
||||||
|
def get(self, sandbox_id: str):
|
||||||
|
if self.sandbox is None:
|
||||||
|
return None
|
||||||
|
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 _make_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 _image_metadata(
|
||||||
|
actual_path: Path,
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
source_sandbox_id: str | None = None,
|
||||||
|
include_digest: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
metadata: dict[str, object] = {
|
||||||
|
"mime_type": "image/png",
|
||||||
|
"size": len(image_bytes),
|
||||||
|
"actual_path": str(actual_path),
|
||||||
|
}
|
||||||
|
if include_digest:
|
||||||
|
metadata["sha256"] = hashlib.sha256(image_bytes).hexdigest()
|
||||||
|
if source_sandbox_id is not None:
|
||||||
|
metadata["source_sandbox_id"] = source_sandbox_id
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runtime(
|
||||||
|
thread_data: dict[str, str],
|
||||||
|
*,
|
||||||
|
sandbox_id: str = "remote-1",
|
||||||
|
viewed_images: dict[str, dict[str, object]] | None = None,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
state: dict[str, object] = {
|
||||||
|
"thread_data": thread_data,
|
||||||
|
"sandbox": {"sandbox_id": sandbox_id},
|
||||||
|
}
|
||||||
|
if viewed_images is not None:
|
||||||
|
state["viewed_images"] = viewed_images
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=state,
|
||||||
|
context={"thread_id": "thread-1"},
|
||||||
|
config={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_model_request(state: dict) -> ModelRequest:
|
||||||
|
assistant = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"name": "view_image",
|
||||||
|
"id": "call-view-image",
|
||||||
|
"args": {"image_path": "/mnt/user-data/outputs/plot.png"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
messages = [
|
||||||
|
assistant,
|
||||||
|
ToolMessage(content="Successfully read image", tool_call_id="call-view-image"),
|
||||||
|
]
|
||||||
|
return ModelRequest(
|
||||||
|
model=FakeMessagesListChatModel(responses=[AIMessage(content="ok")]),
|
||||||
|
messages=messages,
|
||||||
|
system_message=None,
|
||||||
|
tool_choice=None,
|
||||||
|
tools=[],
|
||||||
|
response_format=None,
|
||||||
|
state={"messages": messages, **state},
|
||||||
|
runtime=MagicMock(),
|
||||||
|
model_settings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_content(result) -> str:
|
||||||
|
return result.update["messages"][0].content
|
||||||
|
|
||||||
|
|
||||||
|
def _image_bytes_from_blocks(blocks: list[str | dict]) -> bytes:
|
||||||
|
image_blocks = [block for block in blocks if isinstance(block, dict) and block.get("type") == "image_url"]
|
||||||
|
assert len(image_blocks) == 1
|
||||||
|
data_url = image_blocks[0]["image_url"]["url"]
|
||||||
|
prefix = "data:image/png;base64,"
|
||||||
|
assert data_url.startswith(prefix)
|
||||||
|
return base64.b64decode(data_url[len(prefix) :])
|
||||||
|
|
||||||
|
|
||||||
|
def _image_block_count(blocks: list[str | dict]) -> int:
|
||||||
|
return sum(1 for block in blocks if isinstance(block, dict) and block.get("type") == "image_url")
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_reads_active_sandbox_when_host_mirror_is_missing(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
sandbox = _RemoteSandbox(PNG_BYTES)
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
assert not host_path.exists()
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(thread_data),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-remote",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result) == "Successfully read image"
|
||||||
|
viewed = result.update["viewed_images"]["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert viewed["size"] == len(PNG_BYTES)
|
||||||
|
assert viewed["sha256"] == hashlib.sha256(PNG_BYTES).hexdigest()
|
||||||
|
assert viewed["source_sandbox_id"] == sandbox.id
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert provider.acquire_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_prefers_active_sandbox_over_stale_host_mirror(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
remote_bytes = PNG_BYTES + b"remote-version"
|
||||||
|
sandbox = _RemoteSandbox(remote_bytes)
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(thread_data),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-stale",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result) == "Successfully read image"
|
||||||
|
viewed = result.update["viewed_images"]["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert viewed["size"] == len(remote_bytes)
|
||||||
|
assert viewed["sha256"] == hashlib.sha256(remote_bytes).hexdigest()
|
||||||
|
assert viewed["source_sandbox_id"] == sandbox.id
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert provider.acquire_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_falls_back_to_host_when_saved_sandbox_has_no_live_client(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
provider = _Provider(None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(thread_data),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-host-fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result) == "Successfully read image"
|
||||||
|
viewed = result.update["viewed_images"]["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert viewed["size"] == len(PNG_BYTES)
|
||||||
|
assert viewed["sha256"] == hashlib.sha256(PNG_BYTES).hexdigest()
|
||||||
|
assert "source_sandbox_id" not in viewed
|
||||||
|
assert provider.acquire_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_does_not_fall_back_after_live_sandbox_download_failure(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
sandbox = _FailingRemoteSandbox(PNG_BYTES)
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
previous = {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id="remote-old",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(thread_data, viewed_images=previous),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-live-failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result).startswith("Error reading image file:")
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert provider.acquire_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_recovers_verified_host_copy_when_replacement_sandbox_lacks_file(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
sandbox = _MissingRemoteSandbox(PNG_BYTES, sandbox_id="remote-new")
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
previous = {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id="remote-old",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(
|
||||||
|
thread_data,
|
||||||
|
sandbox_id=sandbox.id,
|
||||||
|
viewed_images=previous,
|
||||||
|
),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-replacement-host-fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result) == "Successfully read image"
|
||||||
|
viewed = result.update["viewed_images"]["/mnt/user-data/outputs/plot.png"]
|
||||||
|
assert viewed["sha256"] == hashlib.sha256(PNG_BYTES).hexdigest()
|
||||||
|
assert "source_sandbox_id" not in viewed
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_rejects_same_size_stale_host_copy_after_replacement(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(STALE_SAME_SIZE_PNG_BYTES)
|
||||||
|
sandbox = _MissingRemoteSandbox(PNG_BYTES, sandbox_id="remote-new")
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
previous = {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id="remote-old",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(
|
||||||
|
thread_data,
|
||||||
|
sandbox_id=sandbox.id,
|
||||||
|
viewed_images=previous,
|
||||||
|
),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-stale-host-rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result) == "Error: Image file not found: /mnt/user-data/outputs/plot.png"
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_same_generation_missing_file_stays_fail_closed(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
sandbox = _MissingRemoteSandbox(PNG_BYTES)
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
previous = {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id=sandbox.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(thread_data, viewed_images=previous),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-same-generation-missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result) == "Error: Image file not found: /mnt/user-data/outputs/plot.png"
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_image_legacy_metadata_does_not_authorize_cross_generation_fallback(tmp_path, monkeypatch):
|
||||||
|
thread_data = _make_thread_data(tmp_path)
|
||||||
|
host_path = Path(thread_data["outputs_path"]) / "plot.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
sandbox = _MissingRemoteSandbox(PNG_BYTES, sandbox_id="remote-new")
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
previous = {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id="remote-old",
|
||||||
|
include_digest=False,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = view_image_tool.func(
|
||||||
|
runtime=_make_runtime(
|
||||||
|
thread_data,
|
||||||
|
sandbox_id=sandbox.id,
|
||||||
|
viewed_images=previous,
|
||||||
|
),
|
||||||
|
image_path="/mnt/user-data/outputs/plot.png",
|
||||||
|
tool_call_id="tc-legacy-no-digest",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _message_content(result) == "Error: Image file not found: /mnt/user-data/outputs/plot.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_injects_image_from_active_sandbox_without_host_copy(tmp_path, monkeypatch):
|
||||||
|
sandbox = _RemoteSandbox(PNG_BYTES)
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
tmp_path / "missing-host-copy.png",
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id=sandbox.id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks = ViewImageMiddleware()._create_image_details_message(state)
|
||||||
|
|
||||||
|
assert _image_bytes_from_blocks(blocks) == PNG_BYTES
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_prefers_active_sandbox_over_stale_host_mirror(tmp_path, monkeypatch):
|
||||||
|
host_path = tmp_path / "stale-host-copy.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
remote_bytes = PNG_BYTES + b"remote-version"
|
||||||
|
sandbox = _RemoteSandbox(remote_bytes)
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
remote_bytes,
|
||||||
|
source_sandbox_id=sandbox.id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks = ViewImageMiddleware()._create_image_details_message(state)
|
||||||
|
|
||||||
|
assert _image_bytes_from_blocks(blocks) == remote_bytes
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_falls_back_to_host_when_saved_sandbox_has_no_live_client(tmp_path, monkeypatch):
|
||||||
|
host_path = tmp_path / "synced-host-copy.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
provider = _Provider(None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"sandbox": {"sandbox_id": "remote-1"},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id="remote-old",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks = ViewImageMiddleware()._create_image_details_message(state)
|
||||||
|
|
||||||
|
assert _image_bytes_from_blocks(blocks) == PNG_BYTES
|
||||||
|
assert provider.acquire_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_uses_verified_host_copy_after_sandbox_replacement(tmp_path, monkeypatch):
|
||||||
|
host_path = tmp_path / "synced-old-image.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
sandbox = _MissingRemoteSandbox(PNG_BYTES, sandbox_id="remote-new")
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id="remote-old",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks = ViewImageMiddleware()._create_image_details_message(state)
|
||||||
|
|
||||||
|
assert _image_bytes_from_blocks(blocks) == PNG_BYTES
|
||||||
|
assert sandbox.downloads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_rejects_same_size_stale_host_after_sandbox_replacement(tmp_path, monkeypatch):
|
||||||
|
host_path = tmp_path / "stale-same-size.png"
|
||||||
|
host_path.write_bytes(STALE_SAME_SIZE_PNG_BYTES)
|
||||||
|
sandbox = _MissingRemoteSandbox(PNG_BYTES, sandbox_id="remote-new")
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id="remote-old",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks = ViewImageMiddleware()._create_image_details_message(state)
|
||||||
|
|
||||||
|
assert _image_block_count(blocks) == 0
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_same_generation_failure_does_not_use_host_copy(tmp_path, monkeypatch):
|
||||||
|
host_path = tmp_path / "matching-host-copy.png"
|
||||||
|
host_path.write_bytes(PNG_BYTES)
|
||||||
|
sandbox = _FailingRemoteSandbox(PNG_BYTES)
|
||||||
|
provider = _Provider(sandbox)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.sandbox.sandbox_provider.get_sandbox_provider",
|
||||||
|
lambda: provider,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
host_path,
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id=sandbox.id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks = ViewImageMiddleware()._create_image_details_message(state)
|
||||||
|
|
||||||
|
assert _image_block_count(blocks) == 0
|
||||||
|
assert sandbox.downloads == ["/mnt/user-data/outputs/plot.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_sync_read_finishes_before_lease_release(tmp_path, monkeypatch):
|
||||||
|
sandbox = _BlockingRemoteSandbox(PNG_BYTES)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
request = _make_model_request(
|
||||||
|
{
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
tmp_path / "missing-host-copy.png",
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id=sandbox.id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handler_called = threading.Event()
|
||||||
|
invocation_errors: list[BaseException] = []
|
||||||
|
|
||||||
|
def handler(_prepared: ModelRequest) -> AIMessage:
|
||||||
|
handler_called.set()
|
||||||
|
return AIMessage(content="ok")
|
||||||
|
|
||||||
|
def invoke_under_lease() -> None:
|
||||||
|
try:
|
||||||
|
ViewImageMiddleware().wrap_model_call(request, handler)
|
||||||
|
except BaseException as error:
|
||||||
|
invocation_errors.append(error)
|
||||||
|
finally:
|
||||||
|
manager.release("run-owner")
|
||||||
|
|
||||||
|
worker = threading.Thread(target=invoke_under_lease)
|
||||||
|
try:
|
||||||
|
worker.start()
|
||||||
|
assert sandbox.download_started.wait(timeout=1)
|
||||||
|
|
||||||
|
assert worker.is_alive()
|
||||||
|
assert not handler_called.is_set()
|
||||||
|
assert provider.release_calls == []
|
||||||
|
assert sandbox.released_scopes == []
|
||||||
|
|
||||||
|
sandbox.allow_download.set()
|
||||||
|
worker.join(timeout=2)
|
||||||
|
|
||||||
|
assert not worker.is_alive()
|
||||||
|
assert invocation_errors == []
|
||||||
|
assert handler_called.is_set()
|
||||||
|
assert provider.release_calls == [sandbox.id]
|
||||||
|
assert sandbox.released_scopes == ["run-owner"]
|
||||||
|
finally:
|
||||||
|
sandbox.allow_download.set()
|
||||||
|
worker.join(timeout=2)
|
||||||
|
if manager.binding_for("run-owner") is not None:
|
||||||
|
manager.release("run-owner")
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_middleware_cancellation_drains_sandbox_download_before_lease_release(tmp_path, monkeypatch):
|
||||||
|
sandbox = _BlockingRemoteSandbox(PNG_BYTES)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
request = _make_model_request(
|
||||||
|
{
|
||||||
|
"sandbox": {"sandbox_id": sandbox.id},
|
||||||
|
"viewed_images": {
|
||||||
|
"/mnt/user-data/outputs/plot.png": _image_metadata(
|
||||||
|
tmp_path / "missing-host-copy.png",
|
||||||
|
PNG_BYTES,
|
||||||
|
source_sandbox_id=sandbox.id,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handler_called = False
|
||||||
|
|
||||||
|
async def handler(_prepared: ModelRequest) -> AIMessage:
|
||||||
|
nonlocal handler_called
|
||||||
|
handler_called = True
|
||||||
|
return AIMessage(content="unexpected")
|
||||||
|
|
||||||
|
async def invoke_under_lease():
|
||||||
|
try:
|
||||||
|
return await ViewImageMiddleware().awrap_model_call(request, handler)
|
||||||
|
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 handler_called is False
|
||||||
|
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
|
||||||
|
manager.close()
|
||||||
134
backend/tests/test_view_image_tool_cancellation.py
Normal file
134
backend/tests/test_view_image_tool_cancellation.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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()
|
||||||
Loading…
x
Reference in New Issue
Block a user