mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 08:28:00 +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>
163 lines
6.8 KiB
Python
163 lines
6.8 KiB
Python
"""Shared document outline extraction.
|
|
|
|
Extracted from ``file_conversion.py`` and ``uploads_middleware.py`` so both
|
|
the middleware and the ``list_uploaded_files`` tool can use the same code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Regex for bold structural headings produced by pymupdf4llm when it can't
|
|
# promote bold text to a Markdown # heading (common in SEC filings).
|
|
#
|
|
# Chinese headings (第三节...) are already captured as standard # headings
|
|
# by pymupdf4llm, so they don't need this pattern.
|
|
_BOLD_HEADING_RE = re.compile(r"^\*\*((ITEM|PART|SECTION|SCHEDULE|EXHIBIT|APPENDIX|ANNEX|CHAPTER)\b[A-Z0-9 .,\-]*)\*\*\s*$")
|
|
|
|
# Regex for split-bold headings produced by pymupdf4llm when a heading spans
|
|
# multiple text spans in the PDF (e.g. section number and title are separate spans).
|
|
# Matches lines like: **1** **Introduction** or **3.2** **Multi-Head Attention**
|
|
# Requirements:
|
|
# 1. Entire line consists only of **...** blocks separated by whitespace (no prose)
|
|
# 2. First block is a section number (digits and dots, e.g. "1", "3.2", "A.1")
|
|
# 3. Second block must not be purely numeric/punctuation — excludes financial table
|
|
# headers like **2023** **2022** **2021** while allowing non-ASCII titles such as
|
|
# **1** **概述** or accented words (negative lookahead instead of [A-Za-z])
|
|
# 4. At most two additional blocks (four total) with [^*]+ (no * inside) to keep
|
|
# the regex linear and avoid ReDoS on attacker-controlled content
|
|
_SPLIT_BOLD_HEADING_RE = re.compile(r"^\*\*[\dA-Z][\d\.]*\*\*\s+\*\*(?!\d[\d\s.,\-–—/:()%]*\*\*)[^*]+\*\*(?:\s+\*\*[^*]+\*\*){0,2}\s*$")
|
|
|
|
# Maximum number of outline entries injected into the agent context.
|
|
# Keeps prompt size bounded even for very long documents.
|
|
MAX_OUTLINE_ENTRIES = 50
|
|
|
|
_OUTLINE_PREVIEW_LINES = 5
|
|
|
|
|
|
def _clean_bold_title(raw: str) -> str:
|
|
"""Normalise a title string that may contain pymupdf4llm bold artefacts.
|
|
|
|
pymupdf4llm sometimes emits adjacent bold spans as ``**A** **B**`` instead
|
|
of a single ``**A B**`` block. This helper merges those fragments and then
|
|
strips the outermost ``**...**`` wrapper so the caller gets plain text.
|
|
|
|
Examples::
|
|
|
|
"**Overview**" → "Overview"
|
|
"**UNITED STATES** **SECURITIES**" → "UNITED STATES SECURITIES"
|
|
"plain text" → "plain text" (unchanged)
|
|
"""
|
|
# Merge adjacent bold spans: "** **" → " "
|
|
merged = re.sub(r"\*\*\s*\*\*", " ", raw).strip()
|
|
# Strip outermost **...** if the whole string is wrapped
|
|
if m := re.fullmatch(r"\*\*(.+?)\*\*", merged, re.DOTALL):
|
|
return m.group(1).strip()
|
|
return merged
|
|
|
|
|
|
def extract_outline(md_path: Path) -> list[dict]:
|
|
"""Extract document outline (headings) from a Markdown file.
|
|
|
|
Recognises three heading styles produced by pymupdf4llm:
|
|
|
|
1. Standard Markdown headings: lines starting with one or more '#'.
|
|
Inline ``**...**`` wrappers and adjacent bold spans (``** **``) are
|
|
cleaned so the title is plain text.
|
|
|
|
2. Bold-only structural headings: ``**ITEM 1. BUSINESS**``, ``**PART II**``,
|
|
etc. SEC filings use bold+caps for section headings with the same font
|
|
size as body text, so pymupdf4llm cannot promote them to # headings.
|
|
|
|
3. Split-bold headings: ``**1** **Introduction**``, ``**3.2** **Attention**``.
|
|
pymupdf4llm emits these when the section number and title text are
|
|
separate spans in the underlying PDF (common in academic papers).
|
|
|
|
Args:
|
|
md_path: Path to the .md file.
|
|
|
|
Returns:
|
|
List of dicts with keys: title (str), line (int, 1-based).
|
|
When the outline is truncated at MAX_OUTLINE_ENTRIES, a sentinel entry
|
|
``{"truncated": True}`` is appended as the last element so callers can
|
|
render a "showing first N headings" hint without re-scanning the file.
|
|
Returns an empty list if the file cannot be read or has no headings.
|
|
"""
|
|
outline: list[dict] = []
|
|
try:
|
|
with md_path.open(encoding="utf-8") as f:
|
|
for lineno, line in enumerate(f, 1):
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
|
|
# Style 1: standard Markdown heading
|
|
if stripped.startswith("#"):
|
|
title = _clean_bold_title(stripped.lstrip("#").strip())
|
|
if title:
|
|
outline.append({"title": title, "line": lineno})
|
|
|
|
# Style 2: single bold block with SEC structural keyword
|
|
elif m := _BOLD_HEADING_RE.match(stripped):
|
|
title = m.group(1).strip()
|
|
if title:
|
|
outline.append({"title": title, "line": lineno})
|
|
|
|
# Style 3: split-bold heading — **<num>** **<title>**
|
|
# Regex already enforces max 4 blocks and non-numeric second block.
|
|
elif _SPLIT_BOLD_HEADING_RE.match(stripped):
|
|
title = " ".join(re.findall(r"\*\*([^*]+)\*\*", stripped))
|
|
if title:
|
|
outline.append({"title": title, "line": lineno})
|
|
|
|
if len(outline) > MAX_OUTLINE_ENTRIES:
|
|
outline.pop()
|
|
outline.append({"truncated": True})
|
|
break
|
|
except Exception:
|
|
return []
|
|
|
|
return outline
|
|
|
|
|
|
def extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
|
|
"""Return the document outline and fallback preview for *file_path*.
|
|
|
|
Looks for a sibling ``<stem>.md`` file produced by the upload conversion
|
|
pipeline.
|
|
|
|
Returns:
|
|
(outline, preview) where:
|
|
- outline: list of ``{title, line}`` dicts (plus optional sentinel).
|
|
Empty when no headings are found or no .md exists.
|
|
- preview: first few non-empty lines of the .md, used as a content
|
|
anchor when outline is empty so the agent has some context.
|
|
Empty when outline is non-empty (no fallback needed).
|
|
"""
|
|
md_path = file_path.with_suffix(".md")
|
|
if not md_path.is_file():
|
|
return [], []
|
|
|
|
outline = extract_outline(md_path)
|
|
if outline:
|
|
logger.debug("Extracted %d outline entries from %s", len(outline), file_path.name)
|
|
return outline, []
|
|
|
|
# outline is empty — read the first few non-empty lines as a content preview
|
|
preview: list[str] = []
|
|
try:
|
|
with md_path.open(encoding="utf-8") as f:
|
|
for line in f:
|
|
stripped = line.strip()
|
|
if stripped:
|
|
preview.append(stripped)
|
|
if len(preview) >= _OUTLINE_PREVIEW_LINES:
|
|
break
|
|
except Exception:
|
|
logger.debug("Failed to read preview lines from %s", md_path, exc_info=True)
|
|
return [], preview
|