diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 03d335207..cf668cff2 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -315,7 +315,7 @@ Multi-file upload with automatic document conversion: - 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` +- 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` See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details. diff --git a/backend/README.md b/backend/README.md index 4b8999877..056b5ed7c 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 after first exchange | +| 6 | **TitleMiddleware** | Auto-generates conversation titles from the original user request after first exchange; attachment-only messages fall back to `New Conversation` | | 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 137aeb89f..fb6298e02 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -2,6 +2,7 @@ import logging import re +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, NotRequired, override from langchain.agents import AgentState @@ -13,6 +14,7 @@ from langgraph.runtime import Runtime from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder from deerflow.config.title_config import get_title_config from deerflow.models import create_chat_model +from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text if TYPE_CHECKING: from deerflow.config.app_config import AppConfig @@ -106,7 +108,18 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): def _get_title_user_message(self, state: TitleMiddlewareState) -> str: messages = state.get("messages") or [] - user_msg_content = next((self._message_content(m) for m in messages if self._is_user_message_for_title(m)), "") + user_message = next((m for m in messages if self._is_user_message_for_title(m)), None) + if user_message is None: + return "" + if isinstance(user_message, dict): + additional_kwargs = user_message.get("additional_kwargs") + else: + additional_kwargs = getattr(user_message, "additional_kwargs", None) + if isinstance(additional_kwargs, Mapping) and isinstance(additional_kwargs.get(ORIGINAL_USER_CONTENT_KEY), str): + user_msg_content = get_original_user_content_text(self._message_content(user_message), additional_kwargs) + else: + # Keep TitleMiddleware's richer normalization for ordinary structured content. + user_msg_content = self._message_content(user_message) return self._normalize_content(user_msg_content) def _should_generate_title(self, state: TitleMiddlewareState, *, allow_partial_exchange: bool = False) -> bool: @@ -170,6 +183,9 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return title[: config.max_chars] if len(title) > config.max_chars else title def _fallback_title(self, user_msg: str) -> str: + if not user_msg.strip(): + return "New Conversation" + config = self._get_title_config() fallback_chars = min(config.max_chars, 50) if len(user_msg) > fallback_chars: @@ -178,7 +194,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): ellipsis = "..." body = min(fallback_chars, config.max_chars - len(ellipsis)) return user_msg[:body].rstrip() + ellipsis - return user_msg if user_msg else "New Conversation" + return user_msg def _get_runnable_config(self) -> dict[str, Any]: """Inherit the parent RunnableConfig and add middleware tag. @@ -217,12 +233,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): if not self._should_generate_title(state): return None - config = self._get_title_config() - if not config.model_name: - user_msg = self._get_title_user_message(state) + 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. + if not user_msg.strip(): return {"title": self._fallback_title(user_msg)} - user_msg = self._get_title_user_message(state) + config = self._get_title_config() + if not config.model_name: + return {"title": self._fallback_title(user_msg)} try: prompt, user_msg = self._build_title_prompt(state) diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index 14b03a475..0f7dbe476 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -12,6 +12,7 @@ from deerflow.agents.middlewares import title_middleware as title_middleware_mod from deerflow.agents.middlewares.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY from deerflow.agents.middlewares.title_middleware import TitleMiddleware from deerflow.config.title_config import TitleConfig, get_title_config, set_title_config +from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY def _clone_title_config(config: TitleConfig) -> TitleConfig: @@ -63,6 +64,82 @@ class TestTitleMiddlewareCoreLogic: assert middleware._should_generate_title(state) is True + @pytest.mark.parametrize( + "message", + [ + HumanMessage( + content="\nThe following files were uploaded in this message:\n\n- report.pdf\n\n\n分析这份报告", + additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "分析这份报告"}, + ), + { + "type": "human", + "content": "\n...\n\n\n分析这份报告", + "additional_kwargs": {ORIGINAL_USER_CONTENT_KEY: "分析这份报告"}, + }, + ], + ids=["langchain-message", "checkpoint-dict"], + ) + def test_title_uses_original_user_content_when_upload_context_is_injected(self, message): + middleware = TitleMiddleware() + + state = { + "messages": [message, AIMessage(content="好的,我来分析报告")], + } + + assert middleware._get_title_user_message(state) == "分析这份报告" + + def test_attachment_only_title_falls_back_to_new_conversation(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: ""}, + ), + AIMessage(content="好的,我来分析 report.pdf"), + ] + } + + result = asyncio.run(middleware._agenerate_title_result(state)) + + assert result == {"title": "New Conversation"} + + @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): + _set_test_title_config(enabled=True, model_name="title-model") + middleware = TitleMiddleware() + create_model = MagicMock() + monkeypatch.setattr(title_middleware_module, "create_chat_model", create_model) + 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: original_user_content}, + ), + AIMessage(content="好的,我来分析 report.pdf"), + ] + } + + result = asyncio.run(middleware._agenerate_title_result(state)) + + assert result == {"title": "New Conversation"} + create_model.assert_not_called() + + def test_title_preserves_structured_content_without_original_user_content(self): + middleware = TitleMiddleware() + state = { + "messages": [ + { + "type": "human", + "content": [{"type": "text", "content": "嵌套的用户请求"}], + }, + {"type": "ai", "content": "好的"}, + ] + } + + assert middleware._get_title_user_message(state) == "嵌套的用户请求" + def test_should_not_generate_title_when_disabled_or_already_set(self): middleware = TitleMiddleware()