From 9f4a7823e2c2b9a4699049cc7322c832aa6f3ee6 Mon Sep 17 00:00:00 2001 From: qian <34366496+xiaoqianbaobao@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:22:27 +0800 Subject: [PATCH] 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 --- backend/AGENTS.md | 6 +- backend/README.md | 2 +- .../agents/middlewares/title_middleware.py | 90 +++++++ .../tests/test_title_middleware_core_logic.py | 220 +++++++++++++++++- 4 files changed, 307 insertions(+), 11 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index c95ed29f7..04d964b18 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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. diff --git a/backend/README.md b/backend/README.md index 88f87f7f5..3ae87e65d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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) | diff --git a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index fb6298e02..f2d37eb41 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -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. diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index 0f7dbe476..5021ebfa4 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -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="\nThe following files were uploaded in this message:\n\n- report.pdf\n\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="\nThe following files were uploaded in this message:\n\n- report.pdf\n\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="\n...\n\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 = {