mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-14 04:33:45 +00:00
* fix(tools): introduce Runtime type alias to eliminate Pydantic serialization warning
Add deerflow/tools/types.py with:
Runtime = ToolRuntime[dict[str, Any], ThreadState]
Replace every runtime: ToolRuntime[ContextT, ThreadState] and
runtime: ToolRuntime[dict[str, Any], ThreadState] annotation in
sandbox/tools.py, present_file_tool.py, task_tool.py, view_image_tool.py,
and skill_manage_tool.py with the new Runtime alias.
The unbound ContextT TypeVar (default None) caused
PydanticSerializationUnexpectedValue warnings on every tool call because
LangChain's BaseTool._parse_input calls model_dump() on the auto-generated
args_schema while DeerFlow passes a dict as runtime context.
Binding the context to dict[str, Any] aligns Pydantic's serialization
expectations with reality and removes the noise from all run modes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(tools): extend Runtime alias to setup_agent and update_agent tools
Replace bare ToolRuntime annotations in setup_agent_tool.py and
update_agent_tool.py with the shared Runtime alias introduced in the
previous commit, and add both tools to the Pydantic serialization
warning regression test (13 cases total).
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(tools): loosen Pydantic warning filter to avoid version-specific format
Replace the brittle "field_name='context'" substring check with a looser
"context" match so the assertion stays valid if Pydantic changes its
internal warning format across versions.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(tools): simplify warning filter and clean up docstring
Remove the "context" substring condition from the Pydantic warning
filter — asserting that no PydanticSerializationUnexpectedValue fires
at all is both simpler and more comprehensive, since the test payload
contains only the tool's own args plus runtime.
Also update the module docstring to remove the version-specific warning
format example that was inconsistent with the looser filter.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
import base64
|
|
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 and convert to base64
|
|
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)]},
|
|
)
|
|
|
|
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_base64 = base64.b64encode(image_data).decode("utf-8")
|
|
|
|
# Update viewed_images in state
|
|
# The merge_viewed_images reducer will handle merging with existing images
|
|
new_viewed_images = {image_path: {"base64": image_base64, "mime_type": mime_type}}
|
|
|
|
return Command(
|
|
update={"viewed_images": new_viewed_images, "messages": [ToolMessage("Successfully read image", tool_call_id=tool_call_id)]},
|
|
)
|