mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-26 22:58:54 +00:00
* feat(uploads): lazy-load historical files via list_uploaded_files tool
Replace per-turn injection of all historical upload metadata with on-demand
discovery via a new `list_uploaded_files` built-in tool, following the same
deferred-discovery pattern used by skills.
- Rename <uploaded_files> block to <current_uploads> (current-run files only)
- Add list_uploaded_files tool with include_outline: bool|list[str]
- Extract outline helpers to shared deerflow/utils/file_outline.py
- Update system prompt to reflect lazy-loading behaviour
- Historical file scan removed from UploadsMiddleware.before_agent()
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(uploads): clear uploaded_files state when no new files in current turn
When before_agent() returns None on empty turns, the LastValue
uploaded_files field retains the previous turn's filenames.
list_uploaded_files then incorrectly excludes those files as
"current-run" files, making them invisible until the next upload.
Fix: return {"uploaded_files": []} instead of None to explicitly
clear state. Add two-turn regression test covering the exact
scenario from review feedback.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve CI lint errors and stale test assertion from merge
- Split long prompt line to fit 240-char limit
- Add missing `Any` import in list_uploaded_files_tool
- Remove unused `re` import in file_conversion (outline code moved)
- Remove unused `os` import in middleware test
- Fix test assertion: <uploaded_files> → <current_uploads> after main merge
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve CI lint errors and stale test assertion from merge
- Split long prompt line to fit 240-char limit
- Add missing `Any` import in list_uploaded_files_tool
- Remove unused `re` import in file_conversion (outline code moved)
- Remove unused `os` import in middleware test
- Fix test assertion: <uploaded_files> → <current_uploads> after main merge
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: add current_uploads to input sanitization exempt tags
The lazy-loading PR renamed <uploaded_files> to <current_uploads>.
The anti-drift guard scans all framework XML blocks and requires each
to be either blocked or explicitly exempted. current_uploads wraps
trusted server-generated file metadata, not user input, so it belongs
in the exempt set.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: regenerate replay golden after uploaded_files state change
before_agent now returns {"uploaded_files": []} instead of None,
adding uploaded_files to SSE values events. Regenerated via
DEERFLOW_WRITE_GOLDEN=1.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: review feedback — memory pipeline, stale tags, state clearing, nits
- Match both tags in memory stripping pipeline (uploaded_files|current_uploads)
- Remove stale uploaded_files from _BLOCKED_TAG_NAMES
- Clear uploaded_files on all before_agent early-return paths
- Fix ponytail: stray word in file_conversion re-export comment
- Remove dead total_omitted branch in _format_omitted_summary
- ruff format fixes
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: block current_uploads, sanitize only original user content
Per review feedback: instead of exempting <current_uploads> (which
allows user forgery), move it to _BLOCKED_TAG_NAMES and change
InputSanitizationMiddleware._process_request to scan only the
original user content (ORIGINAL_USER_CONTENT_KEY) when available.
Server-injected trusted blocks are no longer checked against the
blocked-tag denylist.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: clarify fallback reason in input sanitization comment
Co-Authored-By: Claude <noreply@anthropic.com>
* @
fix: third-round review feedback — state visibility, sanitization, regex, nits
- list_uploaded_files_tool: logger.warning instead of silent try/except
on runtime.state read failure (High)
- input_sanitization_middleware: _extract_text_from_content skips empty
text blocks to match message_content_to_text behaviour; rfind fallback
path logs warning for observability (Medium)
- memory pipeline regexes: backreference (?P<tag>)(?P=tag) in
message_processing.py and prompt.py (Low)
- file_conversion.py: re-export moved to top of file (Low)
- Tests: middleware→tool state bridge test; integrated forged-tag +
multimodal sanitization tests
PR #4174 — Follow-up issues: #4212, #4213, #4214
Co-Authored-By: Claude <noreply@anthropic.com>
@
* @
fix: 4th-round review — denylist, sanitization, scandir, nits
- Add "uploaded_files" back to _BLOCKED_TAG_NAMES (old tag still processed by
deermem; user forgery must be escaped) (consistency)
- Fix inaccurate rfind-fallback comment: UploadsMiddleware keeps string as
string, fallback is unreachable for strings (doc fix)
- Distinguish "empty string key" (upload without text) from "non-string key"
(caller forgery) so empty-text uploads never escape the server block (edge)
- Merge dual os.scandir(uploads_dir) calls into one list re-use (minor)
- Add comment on .md sibling skip known limitation: user-uploaded .md files
whose stem collides with a converted doc are hidden (boundary, no code change)
Co-Authored-By: Claude <noreply@anthropic.com>
@
* @
fix: tighten rfind-failure fallback — distinguish server blocks from user blocks
When _extract_text_from_content and message_content_to_text disagree on
multimodal list content and rfind fails, use content[0] (server-injected
<current_uploads> block) vs content[1:] (user blocks) to sanitize only
user blocks. Raw strings and non-standard dict blocks that
_extract_text_from_content misses are now also sanitized.
Non-distinguishable paths (< 2 text blocks, non-list content) still
degrade to full sanitization (safe — server block may be escaped but
user forgery never leaks). All fallback paths log via logger.warning.
Decision 18 / willem-bd 4th-round comment #3
Co-Authored-By: Claude <noreply@anthropic.com>
@
* @
fix: correct comments referencing text_blocks → content in rfind fallback
Co-Authored-By: Claude <noreply@anthropic.com>
@
* fix: 5th-round review — dead code, subagent gating, integration test, perf, consistency
- Delete unreachable ORIGINAL_USER_CONTENT_KEY guard in rfind fallback
branch (original_user_content guaranteed non-empty str at that point)
- Remove list_uploaded_files from BUILTIN_TOOLS; add include_upload_tool
param to get_available_tools(), default True; task_tool.py passes False
so subagents no longer receive a tool whose state exclusion is broken
- Add integration test exercising real create_agent graph (not mocked
runtime.state) to verify LangGraph propagates before_agent state writes
into ToolRuntime.state during same-turn tool calls
- Cache DirEntry.stat() st_size in candidates tuple to avoid second
per-file syscall in the rendering loop
- Make the upload-tag pre-check case-insensitive (content_str.lower())
to match _UPLOAD_BLOCK_RE re.IGNORECASE
PR #4174 — willem-bd 5th-round review items #1-#5
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(channels): pass files metadata through _human_input_message() for IM uploads
_human_input_message() was not passing additional_kwargs.files to the
downstream message. UploadsMiddleware read no files, wrote
uploaded_files=[], and list_uploaded_files reported same-run IM
attachments as historical files (fancyboi999 repro).
Fix: add files parameter to _human_input_message(), call site passes
files=uploaded. Regression test locks the contract.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(channels): remove legacy <uploaded_files> manual prepend to fix double-injection regression
Commit 8d86dbf6 added files= pass-through to UploadsMiddleware but
left the manual _format_uploaded_files_block() prepend in place.
Every IM attachment reached the model twice — once via the legacy
<uploaded_files> block and once via <current_uploads>.
This commit removes the manual prepend and the now-dead
_format_uploaded_files_block() function. UploadsMiddleware is the
sole upload-context producer for both IM and web paths.
Reported-by: fancyboi999 (PR review)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update #4212 issue body to reflect completed fixes and narrowed remaining scope
* chore: remove temporary scratch file
* fix(middleware): neutralize user-derived values inside <current_uploads> block
Upload-derived filenames, paths, outline titles, and preview text are
interpolated verbatim inside the trusted <current_uploads> wrapper,
which InputSanitizationMiddleware exempts from sanitization. A crafted
filename or document heading containing blocked authority tags would
bypass the guardrail and enter model context as trusted framework data.
Fix: call neutralize_untrusted_tags() on all four user-derived values
inside _format_file_entry(), preserving the outer <current_uploads>
wrapper untouched.
Reported-by: fancyboi999 (P1 security review)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(middleware): neutralize extension labels in omitted-file summary
Files exceeding the 10-item context cap bypass _format_file_entry().
Their extensions, derived from user-controlled filenames via
_extension_label(), were interpolated verbatim into the trusted
<current_uploads> wrapper — another path for blocked authority tags
to escape the guardrail.
Fix: neutralize extension values inside _extension_label(), the
single extraction point for all extension labels.
Reported-by: fancyboi999 (P1 security review)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(tools): neutralize user-derived values in list_uploaded_files tool result
Apply neutralize_untrusted_tags() to every model-visible user-derived value
returned by list_uploaded_files: filename, virtual path, extension, outline
titles, outline preview lines, and omitted-file extension summary.
This closes the last remaining injection bypass in the upload lazy-loading
path - the <current_uploads> block and its omitted summary were already
neutralized (previous commits), but the list_uploaded_files tool produced
a second exit for the same attacker-controlled metadata that
ToolResultSanitizationMiddleware did not cover.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(tests): add missing include_upload_tool=False to task_tool mock assertions
PR #4174 added include_upload_tool parameter to get_available_tools().
task_tool.py correctly passes include_upload_tool=False for subagents
but 5 existing tests' assert_called_once_with expectations were not
updated, causing CI failures.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
301 lines
13 KiB
Python
301 lines
13 KiB
Python
"""Middleware to inject current-run uploaded files into the agent context.
|
|
|
|
Historical uploads are no longer injected every turn — the agent discovers them
|
|
on demand via the ``list_uploaded_files`` tool.
|
|
"""
|
|
|
|
import logging
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
from typing import NotRequired, override
|
|
|
|
from langchain.agents import AgentState
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain_core.messages import HumanMessage
|
|
from langchain_core.runnables import run_in_executor
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.agents.middlewares.input_sanitization_middleware import neutralize_untrusted_tags
|
|
from deerflow.config.paths import Paths, get_paths
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
from deerflow.uploads.manager import is_upload_staging_file
|
|
from deerflow.utils.file_outline import extract_outline_for_file
|
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MAX_FILES_PER_CONTEXT_SECTION = 10
|
|
|
|
|
|
def _extension_label(file: dict) -> str:
|
|
extension = str(file.get("extension") or Path(str(file.get("filename") or "")).suffix).lower()
|
|
return neutralize_untrusted_tags(extension) or "(no extension)"
|
|
|
|
|
|
def _format_omitted_file_types(files: list[dict]) -> str:
|
|
counts = Counter(_extension_label(file) for file in files)
|
|
parts = [f"{count} {extension}" for extension, count in sorted(counts.items())]
|
|
return neutralize_untrusted_tags(", ".join(parts))
|
|
|
|
|
|
class UploadsMiddlewareState(AgentState):
|
|
"""State schema for uploads middleware."""
|
|
|
|
uploaded_files: NotRequired[list[dict] | None]
|
|
|
|
|
|
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|
"""Middleware to inject current-run uploaded files into the agent context.
|
|
|
|
Reads file metadata from the current message's additional_kwargs.files
|
|
(set by the frontend after upload) and prepends a <current_uploads> block
|
|
to the last human message so the model knows which files were just uploaded.
|
|
|
|
Historical uploads are NOT injected — the agent discovers them on demand
|
|
via the ``list_uploaded_files`` tool.
|
|
"""
|
|
|
|
state_schema = UploadsMiddlewareState
|
|
|
|
def __init__(
|
|
self,
|
|
base_dir: str | None = None,
|
|
*,
|
|
max_files_per_context_section: int = _MAX_FILES_PER_CONTEXT_SECTION,
|
|
):
|
|
"""Initialize the middleware.
|
|
|
|
Args:
|
|
base_dir: Base directory for thread data. Defaults to Paths resolution.
|
|
max_files_per_context_section: Maximum number of files listed in
|
|
each uploaded-files prompt section.
|
|
"""
|
|
super().__init__()
|
|
if max_files_per_context_section < 1:
|
|
raise ValueError("max_files_per_context_section must be at least 1")
|
|
self._paths = Paths(base_dir) if base_dir else get_paths()
|
|
self._max_files_per_context_section = max_files_per_context_section
|
|
|
|
def _format_file_entry(self, file: dict, lines: list[str]) -> None:
|
|
"""Append a single file entry (name, size, path, optional outline) to lines.
|
|
|
|
User-derived values (filename, path, outline titles, preview text) are
|
|
neutralized via ``neutralize_untrusted_tags`` so a crafted filename or
|
|
document cannot embed blocked authority tags inside the trusted
|
|
``<current_uploads>`` wrapper.
|
|
"""
|
|
size_kb = file["size"] / 1024
|
|
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
|
lines.append(f"- {neutralize_untrusted_tags(file['filename'])} ({size_str})")
|
|
lines.append(f" Path: {neutralize_untrusted_tags(file['path'])}")
|
|
if file.get("selection_reason") == "query_match":
|
|
lines.append(" Selected because: matched the current query.")
|
|
outline = file.get("outline") or []
|
|
if outline:
|
|
truncated = outline[-1].get("truncated", False)
|
|
visible = [e for e in outline if not e.get("truncated")]
|
|
lines.append(" Document outline (use `read_file` with line ranges to read sections):")
|
|
for entry in visible:
|
|
lines.append(f" L{entry['line']}: {neutralize_untrusted_tags(entry['title'])}")
|
|
if truncated:
|
|
lines.append(f" ... (showing first {len(visible)} headings; use `read_file` to explore further)")
|
|
else:
|
|
preview = file.get("outline_preview") or []
|
|
if preview:
|
|
lines.append(" No structural headings detected. Document begins with:")
|
|
for text in preview:
|
|
lines.append(f" > {neutralize_untrusted_tags(text)}")
|
|
lines.append(" Use `grep` to search for keywords (e.g. `grep(pattern='keyword', path='/mnt/user-data/uploads/')`).")
|
|
lines.append("")
|
|
|
|
def _select_files_for_context(
|
|
self,
|
|
files: list[dict],
|
|
) -> tuple[list[dict], list[dict]]:
|
|
"""Return bounded context files in upload order."""
|
|
selected = [dict(f) for f in files[: self._max_files_per_context_section]]
|
|
omitted = [dict(f) for f in files[self._max_files_per_context_section :]]
|
|
return selected, omitted
|
|
|
|
def _create_files_message(
|
|
self,
|
|
files: list[dict],
|
|
*,
|
|
omitted_files: list[dict] | None = None,
|
|
) -> str:
|
|
"""Create a formatted message listing current-run uploaded files.
|
|
|
|
Args:
|
|
files: Files uploaded in the current message.
|
|
omitted_files: Files omitted from the prompt context (over cap).
|
|
|
|
Returns:
|
|
Formatted string inside <current_uploads> tags.
|
|
"""
|
|
lines = ["<current_uploads>"]
|
|
|
|
lines.append("The following files were uploaded in this message:")
|
|
lines.append("")
|
|
if files:
|
|
for file in files:
|
|
self._format_file_entry(file, lines)
|
|
if omitted_files:
|
|
lines.append(f"... ({len(omitted_files)} more file(s) from this message omitted from this context.)")
|
|
lines.append(f" Omitted file types: {_format_omitted_file_types(omitted_files)}")
|
|
lines.append(" Use `glob(pattern='**/*', path='/mnt/user-data/uploads/')` to list all uploads.")
|
|
lines.append(" Use `grep(pattern='keyword', path='/mnt/user-data/uploads/')` to search across uploads.")
|
|
lines.append("")
|
|
else:
|
|
lines.append("(empty)")
|
|
lines.append("")
|
|
|
|
lines.append("To work with these files:")
|
|
lines.append("- Read from the file first — use the outline line numbers and `read_file` to locate relevant sections.")
|
|
lines.append("- Use `grep` to search for keywords when you are not sure which section to look at")
|
|
lines.append(" (e.g. `grep(pattern='revenue', path='/mnt/user-data/uploads/')`).")
|
|
lines.append("- Use `glob` to find files by name pattern")
|
|
lines.append(" (e.g. `glob(pattern='**/*.md', path='/mnt/user-data/uploads/')`).")
|
|
lines.append("- Only fall back to web search if the file content is clearly insufficient to answer the question.")
|
|
lines.append("</current_uploads>")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
|
|
"""Extract file info from message additional_kwargs.files.
|
|
|
|
The frontend sends uploaded file metadata in additional_kwargs.files
|
|
after a successful upload. Each entry has: filename, size (bytes),
|
|
path (virtual path), status.
|
|
|
|
Args:
|
|
message: The human message to inspect.
|
|
uploads_dir: Physical uploads directory used to verify file existence.
|
|
When provided, entries whose files no longer exist are skipped.
|
|
|
|
Returns:
|
|
List of file dicts with virtual paths, or None if the field is absent or empty.
|
|
"""
|
|
kwargs_files = (message.additional_kwargs or {}).get("files")
|
|
if not isinstance(kwargs_files, list) or not kwargs_files:
|
|
return None
|
|
|
|
files = []
|
|
for f in kwargs_files:
|
|
if not isinstance(f, dict):
|
|
continue
|
|
filename = f.get("filename") or ""
|
|
if not filename or Path(filename).name != filename or is_upload_staging_file(filename):
|
|
continue
|
|
if uploads_dir is not None and not (uploads_dir / filename).is_file():
|
|
continue
|
|
files.append(
|
|
{
|
|
"filename": filename,
|
|
"size": int(f.get("size") or 0),
|
|
"path": f"/mnt/user-data/uploads/{filename}",
|
|
"extension": Path(filename).suffix,
|
|
}
|
|
)
|
|
return files if files else None
|
|
|
|
@override
|
|
def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
|
"""Inject current-run uploads before agent execution.
|
|
|
|
Only files from the current message's additional_kwargs.files are listed.
|
|
Historical uploads are discovered on demand via ``list_uploaded_files``.
|
|
|
|
Prepends <current_uploads> context to the last human message content.
|
|
"""
|
|
messages = list(state.get("messages", []))
|
|
if not messages:
|
|
return {"uploaded_files": []}
|
|
|
|
last_message_index = len(messages) - 1
|
|
last_message = messages[last_message_index]
|
|
|
|
if not isinstance(last_message, HumanMessage):
|
|
return {"uploaded_files": []}
|
|
|
|
# Resolve uploads directory for existence checks
|
|
thread_id = (runtime.context or {}).get("thread_id")
|
|
if thread_id is None:
|
|
try:
|
|
from langgraph.config import get_config
|
|
|
|
thread_id = get_config().get("configurable", {}).get("thread_id")
|
|
except RuntimeError:
|
|
pass
|
|
uploads_dir = self._paths.sandbox_uploads_dir(thread_id, user_id=get_effective_user_id()) if thread_id else None
|
|
|
|
# Get newly uploaded files from the current message's additional_kwargs.files
|
|
new_files = self._files_from_kwargs(last_message, uploads_dir) or []
|
|
if not new_files:
|
|
# Clear stale uploaded_files so list_uploaded_files doesn't
|
|
# exclude files that became historical after the previous turn.
|
|
return {"uploaded_files": []}
|
|
|
|
context_files, omitted_files = self._select_files_for_context(new_files)
|
|
|
|
# Attach outlines to context files
|
|
if uploads_dir:
|
|
for file in context_files:
|
|
phys_path = uploads_dir / file["filename"]
|
|
outline, preview = extract_outline_for_file(phys_path)
|
|
file["outline"] = outline
|
|
file["outline_preview"] = preview
|
|
|
|
logger.debug(f"Current uploads: {[f['filename'] for f in new_files]}")
|
|
|
|
# Create files message and prepend to the last human message content
|
|
files_message = self._create_files_message(
|
|
context_files,
|
|
omitted_files=omitted_files if omitted_files else None,
|
|
)
|
|
|
|
original_content = last_message.content
|
|
additional_kwargs = dict(last_message.additional_kwargs or {})
|
|
original_user_content = additional_kwargs.get(ORIGINAL_USER_CONTENT_KEY)
|
|
if not isinstance(original_user_content, str):
|
|
if ORIGINAL_USER_CONTENT_KEY in additional_kwargs:
|
|
logger.warning(
|
|
"UploadsMiddleware replaced non-string %s metadata: type=%s",
|
|
ORIGINAL_USER_CONTENT_KEY,
|
|
type(original_user_content).__name__,
|
|
)
|
|
additional_kwargs[ORIGINAL_USER_CONTENT_KEY] = message_content_to_text(original_content)
|
|
if isinstance(original_content, str):
|
|
updated_content = f"{files_message}\n\n{original_content}"
|
|
elif isinstance(original_content, list):
|
|
files_block = {"type": "text", "text": f"{files_message}\n\n"}
|
|
updated_content = [files_block, *original_content]
|
|
else:
|
|
updated_content = original_content
|
|
|
|
updated_message = HumanMessage(
|
|
content=updated_content,
|
|
id=last_message.id,
|
|
name=last_message.name,
|
|
additional_kwargs=additional_kwargs,
|
|
)
|
|
|
|
messages[last_message_index] = updated_message
|
|
|
|
return {
|
|
"uploaded_files": new_files,
|
|
"messages": messages,
|
|
}
|
|
|
|
@override
|
|
async def abefore_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
|
"""Async hook that offloads the synchronous uploads scan off the event loop.
|
|
|
|
``before_agent`` performs blocking filesystem IO (directory enumeration,
|
|
``stat``, reading sibling ``.md`` outlines). When the graph runs async,
|
|
langgraph would otherwise execute the sync hook directly on the event
|
|
loop, so it is dispatched to a worker thread via ``run_in_executor``.
|
|
``run_in_executor`` copies the current context, so the ``user_id``
|
|
contextvar read by ``get_effective_user_id()`` is preserved.
|
|
"""
|
|
return await run_in_executor(None, self.before_agent, state, runtime)
|