mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
fix(title): ignore upload context in conversation titles (#4729)
* fix(title): ignore upload context in conversation titles * fix(title): cover attachment-only conversations * fix(title): skip model for attachment-only messages * fix(title): handle whitespace-only user content --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
3ac40bf9bd
commit
30788c79ff
@ -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 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.
|
- 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.
|
- 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.
|
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 |
|
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
|
||||||
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
|
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
|
||||||
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (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 |
|
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
|
||||||
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
|
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
|
||||||
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
|
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import TYPE_CHECKING, Any, NotRequired, override
|
from typing import TYPE_CHECKING, Any, NotRequired, override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
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.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
|
||||||
from deerflow.config.title_config import get_title_config
|
from deerflow.config.title_config import get_title_config
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
@ -106,7 +108,18 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
|
|
||||||
def _get_title_user_message(self, state: TitleMiddlewareState) -> str:
|
def _get_title_user_message(self, state: TitleMiddlewareState) -> str:
|
||||||
messages = state.get("messages") or []
|
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)
|
return self._normalize_content(user_msg_content)
|
||||||
|
|
||||||
def _should_generate_title(self, state: TitleMiddlewareState, *, allow_partial_exchange: bool = False) -> bool:
|
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
|
return title[: config.max_chars] if len(title) > config.max_chars else title
|
||||||
|
|
||||||
def _fallback_title(self, user_msg: str) -> str:
|
def _fallback_title(self, user_msg: str) -> str:
|
||||||
|
if not user_msg.strip():
|
||||||
|
return "New Conversation"
|
||||||
|
|
||||||
config = self._get_title_config()
|
config = self._get_title_config()
|
||||||
fallback_chars = min(config.max_chars, 50)
|
fallback_chars = min(config.max_chars, 50)
|
||||||
if len(user_msg) > fallback_chars:
|
if len(user_msg) > fallback_chars:
|
||||||
@ -178,7 +194,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
ellipsis = "..."
|
ellipsis = "..."
|
||||||
body = min(fallback_chars, config.max_chars - len(ellipsis))
|
body = min(fallback_chars, config.max_chars - len(ellipsis))
|
||||||
return user_msg[:body].rstrip() + 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]:
|
def _get_runnable_config(self) -> dict[str, Any]:
|
||||||
"""Inherit the parent RunnableConfig and add middleware tag.
|
"""Inherit the parent RunnableConfig and add middleware tag.
|
||||||
@ -217,12 +233,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
if not self._should_generate_title(state):
|
if not self._should_generate_title(state):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
config = self._get_title_config()
|
user_msg = self._get_title_user_message(state)
|
||||||
if not config.model_name:
|
# An attachment-only first turn has no user-authored text. Do not let a
|
||||||
user_msg = self._get_title_user_message(state)
|
# configured title model infer a title from the assistant response.
|
||||||
|
if not user_msg.strip():
|
||||||
return {"title": self._fallback_title(user_msg)}
|
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:
|
try:
|
||||||
prompt, user_msg = self._build_title_prompt(state)
|
prompt, user_msg = self._build_title_prompt(state)
|
||||||
|
|||||||
@ -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.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY
|
||||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||||
from deerflow.config.title_config import TitleConfig, get_title_config, set_title_config
|
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:
|
def _clone_title_config(config: TitleConfig) -> TitleConfig:
|
||||||
@ -63,6 +64,82 @@ class TestTitleMiddlewareCoreLogic:
|
|||||||
|
|
||||||
assert middleware._should_generate_title(state) is True
|
assert middleware._should_generate_title(state) is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"message",
|
||||||
|
[
|
||||||
|
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: "分析这份报告"},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"type": "human",
|
||||||
|
"content": "<current_uploads>\n...\n</current_uploads>\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="<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: ""},
|
||||||
|
),
|
||||||
|
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="<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},
|
||||||
|
),
|
||||||
|
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):
|
def test_should_not_generate_title_when_disabled_or_already_set(self):
|
||||||
middleware = TitleMiddleware()
|
middleware = TitleMiddleware()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user