qin-chenghan 713ee544b7
fix(agents): stop persisting base64 image data in checkpoint state (#4140)
* fix(agents): stop persisting base64 image data in checkpoint state (#4138)

The viewed_images state field stored full base64-encoded image data,
which was duplicated across every subsequent checkpoint (O(n * steps)
growth). A single 1MB image viewed early in a conversation would be
re-stored in every checkpoint for the rest of the session.

Changes:
- ViewedImageData: replace base64 field with lightweight metadata
  (mime_type, size, actual_path)
- view_image_tool: store only metadata in state, no base64 encoding
- ViewImageMiddleware: read image files from disk on-demand in
  before_model and encode base64 temporarily for the model call
- Update all tests to use the new metadata-only format

This is the first step of #4138. The base64 data is no longer in
persistent state, but the injected HumanMessage (with base64 content)
still appears in the checkpoint for the step where it was injected.
Checkpoint retention policies and large tool result dedup are separate
follow-up items.

* fix(agents): address review feedback on #4140

- view_image_tool: remove stale 'convert to base64' comment, replace with
  'validate contents'; drop redundant image_size reassignment and add a
  TOCTOU guard that rejects files changed between stat() and read().
- view_image_middleware: extract _read_image_as_data_url helper that
  re-checks size against the recorded value AND the absolute cap
  (_MAX_IMAGE_BYTES). Document the trust assumption for actual_path
  (server-set, not client-settable) in the helper docstring.
- view_image_middleware: abefore_model now runs the blocking read+encode
  via asyncio.to_thread to avoid stalling the event loop on up to 20MB
  images.
- tests: add coverage for OSError during read, file-changed-since-view
  (TOCTOU), and size-exceeds-cap branches.
2026-07-14 23:02:26 +08:00

174 lines
6.3 KiB
Python

import mimetypes
from pathlib import Path
from typing import Annotated
from langchain.tools import InjectedToolCallId, tool
from langchain_core.messages import ToolMessage
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",
}
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"
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)
@tool("view_image", parse_docstring=True)
def view_image_tool(
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.
"""
from deerflow.sandbox.exceptions import SandboxRuntimeError
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)]},
)
path = Path(actual_path)
# 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:
return Command(
update={"messages": [ToolMessage(f"Error: Unsupported image format: {path.suffix}. Supported formats: {', '.join(_EXTENSION_TO_MIME)}", tool_call_id=tool_call_id)]},
)
# Detect MIME type from file extension
mime_type, _ = mimetypes.guess_type(actual_path)
if mime_type is None:
mime_type = expected_mime_type
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)]},
)
# 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)
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
# Store only lightweight metadata in state (not base64) to avoid
# duplicating large payloads across every checkpoint (see #4138).
# The middleware reads the file on-demand when the model needs it.
new_viewed_images = {
image_path: {
"mime_type": mime_type,
"size": image_size,
"actual_path": str(actual_path),
}
}
return Command(
update={"viewed_images": new_viewed_images, "messages": [ToolMessage("Successfully read image", tool_call_id=tool_call_id)]},
)