mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-18 02:38:54 +00:00
* 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.
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Middleware for injecting image details into conversation before LLM call."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import override
|
|
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.agents.thread_state import ThreadState
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 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
|
|
# case the file grew on disk between view and injection.
|
|
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
class ViewImageMiddlewareState(ThreadState):
|
|
"""Reuse the thread state so reducer-backed keys keep their annotations."""
|
|
|
|
|
|
class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
|
"""Injects image details as a human message before LLM calls when view_image tools have completed.
|
|
|
|
This middleware:
|
|
1. Runs before each LLM call
|
|
2. Checks if the last assistant message contains view_image tool calls
|
|
3. Verifies all tool calls in that message have been completed (have corresponding ToolMessages)
|
|
4. If conditions are met, creates a human message with all viewed image details (including base64 data)
|
|
5. Adds the message to state so the LLM can see and analyze the images
|
|
|
|
This enables the LLM to automatically receive and analyze images that were loaded via view_image tool,
|
|
without requiring explicit user prompts to describe the images.
|
|
"""
|
|
|
|
state_schema = ViewImageMiddlewareState
|
|
|
|
def _get_last_assistant_message(self, messages: list) -> AIMessage | None:
|
|
"""Get the last assistant message from the message list.
|
|
|
|
Args:
|
|
messages: List of messages
|
|
|
|
Returns:
|
|
Last AIMessage or None if not found
|
|
"""
|
|
for msg in reversed(messages):
|
|
if isinstance(msg, AIMessage):
|
|
return msg
|
|
return None
|
|
|
|
def _has_view_image_tool(self, message: AIMessage) -> bool:
|
|
"""Check if the assistant message contains view_image tool calls.
|
|
|
|
Args:
|
|
message: Assistant message to check
|
|
|
|
Returns:
|
|
True if message contains view_image tool calls
|
|
"""
|
|
if not hasattr(message, "tool_calls") or not message.tool_calls:
|
|
return False
|
|
|
|
return any(tool_call.get("name") == "view_image" for tool_call in message.tool_calls)
|
|
|
|
def _all_tools_completed(self, messages: list, assistant_msg: AIMessage) -> bool:
|
|
"""Check if all tool calls in the assistant message have been completed.
|
|
|
|
Args:
|
|
messages: List of all messages
|
|
assistant_msg: The assistant message containing tool calls
|
|
|
|
Returns:
|
|
True if all tool calls have corresponding ToolMessages
|
|
"""
|
|
if not hasattr(assistant_msg, "tool_calls") or not assistant_msg.tool_calls:
|
|
return False
|
|
|
|
# Get all tool call IDs from the assistant message
|
|
tool_call_ids = {tool_call.get("id") for tool_call in assistant_msg.tool_calls if tool_call.get("id")}
|
|
|
|
# Find the index of the assistant message
|
|
try:
|
|
assistant_idx = messages.index(assistant_msg)
|
|
except ValueError:
|
|
return False
|
|
|
|
# Get all ToolMessages after the assistant message
|
|
completed_tool_ids = set()
|
|
for msg in messages[assistant_idx + 1 :]:
|
|
if isinstance(msg, ToolMessage) and msg.tool_call_id:
|
|
completed_tool_ids.add(msg.tool_call_id)
|
|
|
|
# Check if all tool calls have been completed
|
|
return tool_call_ids.issubset(completed_tool_ids)
|
|
|
|
@staticmethod
|
|
def _read_image_as_data_url(actual_path: str, mime_type: str, expected_size: int) -> str | None:
|
|
"""Read image file and return a `data:` URL, or None on failure.
|
|
|
|
Trust assumption: ``actual_path`` is set by ``view_image_tool``
|
|
(server-side, validated against the allowed virtual roots at write
|
|
time) and held in LangGraph-controlled state. Client input cannot
|
|
reach this field, so the read scope is trusted. We still re-check
|
|
size at read time to defend against TOCTOU growth and skip files
|
|
exceeding ``_MAX_IMAGE_BYTES``.
|
|
"""
|
|
try:
|
|
file_path = Path(actual_path)
|
|
if not file_path.exists() or not file_path.is_file():
|
|
return None
|
|
current_size = file_path.stat().st_size
|
|
if current_size != expected_size:
|
|
# File changed between view and inject - skip.
|
|
return None
|
|
if current_size > _MAX_IMAGE_BYTES:
|
|
return None
|
|
with open(file_path, "rb") as f:
|
|
image_bytes = f.read()
|
|
base64_data = base64.b64encode(image_bytes).decode("utf-8")
|
|
return f"data:{mime_type};base64,{base64_data}"
|
|
except OSError:
|
|
return None
|
|
|
|
def _create_image_details_message(self, state: ViewImageMiddlewareState) -> list[str | dict]:
|
|
"""Create a formatted message with all viewed image details.
|
|
|
|
Reads image files from disk on-demand and encodes them as base64
|
|
for the model. The base64 data is NOT persisted in state -- only
|
|
lightweight metadata (path, mime_type, size) is stored in
|
|
``viewed_images``, avoiding large duplicate payloads across every
|
|
checkpoint (see #4138).
|
|
|
|
Args:
|
|
state: Current state containing viewed_images
|
|
|
|
Returns:
|
|
List of content blocks (text and images) for the HumanMessage
|
|
"""
|
|
viewed_images = state.get("viewed_images", {})
|
|
if not viewed_images:
|
|
# Return a properly formatted text block, not a plain string array
|
|
return [{"type": "text", "text": "No images have been viewed."}]
|
|
|
|
# Build the message with image information
|
|
content_blocks: list[str | dict] = [{"type": "text", "text": "Here are the images you've viewed:"}]
|
|
|
|
for image_path, image_data in viewed_images.items():
|
|
mime_type = image_data.get("mime_type", "unknown")
|
|
actual_path = image_data.get("actual_path", "")
|
|
expected_size = image_data.get("size", 0)
|
|
|
|
# Add text description
|
|
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
|
|
if actual_path:
|
|
data_url = self._read_image_as_data_url(actual_path, mime_type, expected_size)
|
|
if data_url:
|
|
content_blocks.append(
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": data_url},
|
|
}
|
|
)
|
|
else:
|
|
content_blocks.append({"type": "text", "text": f" (file unavailable or changed on disk: {actual_path})"})
|
|
|
|
return content_blocks
|
|
|
|
def _should_inject_image_message(self, state: ViewImageMiddlewareState) -> bool:
|
|
"""Determine if we should inject an image details message.
|
|
|
|
Args:
|
|
state: Current state
|
|
|
|
Returns:
|
|
True if we should inject the message
|
|
"""
|
|
messages = state.get("messages", [])
|
|
if not messages:
|
|
return False
|
|
|
|
# Get the last assistant message
|
|
last_assistant_msg = self._get_last_assistant_message(messages)
|
|
if not last_assistant_msg:
|
|
return False
|
|
|
|
# Check if it has view_image tool calls
|
|
if not self._has_view_image_tool(last_assistant_msg):
|
|
return False
|
|
|
|
# Check if all tools have been completed
|
|
if not self._all_tools_completed(messages, last_assistant_msg):
|
|
return False
|
|
|
|
# Check if we've already added an image details message
|
|
# Look for a human message after the last assistant message that contains image details
|
|
assistant_idx = messages.index(last_assistant_msg)
|
|
for msg in messages[assistant_idx + 1 :]:
|
|
if isinstance(msg, HumanMessage):
|
|
content_str = str(msg.content)
|
|
if "Here are the images you've viewed" in content_str or "Here are the details of the images you've viewed" in content_str:
|
|
# Already added, don't add again
|
|
return False
|
|
|
|
return True
|
|
|
|
def _inject_image_message(self, state: ViewImageMiddlewareState) -> dict | None:
|
|
"""Internal helper to inject image details message.
|
|
|
|
Args:
|
|
state: Current state
|
|
|
|
Returns:
|
|
State update with additional human message, or None if no update needed
|
|
"""
|
|
if not self._should_inject_image_message(state):
|
|
return None
|
|
|
|
# Create the image details message with text and image content
|
|
image_content = self._create_image_details_message(state)
|
|
|
|
# Create a new human message with mixed content (text + images). This is
|
|
# internal context for the model only, so hide it from the chat UI and IM
|
|
# channels (matches the other middleware-injected context messages).
|
|
human_msg = HumanMessage(content=image_content, additional_kwargs={"hide_from_ui": True})
|
|
|
|
logger.debug("Injecting image details message with images before LLM call")
|
|
|
|
# Return state update with the new message
|
|
return {"messages": [human_msg]}
|
|
|
|
@override
|
|
def before_model(self, state: ViewImageMiddlewareState, runtime: Runtime) -> dict | None:
|
|
"""Inject image details message before LLM call if view_image tools have completed (sync version).
|
|
|
|
This runs before each LLM call, checking if the previous turn included view_image
|
|
tool calls that have all completed. If so, it injects a human message with the image
|
|
details so the LLM can see and analyze the images.
|
|
|
|
Args:
|
|
state: Current state
|
|
runtime: Runtime context (unused but required by interface)
|
|
|
|
Returns:
|
|
State update with additional human message, or None if no update needed
|
|
"""
|
|
return self._inject_image_message(state)
|
|
|
|
@override
|
|
async def abefore_model(self, state: ViewImageMiddlewareState, runtime: Runtime) -> dict | None:
|
|
"""Inject image details message before LLM call if view_image tools have completed (async version).
|
|
|
|
This runs before each LLM call, checking if the previous turn included view_image
|
|
tool calls that have all completed. If so, it injects a human message with the image
|
|
details so the LLM can see and analyze the images.
|
|
|
|
Args:
|
|
state: Current state
|
|
runtime: Runtime context (unused but required by interface)
|
|
|
|
Returns:
|
|
State update with additional human message, or None if no update needed
|
|
"""
|
|
if not self._should_inject_image_message(state):
|
|
return None
|
|
# Image reads + base64 encoding can be slow (up to 20MB), so offload
|
|
# the blocking work to a thread rather than stalling the event loop.
|
|
image_content = await asyncio.to_thread(self._create_image_details_message, state)
|
|
human_msg = HumanMessage(content=image_content, additional_kwargs={"hide_from_ui": True})
|
|
logger.debug("Injecting image details message with images before LLM call")
|
|
return {"messages": [human_msg]}
|