mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 17:46:20 +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
306 lines
12 KiB
Python
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,
|
|
)
|