mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30:39 +00:00
* feat(projects): search shelf documents by keyword Add search_project_documents: two-tier name/summary then bounded content scan, case-insensitive substring matching with original-text offsets. Never converts on the fly; reports stop_reason, content_scanned, skipped reasons, and capped unscanned/truncated ids. * fix(projects): derive unscanned count without paging after budget trip Review feedback: once the content budget trips, stop paging list_active to enumerate unscanned ids — cap ids at the trip page and derive the honest total from one count_active query (tier 1 is exhaustive, so active - tier1 hits - processed = unscanned). Also add a tool-level isolation test pinning that search never crosses projects or users. * fix(projects): scan the shelf over one stable snapshot, not offset pages Review feedback: offset paging re-read the live shelf per page, so a concurrent upload/trash between pages could duplicate a match or silently skip one while still claiming complete coverage. Tier 1 now materializes the whole shelf in a single SELECT (one read snapshot) and tier 2 iterates those same rows; the budget-trip unscanned count is derived exactly from the snapshot with no extra query. Mid-scan upload/trash regression tests pin both failure modes.
427 lines
20 KiB
Python
427 lines
20 KiB
Python
"""Project shelf tools: bounded live reads through the pinned project identity.
|
|
|
|
Read-only tools (Phase-2 spec §7.3; the shelf is user-curated — there is
|
|
no agent-initiated shelf write). All take ``project_id`` from the run's
|
|
admission-pinned context (``PROJECT_CONTEXT_KEY``) and ``user_id`` from
|
|
:func:`resolve_runtime_user_id`, then query **live** shelf rows: the pinned
|
|
snapshot fixes *which* project, never *what* the shelf currently holds. A
|
|
missing pin or a missing session factory is a tool error, never an empty
|
|
success; a document trashed after this run's index was rendered fails with a
|
|
"no longer on the shelf" error rather than serving stale content (§11).
|
|
|
|
Registration is conditional on the pinned key (§10.11) — see
|
|
``agents/lead_agent/agent.py``; subagents never receive these tools. Every
|
|
file read and conversion is offloaded via
|
|
:func:`deerflow.utils.file_io.run_file_io`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Annotated, Any
|
|
|
|
from langchain.tools import tool
|
|
|
|
from deerflow.agents.middlewares.input_sanitization_middleware import neutralize_untrusted_tags
|
|
from deerflow.config.paths import Paths, get_paths
|
|
from deerflow.projects.context import pinned_project_snapshot
|
|
from deerflow.projects.documents import auto_convert_documents_enabled, document_char_count, read_document_text_window, read_text_serving_path
|
|
from deerflow.runtime.user_context import resolve_runtime_user_id
|
|
from deerflow.tools.types import Runtime
|
|
from deerflow.utils.file_io import run_file_io
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LIST_DEFAULT_LIMIT = 50
|
|
_LIST_MAX_LIMIT = 200
|
|
_READ_DEFAULT_LIMIT = 8000
|
|
_READ_MAX_LIMIT = 20000
|
|
_SEARCH_DEFAULT_LIMIT = 10
|
|
_SEARCH_MAX_LIMIT = 50
|
|
_SEARCH_QUERY_MAX_CHARS = 200
|
|
#: Excerpt window: this many characters on each side of the match (spec §7;
|
|
#: a constant, deliberately not a config knob).
|
|
_SEARCH_EXCERPT_CONTEXT = 80
|
|
#: Cap on each id list in the search result payload.
|
|
_SEARCH_ID_LIST_CAP = 20
|
|
|
|
_BINARY_DECLINE_MESSAGE = "This document is binary and cannot be read as text. To let the agent process it, attach it to a thread (attach-to-thread) instead."
|
|
_CONVERSION_DISABLED_MESSAGE = "This document is a convertible office/PDF file, but automatic document conversion is disabled (uploads.auto_convert_documents). Enable conversion, or attach the file to a thread (attach-to-thread) instead."
|
|
_NOT_ON_SHELF_MESSAGE = "This document is no longer on the shelf (it was trashed or removed after this run started). Call list_project_documents for the current shelf."
|
|
_CONTENT_MISSING_MESSAGE = "This document's content is missing from storage (content_missing); only its shelf row remains. Move it to trash from the project page."
|
|
_NO_PROJECT_CONTEXT_MESSAGE = "No project is pinned for this run; project document tools are only available in project member threads."
|
|
_NO_STORE_MESSAGE = "Project document store is unavailable."
|
|
|
|
|
|
def _error(message: str) -> str:
|
|
return json.dumps({"error": message}, ensure_ascii=False)
|
|
|
|
|
|
def _resolve_pin_and_repo(runtime: Runtime) -> tuple[str, str, Any] | str:
|
|
"""Resolve ``(project_id, user_id, repository)`` or a JSON error string.
|
|
|
|
Fail closed (§7.3): no pinned project context or no session factory is a
|
|
tool error with no data, never an empty success.
|
|
"""
|
|
snapshot = pinned_project_snapshot(runtime)
|
|
project_id = str(snapshot.get("project_id") or "") if snapshot is not None else ""
|
|
if not project_id:
|
|
return _error(_NO_PROJECT_CONTEXT_MESSAGE)
|
|
from deerflow.persistence import get_session_factory
|
|
from deerflow.persistence.projects import ProjectDocumentRepository
|
|
|
|
session_factory = get_session_factory()
|
|
if session_factory is None:
|
|
return _error(_NO_STORE_MESSAGE)
|
|
user_id = resolve_runtime_user_id(runtime)
|
|
return project_id, user_id, ProjectDocumentRepository(session_factory)
|
|
|
|
|
|
def _resolve_auto_convert() -> bool:
|
|
"""Worker-thread config read: the app config may cold-load from disk."""
|
|
try:
|
|
from deerflow.config.app_config import get_app_config
|
|
|
|
return auto_convert_documents_enabled(get_app_config())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _clamp(value: int, *, default: int, lower: int, upper: int) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(lower, min(parsed, upper))
|
|
|
|
|
|
def _shelf_entry_json(row: dict) -> dict[str, Any]:
|
|
summary = row.get("summary")
|
|
return {
|
|
"id": row["id"],
|
|
"name": neutralize_untrusted_tags(str(row.get("name") or "")),
|
|
"size_bytes": int(row.get("size_bytes") or 0),
|
|
"updated_at": str(row.get("updated_at") or ""),
|
|
# LLM-processed untrusted content: neutralized here exactly like the
|
|
# name, mirroring the injected index line.
|
|
"summary": (neutralize_untrusted_tags(str(summary)) if summary else None),
|
|
}
|
|
|
|
|
|
async def _list_project_documents_impl(runtime: Runtime, *, offset: int, limit: int) -> str:
|
|
resolved = _resolve_pin_and_repo(runtime)
|
|
if isinstance(resolved, str):
|
|
return resolved
|
|
project_id, user_id, repo = resolved
|
|
limit = _clamp(limit, default=_LIST_DEFAULT_LIMIT, lower=1, upper=_LIST_MAX_LIMIT)
|
|
offset = max(_clamp(offset, default=0, lower=0, upper=1 << 62), 0)
|
|
rows = await repo.list_active(project_id, limit=limit, offset=offset, user_id=user_id)
|
|
total = await repo.count_active(project_id, user_id=user_id)
|
|
next_offset: int | None = offset + len(rows) if offset + len(rows) < total else None
|
|
return json.dumps(
|
|
{
|
|
"total": total,
|
|
"offset": offset,
|
|
"next_offset": next_offset,
|
|
"documents": [_shelf_entry_json(row) for row in rows],
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
async def _read_project_document_impl(runtime: Runtime, *, document_id: str, offset: int, limit: int, paths: Paths | None = None) -> str:
|
|
resolved = _resolve_pin_and_repo(runtime)
|
|
if isinstance(resolved, str):
|
|
return resolved
|
|
project_id, user_id, repo = resolved
|
|
row = await repo.get(document_id, user_id=user_id)
|
|
# Fail closed on every mismatch: trashed/purged after the index rendered,
|
|
# foreign, or belonging to a different project than the pinned one — one
|
|
# stale-entry error, never cross-project reads, never stale content (§11).
|
|
if row is None or row.get("project_id") != project_id:
|
|
return _error(_NOT_ON_SHELF_MESSAGE)
|
|
paths = paths or get_paths()
|
|
auto_convert = await run_file_io(_resolve_auto_convert)
|
|
serving_path, reason = await read_text_serving_path(repo, paths, user_id=user_id, row=row, auto_convert=auto_convert)
|
|
if serving_path is None:
|
|
if reason == "content_missing":
|
|
return _error(_CONTENT_MISSING_MESSAGE)
|
|
if reason == "conversion_disabled":
|
|
return _error(_CONVERSION_DISABLED_MESSAGE)
|
|
return _error(_BINARY_DECLINE_MESSAGE)
|
|
offset = _clamp(offset, default=0, lower=0, upper=1 << 62)
|
|
limit = _clamp(limit, default=_READ_DEFAULT_LIMIT, lower=1, upper=_READ_MAX_LIMIT)
|
|
# The character count is content-identity cached (immutable rows, §6.2);
|
|
# the windowed read decodes only what the page needs — and past-end pages
|
|
# read nothing at all.
|
|
total_chars = await document_char_count(document_id=row["id"], sha256=row["sha256"], path=serving_path)
|
|
content = await read_document_text_window(serving_path, offset=offset, limit=limit) if offset < total_chars else ""
|
|
return json.dumps(
|
|
{
|
|
"name": neutralize_untrusted_tags(str(row.get("name") or "")),
|
|
"total_chars": total_chars,
|
|
"offset": offset,
|
|
"returned_chars": len(content),
|
|
"truncated": offset + len(content) < total_chars,
|
|
"content": content,
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
@tool
|
|
async def list_project_documents(
|
|
runtime: Runtime,
|
|
offset: Annotated[int, "Number of shelf entries to skip for pagination (default 0). Use next_offset from a previous call to walk the shelf."] = 0,
|
|
limit: Annotated[int, "Maximum entries to return (default 50, max 200)."] = _LIST_DEFAULT_LIMIT,
|
|
) -> str:
|
|
"""List documents on the current project's shelf (metadata only, no content).
|
|
|
|
Returns JSON: {"total", "offset", "next_offset", "documents": [{"id",
|
|
"name", "size_bytes", "updated_at", "summary"}, ...]} in the shelf's own
|
|
order (recently updated first). "summary" is an optional one-line
|
|
description of the document (null while generation is pending/disabled/
|
|
failed) — use it to decide whether a read_project_document call is worth
|
|
it before paying for a content slice. Use this tool when the <documents>
|
|
index says the shelf has more entries than it shows, or the user asks
|
|
what documents the project holds. Each entry's stable "id" is what
|
|
read_project_document takes. Live data: reflects the shelf as of this
|
|
call.
|
|
"""
|
|
return await _list_project_documents_impl(runtime, offset=offset, limit=limit)
|
|
|
|
|
|
@tool
|
|
async def read_project_document(
|
|
runtime: Runtime,
|
|
document_id: Annotated[str, "Stable document ID from the <documents> index or list_project_documents."],
|
|
offset: Annotated[int, "Character offset into the document's text (default 0)."] = 0,
|
|
limit: Annotated[int, "Maximum characters to return (default 8000, max 20000)."] = _READ_DEFAULT_LIMIT,
|
|
) -> str:
|
|
"""Read a bounded text slice of one project shelf document.
|
|
|
|
Returns JSON: {"name", "total_chars", "offset", "returned_chars",
|
|
"truncated", "content"}. Long documents are served in slices: advance
|
|
"offset" by the returned character count while "truncated" is true.
|
|
Office/PDF documents are served as converted markdown when conversion is
|
|
enabled. Binary documents cannot be read — attach them to a thread
|
|
instead. A document trashed after this run started reports that it is no
|
|
longer on the shelf.
|
|
"""
|
|
return await _read_project_document_impl(runtime, document_id=document_id, offset=offset, limit=limit)
|
|
|
|
|
|
_QUERY_BLANK_MESSAGE = "Query must be 1-200 non-whitespace characters."
|
|
|
|
|
|
def _resolve_search_budgets() -> tuple[int, int]:
|
|
"""Worker-thread config read: per-call so edits apply without a restart."""
|
|
try:
|
|
from deerflow.config.app_config import get_app_config
|
|
from deerflow.config.projects_config import ProjectsConfig
|
|
|
|
cfg = getattr(get_app_config(), "projects", None)
|
|
if not isinstance(cfg, ProjectsConfig):
|
|
cfg = ProjectsConfig()
|
|
return cfg.search_max_chars_per_document, cfg.search_max_total_chars
|
|
except Exception:
|
|
from deerflow.config.projects_config import ProjectsConfig
|
|
|
|
fallback = ProjectsConfig()
|
|
return fallback.search_max_chars_per_document, fallback.search_max_total_chars
|
|
|
|
|
|
def _search_excerpt(text: str, match: re.Match) -> str:
|
|
"""Slice ±``_SEARCH_EXCERPT_CONTEXT`` around *match* on the ORIGINAL text, then neutralize.
|
|
|
|
Order matters: slicing on neutralized text would misalign every offset.
|
|
"""
|
|
start = max(0, match.start() - _SEARCH_EXCERPT_CONTEXT)
|
|
end = min(len(text), match.end() + _SEARCH_EXCERPT_CONTEXT)
|
|
snippet = text[start:end]
|
|
if start > 0:
|
|
snippet = "…" + snippet
|
|
if end < len(text):
|
|
snippet += "…"
|
|
return neutralize_untrusted_tags(snippet)
|
|
|
|
|
|
def _capped_id_list(ids: list[str]) -> tuple[list[str], int]:
|
|
"""The first ``_SEARCH_ID_LIST_CAP`` ids plus the full count (spec §6)."""
|
|
return ids[:_SEARCH_ID_LIST_CAP], len(ids)
|
|
|
|
|
|
async def _search_project_documents_impl(runtime: Runtime, *, query: str, limit: int) -> str:
|
|
resolved = _resolve_pin_and_repo(runtime)
|
|
if isinstance(resolved, str):
|
|
return resolved
|
|
project_id, user_id, repo = resolved
|
|
|
|
query = query.strip()
|
|
if not query or len(query) > _SEARCH_QUERY_MAX_CHARS:
|
|
return _error(_QUERY_BLANK_MESSAGE)
|
|
limit = _clamp(limit, default=_SEARCH_DEFAULT_LIMIT, lower=1, upper=_SEARCH_MAX_LIMIT)
|
|
per_doc_cap, total_budget = await run_file_io(_resolve_search_budgets)
|
|
# re.IGNORECASE, NOT casefold: indices must stay original-text character
|
|
# positions — casefold changes length (ß→ss, İ→i̇) and would corrupt both
|
|
# the returned offset and the excerpt slice (spec §5.3).
|
|
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
|
|
|
# Tier 1: name/summary over the whole shelf, materialized in ONE query.
|
|
# The rows are kept and reused for tier 2 — a stable candidate set.
|
|
# Offset paging would re-read the live shelf per page, so a concurrent
|
|
# upload/trash between pages could duplicate a row (an unrelated newest
|
|
# upload shifts every offset) or silently skip one (a trash shrinks
|
|
# earlier pages); a single SELECT reads one snapshot. A row trashed
|
|
# between the snapshot and its content read follows the normal serving
|
|
# rules ("missing" etc.) — shelf membership is fixed as of this call.
|
|
shelf_rows = await repo.list_active(project_id, limit=1 << 62, offset=0, user_id=user_id) # effectively unbounded: one read snapshot
|
|
name_hits: list[dict] = []
|
|
summary_hits: list[dict] = []
|
|
tier1_ids: set[str] = set()
|
|
for row in shelf_rows:
|
|
name = str(row.get("name") or "")
|
|
summary = row.get("summary") or None
|
|
name_match = pattern.search(name)
|
|
if name_match is not None:
|
|
name_hits.append({"id": row["id"], "name": neutralize_untrusted_tags(name), "matched": "name", "excerpt": _search_excerpt(name, name_match), "offset": None})
|
|
tier1_ids.add(row["id"])
|
|
elif summary:
|
|
summary_match = pattern.search(str(summary))
|
|
if summary_match is not None:
|
|
summary_hits.append({"id": row["id"], "name": neutralize_untrusted_tags(name), "matched": "summary", "excerpt": _search_excerpt(str(summary), summary_match), "offset": None})
|
|
tier1_ids.add(row["id"])
|
|
|
|
results = (name_hits + summary_hits)[:limit]
|
|
if len(results) >= limit:
|
|
# Tier 1 alone filled the page: content was never read, and the
|
|
# ranking is exact — the answer must NOT claim coverage (spec §6).
|
|
return json.dumps(
|
|
{
|
|
"results": results,
|
|
"stop_reason": "limit",
|
|
"content_scanned": False,
|
|
"skipped": {"non_text": 0, "unconverted": 0, "missing": 0, "error": 0},
|
|
"truncated_document_ids": [],
|
|
"truncated_count": 0,
|
|
"unscanned_document_ids": [],
|
|
"unscanned_count": 0,
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
# Tier 2: content scan over the SAME snapshot, in shelf order, skipping
|
|
# tier-1 hits, bounded by the whole-shelf budget. auto_convert=False:
|
|
# search NEVER converts (a first search must not synchronously convert
|
|
# every PDF on the shelf).
|
|
remaining = limit - len(results)
|
|
budget_left = total_budget
|
|
stop_reason = "complete"
|
|
skipped = {"non_text": 0, "unconverted": 0, "missing": 0, "error": 0}
|
|
truncated_ids: list[str] = []
|
|
unscanned_ids: list[str] = []
|
|
processed = 0 # non-tier-1 rows examined before any budget trip
|
|
paths = get_paths()
|
|
for row in shelf_rows:
|
|
if remaining == 0:
|
|
stop_reason = "limit"
|
|
break
|
|
if row["id"] in tier1_ids:
|
|
# Tier 1 was fully scanned over this snapshot: these are already
|
|
# in results and were searched — never "unscanned".
|
|
continue
|
|
if budget_left <= 0:
|
|
# Budget tripped. Unscanned ids are enumerated in memory, up to
|
|
# the payload cap; the honest total is derived after the loop
|
|
# from the snapshot — no further DB reads for rows already ruled
|
|
# out.
|
|
stop_reason = "budget"
|
|
if len(unscanned_ids) < _SEARCH_ID_LIST_CAP:
|
|
unscanned_ids.append(row["id"])
|
|
continue
|
|
processed += 1
|
|
try:
|
|
serving_path, reason = await read_text_serving_path(repo, paths, user_id=user_id, row=row, auto_convert=False)
|
|
except Exception:
|
|
skipped["error"] += 1
|
|
logger.warning("Shelf search: failed to resolve serving path for %s", row["id"], exc_info=True)
|
|
continue
|
|
if serving_path is None:
|
|
skipped[{"content_missing": "missing", "conversion_disabled": "unconverted"}.get(reason, "non_text")] += 1
|
|
continue
|
|
cap = min(per_doc_cap, budget_left)
|
|
try:
|
|
text = await read_document_text_window(serving_path, offset=0, limit=cap + 1)
|
|
except Exception:
|
|
skipped["error"] += 1
|
|
logger.warning("Shelf search: failed to read %s", row["id"], exc_info=True)
|
|
continue
|
|
budget_left -= len(text)
|
|
if len(text) > cap:
|
|
text = text[:cap]
|
|
truncated_ids.append(row["id"])
|
|
match = pattern.search(text)
|
|
if match is not None:
|
|
results.append({"id": row["id"], "name": neutralize_untrusted_tags(str(row.get("name") or "")), "matched": "content", "excerpt": _search_excerpt(text, match), "offset": match.start()})
|
|
remaining -= 1
|
|
|
|
truncated_capped, truncated_total = _capped_id_list(truncated_ids)
|
|
if stop_reason == "complete" and remaining == 0:
|
|
# The limit filled exactly on the snapshot's final row — a limit stop,
|
|
# not a complete scan.
|
|
stop_reason = "limit"
|
|
unscanned_total = 0
|
|
if stop_reason == "budget":
|
|
# Exact — the snapshot IS the candidate set: unscanned = non-tier-1
|
|
# rows minus those processed before the trip.
|
|
unscanned_total = len(shelf_rows) - len(tier1_ids) - processed
|
|
return json.dumps(
|
|
{
|
|
"results": results,
|
|
"stop_reason": stop_reason,
|
|
"content_scanned": True,
|
|
"skipped": skipped,
|
|
"truncated_document_ids": truncated_capped,
|
|
"truncated_count": truncated_total,
|
|
"unscanned_document_ids": unscanned_ids,
|
|
"unscanned_count": unscanned_total,
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
@tool
|
|
async def search_project_documents(
|
|
runtime: Runtime,
|
|
query: Annotated[str, "Case-insensitive keyword to find on the shelf (plain substring, not regex; 1-200 chars)."],
|
|
limit: Annotated[int, "Maximum results to return (default 10, max 50)."] = _SEARCH_DEFAULT_LIMIT,
|
|
) -> str:
|
|
"""Search the current project's shelf by keyword across names, summaries, and content.
|
|
|
|
Two tiers: every document's name and summary are matched first (cheap),
|
|
and content is scanned only if that produced too few hits — so ranking is
|
|
always name > summary > content, with the shelf's own order inside a
|
|
tier. Each document appears at most once, credited to its best field.
|
|
|
|
Returns JSON: {"results": [{"id", "name", "matched", "excerpt",
|
|
"offset"}], "stop_reason", "content_scanned", "skipped",
|
|
"truncated_document_ids", "truncated_count", "unscanned_document_ids",
|
|
"unscanned_count"}. "offset" is set ONLY for content hits: a character
|
|
position to pass straight to read_project_document(offset=...).
|
|
stop_reason is "complete" (everything searched), "limit" (enough hits;
|
|
unsearched remainder is irrelevant), or "budget" (the whole-shelf scan
|
|
budget ran out — unscanned_document_ids lists what was not searched).
|
|
content_scanned=false means content was never read (tier 1 was enough);
|
|
never treat that as full coverage. skipped counts non_text binaries,
|
|
unconverted office/PDF (never converted on the fly), and missing
|
|
content. Live data: reflects the shelf as of this call.
|
|
"""
|
|
return await _search_project_documents_impl(runtime, query=query, limit=limit)
|
|
|
|
|
|
def get_project_document_tools() -> list:
|
|
"""The shelf tools the lead agent gains in project runs (§7.3)."""
|
|
return [list_project_documents, read_project_document, search_project_documents]
|