mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-13 15:40:00 +00:00
feat(title): use filenames for attachment-only conversations (#5304)
* feat(title): use filenames for attachment-only conversations * docs: trim upload guidance to fit inherited size budget * fix(title): bound attachment-only fallback titles --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
1ee93cd186
commit
9f4a7823e2
@ -339,11 +339,11 @@ Multi-file uploads convert documents; outlines skip fenced code:
|
||||
- Rejects directory inputs before copying so uploads stay all-or-nothing
|
||||
- Reuses one conversion worker per request when called from an active event loop
|
||||
- Files stored in thread-isolated directories under the resolving user's bucket (`users/{user_id}/threads/{thread_id}/user-data/uploads`). For IM channels the owner is threaded explicitly via the `user_id=` kwarg (see IM Channels → Owner-scoped file storage); HTTP/embedded callers resolve it from `get_effective_user_id()`
|
||||
- Duplicate filenames in a single upload request are auto-renamed with `_N` suffixes so later files do not truncate earlier files
|
||||
- Duplicate filenames within one request get `_N` suffixes to prevent overwrites.
|
||||
- Gateway HTTP uploads stage bytes as `.upload-*.part` files and atomically replace the destination only after size validation. These staging files are hidden from upload listings, agent upload context, and sandbox listing/search tools, and swept on Gateway startup if a hard crash leaves one behind.
|
||||
- Gateway HTTP upload/list/delete handlers offload filesystem work through `deerflow.utils.file_io.run_file_io`, a dedicated ContextVar-preserving file IO executor. Non-mounted sandbox uploads acquire sandboxes with `SandboxProvider.acquire_async()` and offload `read_bytes()` plus `sandbox.update_file()` together.
|
||||
- Mounted upload paths skip both sandbox acquisition and per-file synchronization. For AIO remote/provisioner deployments this requires an explicit, accurate `sandbox.thread_data_mounts: true`; omission preserves backend auto-detection.
|
||||
- Agent receives uploaded file list via `UploadsMiddleware`; title generation continues to use the original user request rather than the injected upload-context wrapper, with attachment-only messages falling back to `New Conversation`
|
||||
- Mounted uploads skip sandbox acquire/sync. AIO remote/provisioner requires accurate `sandbox.thread_data_mounts: true`; omission keeps backend auto-detection.
|
||||
- `UploadsMiddleware` supplies file lists. Titles use original user text, not upload context; attachment-only titles use a sanitized, bounded filename or count.
|
||||
|
||||
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
||||
|
||||
|
||||
@ -61,7 +61,7 @@ Middlewares execute in strict order, each handling a specific concern:
|
||||
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
|
||||
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
|
||||
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
|
||||
| 6 | **TitleMiddleware** | Auto-generates conversation titles from the original user request after first exchange; attachment-only messages fall back to `New Conversation` |
|
||||
| 6 | **TitleMiddleware** | Auto-generates conversation titles from the original user request after first exchange; attachment-only messages use a sanitized file name (or `N files uploaded` for multiple attachments) |
|
||||
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
|
||||
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
|
||||
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, NotRequired, override
|
||||
from unicodedata import category
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
@ -27,6 +29,7 @@ class TitleMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
title: NotRequired[str | None]
|
||||
uploaded_files: NotRequired[list[dict] | None]
|
||||
|
||||
|
||||
class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
@ -122,6 +125,55 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
user_msg_content = self._message_content(user_message)
|
||||
return self._normalize_content(user_msg_content)
|
||||
|
||||
@staticmethod
|
||||
def _clean_attachment_filename(filename: object) -> str | None:
|
||||
"""Return a safe, readable upload filename for use as a thread title."""
|
||||
if not isinstance(filename, str) or not filename or Path(filename).name != filename:
|
||||
return None
|
||||
|
||||
# File names enter the title as display text, never as a URL. Preserve
|
||||
# readable Unicode and punctuation while preventing control characters
|
||||
# from changing the thread-list layout.
|
||||
cleaned = "".join(" " if category(char).startswith("C") else char for char in filename)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
return cleaned or None
|
||||
|
||||
def _attachment_only_title(self, state: TitleMiddlewareState) -> str | None:
|
||||
"""Return a local title for a first turn containing attachments only."""
|
||||
if self._get_title_user_message(state).strip():
|
||||
return None
|
||||
|
||||
files = state.get("uploaded_files")
|
||||
if not isinstance(files, list):
|
||||
return None
|
||||
|
||||
filenames = []
|
||||
seen_attachment_ids: set[str] = set()
|
||||
for file in files:
|
||||
if not isinstance(file, Mapping):
|
||||
continue
|
||||
filename = file.get("filename")
|
||||
if not isinstance(filename, str):
|
||||
continue
|
||||
cleaned = self._clean_attachment_filename(filename)
|
||||
if cleaned is None:
|
||||
continue
|
||||
# UploadsMiddleware builds the path from a verified basename.
|
||||
# Deduplicate that stable attachment identity before display-name
|
||||
# cleanup: distinct names can intentionally normalize alike.
|
||||
attachment_id = file.get("path")
|
||||
if not isinstance(attachment_id, str) or not attachment_id:
|
||||
attachment_id = filename
|
||||
if attachment_id in seen_attachment_ids:
|
||||
continue
|
||||
seen_attachment_ids.add(attachment_id)
|
||||
filenames.append(cleaned)
|
||||
if len(filenames) == 1:
|
||||
return self._truncate_attachment_filename(filenames[0])
|
||||
if len(filenames) > 1:
|
||||
return self._attachment_count_title(len(filenames))
|
||||
return None
|
||||
|
||||
def _should_generate_title(self, state: TitleMiddlewareState, *, allow_partial_exchange: bool = False) -> bool:
|
||||
"""Check if we should generate a title for this thread."""
|
||||
config = self._get_title_config()
|
||||
@ -196,6 +248,36 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
return user_msg[:body].rstrip() + ellipsis
|
||||
return user_msg
|
||||
|
||||
def _truncate_attachment_filename(self, filename: str) -> str:
|
||||
"""Truncate a file-name title while retaining its extension when possible."""
|
||||
config = self._get_title_config()
|
||||
max_chars = config.max_chars
|
||||
if len(filename) <= max_chars:
|
||||
return filename
|
||||
|
||||
ellipsis = "..."
|
||||
extension = Path(filename).suffix.lstrip(".")
|
||||
remaining = max_chars - len(ellipsis) - len(extension)
|
||||
if extension and remaining > 0:
|
||||
return filename[:remaining].rstrip() + ellipsis + extension
|
||||
return self._truncate_title(filename)
|
||||
|
||||
def _attachment_count_title(self, count: int) -> str:
|
||||
"""Return a bounded, readable title for multiple validated uploads."""
|
||||
config = self._get_title_config()
|
||||
for title in (f"{count} files uploaded", f"{count} files"):
|
||||
if len(title) <= config.max_chars:
|
||||
return title
|
||||
return self._truncate_title(str(count))
|
||||
|
||||
def _truncate_title(self, title: str) -> str:
|
||||
"""Bound a local attachment title without overriding title.max_chars."""
|
||||
max_chars = self._get_title_config().max_chars
|
||||
if len(title) <= max_chars:
|
||||
return title
|
||||
ellipsis = "..."
|
||||
return title[: max_chars - len(ellipsis)].rstrip() + ellipsis
|
||||
|
||||
def _get_runnable_config(self) -> dict[str, Any]:
|
||||
"""Inherit the parent RunnableConfig and add middleware tag.
|
||||
|
||||
@ -220,6 +302,10 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
if not self._should_generate_title(state, allow_partial_exchange=allow_partial_exchange):
|
||||
return None
|
||||
|
||||
attachment_title = self._attachment_only_title(state)
|
||||
if attachment_title is not None:
|
||||
return {"title": attachment_title}
|
||||
|
||||
user_msg = self._get_title_user_message(state)
|
||||
return {"title": self._fallback_title(user_msg)}
|
||||
|
||||
@ -233,6 +319,10 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
if not self._should_generate_title(state):
|
||||
return None
|
||||
|
||||
attachment_title = self._attachment_only_title(state)
|
||||
if attachment_title is not None:
|
||||
return {"title": attachment_title}
|
||||
|
||||
user_msg = self._get_title_user_message(state)
|
||||
# An attachment-only first turn has no user-authored text. Do not let a
|
||||
# configured title model infer a title from the assistant response.
|
||||
|
||||
@ -88,22 +88,41 @@ class TestTitleMiddlewareCoreLogic:
|
||||
|
||||
assert middleware._get_title_user_message(state) == "分析这份报告"
|
||||
|
||||
def test_attachment_only_title_falls_back_to_new_conversation(self):
|
||||
def test_attachment_only_single_file_uses_filename_as_local_title(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="<current_uploads>\nThe following files were uploaded in this message:\n\n- report.pdf\n</current_uploads>\n\n",
|
||||
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: ""},
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: "",
|
||||
"files": [{"filename": "report.pdf"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的,我来分析 report.pdf"),
|
||||
]
|
||||
],
|
||||
"uploaded_files": [{"filename": "report.pdf"}],
|
||||
}
|
||||
|
||||
result = asyncio.run(middleware._agenerate_title_result(state))
|
||||
|
||||
assert result == {"title": "New Conversation"}
|
||||
assert result == {"title": "report.pdf"}
|
||||
|
||||
def test_interrupted_attachment_only_title_uses_filename(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="",
|
||||
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: ""},
|
||||
),
|
||||
],
|
||||
"uploaded_files": [{"filename": "report.pdf", "path": "/mnt/user-data/uploads/report.pdf"}],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state, allow_partial_exchange=True) == {"title": "report.pdf"}
|
||||
|
||||
@pytest.mark.parametrize("original_user_content", ["", " \t\n"], ids=["empty", "whitespace-only"])
|
||||
def test_attachment_only_title_skips_configured_title_model(self, monkeypatch, original_user_content):
|
||||
@ -115,17 +134,204 @@ class TestTitleMiddlewareCoreLogic:
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="<current_uploads>\nThe following files were uploaded in this message:\n\n- report.pdf\n</current_uploads>\n\n",
|
||||
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: original_user_content},
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: original_user_content,
|
||||
"files": [{"filename": "report.pdf"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的,我来分析 report.pdf"),
|
||||
]
|
||||
],
|
||||
"uploaded_files": [{"filename": "report.pdf"}],
|
||||
}
|
||||
|
||||
result = asyncio.run(middleware._agenerate_title_result(state))
|
||||
|
||||
assert result == {"title": "New Conversation"}
|
||||
assert result == {"title": "report.pdf"}
|
||||
create_model.assert_not_called()
|
||||
|
||||
def test_attachment_only_multiple_files_uses_count_title(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: "",
|
||||
"files": [{"filename": "report.pdf"}, {"filename": "data.csv"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [{"filename": "report.pdf"}, {"filename": "data.csv"}],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "2 files uploaded"}
|
||||
|
||||
@pytest.mark.parametrize("generate_async", [False, True], ids=["sync", "async"])
|
||||
def test_attachment_only_multiple_files_respects_title_max_chars(self, generate_async):
|
||||
config = _set_test_title_config(enabled=True, model_name=None, max_chars=10)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="", additional_kwargs={ORIGINAL_USER_CONTENT_KEY: ""}),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [
|
||||
{"filename": "report.pdf", "path": "/mnt/user-data/uploads/report.pdf"},
|
||||
{"filename": "data.csv", "path": "/mnt/user-data/uploads/data.csv"},
|
||||
],
|
||||
}
|
||||
|
||||
result = asyncio.run(middleware._agenerate_title_result(state)) if generate_async else middleware._generate_title_result(state)
|
||||
|
||||
assert result == {"title": "2 files"}
|
||||
assert len(result["title"]) <= config.max_chars
|
||||
|
||||
def test_attachment_only_duplicate_paths_are_counted_once_before_name_cleanup(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="", additional_kwargs={ORIGINAL_USER_CONTENT_KEY: ""}),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [
|
||||
{"filename": "report.pdf", "path": "/mnt/user-data/uploads/report.pdf"},
|
||||
{"filename": "report.pdf", "path": "/mnt/user-data/uploads/report.pdf"},
|
||||
],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "report.pdf"}
|
||||
|
||||
def test_attachment_only_distinct_paths_with_same_cleaned_name_are_not_deduplicated(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="", additional_kwargs={ORIGINAL_USER_CONTENT_KEY: ""}),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [
|
||||
{"filename": "a\tb.pdf", "path": "/mnt/user-data/uploads/a\tb.pdf"},
|
||||
{"filename": "a b.pdf", "path": "/mnt/user-data/uploads/a b.pdf"},
|
||||
],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "2 files uploaded"}
|
||||
|
||||
def test_attachment_only_without_valid_filename_falls_back_to_new_conversation(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: "",
|
||||
"files": [{"filename": "../not-an-upload.txt"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "New Conversation"}
|
||||
|
||||
def test_attachment_filename_title_cleans_controls_and_preserves_extension_when_truncated(self):
|
||||
config = _set_test_title_config(enabled=True, model_name=None, max_chars=20)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: "",
|
||||
"files": [{"filename": "quarterly\nreport\twith-extra-details.xlsx"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [{"filename": "quarterly\nreport\twith-extra-details.xlsx"}],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "quarterly rep...xlsx"}
|
||||
assert len(middleware._attachment_only_title(state) or "") <= config.max_chars
|
||||
|
||||
def test_attachment_filename_without_suffix_respects_configured_max_chars(self):
|
||||
config = _set_test_title_config(enabled=True, model_name=None, max_chars=60)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="", additional_kwargs={ORIGINAL_USER_CONTENT_KEY: ""}),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [{"filename": "x" * 70}],
|
||||
}
|
||||
|
||||
result = middleware._generate_title_result(state)
|
||||
|
||||
assert result == {"title": "x" * 57 + "..."}
|
||||
assert len(result["title"]) == config.max_chars
|
||||
|
||||
def test_attachment_filename_title_preserves_percent_and_unicode(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: "",
|
||||
"files": [{"filename": "2026%预算报告(最终版).xlsx"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [{"filename": "2026%预算报告(最终版).xlsx"}],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "2026%预算报告(最终版).xlsx"}
|
||||
|
||||
def test_user_text_title_takes_precedence_over_attachment_filename(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="<current_uploads>\n...\n</current_uploads>\n\nAnalyze this report",
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: "Analyze this report",
|
||||
"files": [{"filename": "report.pdf"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [{"filename": "report.pdf"}],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "Analyze this report"}
|
||||
|
||||
def test_attachment_filename_metadata_is_ignored_without_validated_upload_state(self):
|
||||
_set_test_title_config(enabled=True, model_name=None)
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content="",
|
||||
additional_kwargs={
|
||||
ORIGINAL_USER_CONTENT_KEY: "",
|
||||
"files": [{"filename": "unverified.pdf"}],
|
||||
},
|
||||
),
|
||||
AIMessage(content="好的"),
|
||||
],
|
||||
"uploaded_files": [],
|
||||
}
|
||||
|
||||
assert middleware._generate_title_result(state) == {"title": "New Conversation"}
|
||||
|
||||
def test_title_preserves_structured_content_without_original_user_content(self):
|
||||
middleware = TitleMiddleware()
|
||||
state = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user