Jun 3a6e681dee
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
2026-09-10 15:48:55 +08:00

306 lines
12 KiB
Python

import hashlib
import mimetypes
from pathlib import Path
from typing import Annotated
from langchain.tools import InjectedToolCallId
from langchain_core.messages import ToolMessage
from langchain_core.tools import StructuredTool
from langgraph.types import Command
from deerflow.agents.thread_state import ThreadDataState
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.tools.types import Runtime
_ALLOWED_IMAGE_VIRTUAL_ROOTS = (
f"{VIRTUAL_PATH_PREFIX}/workspace",
f"{VIRTUAL_PATH_PREFIX}/uploads",
f"{VIRTUAL_PATH_PREFIX}/outputs",
)
_ALLOWED_IMAGE_VIRTUAL_ROOTS_TEXT = ", ".join(_ALLOWED_IMAGE_VIRTUAL_ROOTS)
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
_EXTENSION_TO_MIME = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
}
def _is_allowed_image_virtual_path(image_path: str) -> bool:
return any(image_path == root or image_path.startswith(f"{root}/") for root in _ALLOWED_IMAGE_VIRTUAL_ROOTS)
def _detect_image_mime(image_data: bytes) -> str | None:
if image_data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if image_data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if len(image_data) >= 12 and image_data.startswith(b"RIFF") and image_data[8:12] == b"WEBP":
return "image/webp"
if image_data.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
return None
def _sanitize_image_error(error: Exception, thread_data: ThreadDataState | None) -> str:
from deerflow.sandbox.tools import mask_local_paths_in_output
return mask_local_paths_in_output(f"{type(error).__name__}: {error}", thread_data)
def _is_file_not_found_error(error: BaseException) -> bool:
"""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,
image_path: str,
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
"""Read an image file.
Use this tool to read an image file and make it available for display.
When to use the view_image tool:
- When you need to view an image file.
When NOT to use the view_image tool:
- For non-image files (use present_files instead)
- For multiple files at once (use present_files instead)
Args:
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.overwrite import unwrap_sandbox
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
from deerflow.sandbox.tools import (
get_thread_data,
resolve_and_validate_user_data_path,
validate_local_tool_path,
)
thread_data = get_thread_data(runtime)
if not _is_allowed_image_virtual_path(image_path):
return Command(
update={
"messages": [
ToolMessage(
f"Error: Only image paths under {_ALLOWED_IMAGE_VIRTUAL_ROOTS_TEXT} are allowed",
tool_call_id=tool_call_id,
)
]
},
)
try:
validate_local_tool_path(image_path, thread_data, read_only=True)
actual_path = resolve_and_validate_user_data_path(image_path, thread_data)
except (PermissionError, SandboxRuntimeError) as e:
return Command(
update={"messages": [ToolMessage(f"Error: {str(e)}", tool_call_id=tool_call_id)]},
)
image_suffix = Path(image_path).suffix.lower()
expected_mime_type = _EXTENSION_TO_MIME.get(image_suffix)
if expected_mime_type is None:
return Command(
update={"messages": [ToolMessage(f"Error: Unsupported image format: {image_suffix}. Supported formats: {', '.join(_EXTENSION_TO_MIME)}", tool_call_id=tool_call_id)]},
)
mime_type, _ = mimetypes.guess_type(image_path)
if mime_type is None:
mime_type = expected_mime_type
state = runtime.state or {}
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
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:
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)]},
)
detected_mime_type = _detect_image_mime(image_data)
if detected_mime_type is None:
return Command(
update={"messages": [ToolMessage("Error: File contents do not match a supported image format", tool_call_id=tool_call_id)]},
)
if detected_mime_type != expected_mime_type:
return Command(
update={"messages": [ToolMessage(f"Error: Image contents are {detected_mime_type}, but file extension indicates {expected_mime_type}", tool_call_id=tool_call_id)]},
)
mime_type = detected_mime_type
image_metadata = {
"mime_type": mime_type,
"size": image_size,
"actual_path": str(actual_path),
"sha256": hashlib.sha256(image_data).hexdigest(),
}
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(
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,
)