From acc29c8121a27e9ca55e4447e2cd1da3681f64fd Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 24 Jun 2026 21:20:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(middlewares):=20=E6=A0=87=E9=A2=98=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=8E=A8=E7=90=86=E4=B8=8D=E5=86=8D=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=E5=88=B0=20UI=20=E6=B5=81=20+=20open/ephemeral=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E9=81=BF=E5=85=8D=E6=97=A0=E8=B0=93=20DB=20=E8=AE=BF?= =?UTF-8?q?=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - title: 标题模型用隔离 config(不继承父级 streaming callbacks),force_disable_thinking, 块清洗,避免推理 token 进入实时流/运行日志 - uploads: 无文件的普通对话轮次直接跳过,不再解析线程归属(避免命中 DB) - thread_data: ephemeral 会话用当前登录用户解析数据目录 - thread_paths: open-/__skill-review__ 等合成线程视为 local-only,直接用当前用户 - frontend: 新增 build:test 构建脚本 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/package.json | 1 + .../middlewares/thread_data_middleware.py | 13 +++-- .../agents/middlewares/title_middleware.py | 54 +++++++++++-------- .../agents/middlewares/uploads_middleware.py | 7 +++ .../harness/deerflow/runtime/thread_paths.py | 10 +++- 5 files changed, 57 insertions(+), 28 deletions(-) diff --git a/frontend-web/package.json b/frontend-web/package.json index 1b854aca..5ec6dacc 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit", + "build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "preview": "vite preview", "typecheck": "tsc --noEmit" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py index 9da88c66..3bf01bf1 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py @@ -11,6 +11,7 @@ from langgraph.runtime import Runtime from deerflow.agents.thread_state import ThreadDataState from deerflow.config.paths import Paths, get_paths from deerflow.runtime.thread_paths import resolve_path_user_id +from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) @@ -81,10 +82,15 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]): @override def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None: context = runtime.context or {} + configurable = {} + try: + config = get_config() + configurable = config.get("configurable", {}) or {} + except RuntimeError: + pass thread_id = context.get("thread_id") if thread_id is None: - config = get_config() - thread_id = config.get("configurable", {}).get("thread_id") + thread_id = configurable.get("thread_id") if thread_id is None: raise ValueError("Thread ID is required in runtime context or config.configurable") @@ -93,7 +99,8 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]): # thread creator so every account that shares the conversation writes to # the same physical user-data directory. Personal threads resolve to the # current login unchanged. - user_id = resolve_path_user_id(thread_id) + is_ephemeral = bool(context.get("ephemeral") or configurable.get("ephemeral")) + user_id = get_effective_user_id() if is_ephemeral else resolve_path_user_id(thread_id) if self._lazy_init: # Lazy initialization: only compute paths, don't create directories diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index f45d4b0c..63cbe592 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, NotRequired, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware -from langgraph.config import get_config from langgraph.runtime import Runtime from deerflow.config.title_config import get_title_config @@ -139,13 +138,29 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return text.strip() def _parse_title(self, content: object) -> str: - """Normalize model output into a clean title string.""" + """Normalize model output into a clean title string (strips blocks).""" config = self._get_title_config() - title_content = self._normalize_content(content) - title_content = self._strip_think_tags(title_content) + title_content = self._strip_think_tags(self._normalize_content(content)) title = title_content.strip().strip('"').strip("'") return title[: config.max_chars] if len(title) > config.max_chars else title + def _isolated_config(self) -> dict[str, Any]: + """Build a RunnableConfig that does NOT inherit the parent's streaming callbacks. + + The leak that pushed title-generation reasoning into the live UI stream came + from inheriting the parent RunnableConfig (and with it the SSE/streaming + callbacks) for the title model call. Here we deliberately start from an empty + config with ``callbacks: []`` so the title model's tokens — including any + ```` reasoning from a reasoning-only deployment — never reach the + frontend stream or the run journal as conversation output. The stored title + is additionally cleaned by ``_parse_title``/``_strip_think_tags``. + """ + return { + "run_name": "title_agent", + "tags": ["middleware:title"], + "callbacks": [], + } + def _fallback_title(self, user_msg: str) -> str: config = self._get_title_config() fallback_chars = min(config.max_chars, 50) @@ -153,21 +168,6 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return user_msg[:fallback_chars].rstrip() + "..." return user_msg if user_msg else "New Conversation" - def _get_runnable_config(self) -> dict[str, Any]: - """Inherit the parent RunnableConfig and add middleware tag. - - This ensures RunJournal identifies LLM calls from this middleware - as ``middleware:title`` instead of ``lead_agent``. - """ - try: - parent = get_config() - except Exception: - parent = {} - config = {**parent} - config["run_name"] = "title_agent" - config["tags"] = [*(config.get("tags") or []), "middleware:title"] - return config - def _provisional_title_result(self, state: TitleMiddlewareState) -> dict | None: """Set an instant placeholder title from the first user message (no LLM).""" if not self._should_set_provisional_title(state): @@ -221,7 +221,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): logger.debug("Could not override extra_body to disable thinking on title model", exc_info=True) async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None: - """Generate a title asynchronously and fall back locally on failure.""" + """Polish the title with an LLM, without leaking its reasoning to the UI. + + The title model is invoked with an *isolated* config (``_isolated_config``) + that does not inherit the parent's streaming callbacks, so even a + reasoning-only deployment's ```` tokens never reach the live UI + stream or the run journal. Thinking is also force-disabled on the model, + and the final stored title is stripped of any residual ```` blocks. + On any failure we fall back to the local question-derived title. + """ if not self._should_generate_title(state): return None @@ -229,7 +237,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): prompt, user_msg = self._build_title_prompt(state) try: - model_kwargs = {"thinking_enabled": False} + model_kwargs: dict[str, Any] = {"thinking_enabled": False, "force_disable_thinking": True} if self._app_config is not None: model_kwargs["app_config"] = self._app_config if config.model_name: @@ -237,12 +245,12 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): else: model = create_chat_model(**model_kwargs) self._force_disable_thinking(model) - response = await model.ainvoke(prompt, config=self._get_runnable_config()) + response = await model.ainvoke(prompt, config=self._isolated_config()) title = self._parse_title(response.content) if title: return {"title": title, "title_provisional": False} except Exception: - logger.debug("Failed to generate async title; falling back to local title", exc_info=True) + logger.debug("Failed to generate async title via LLM; falling back to local title", exc_info=True) return {"title": self._fallback_title(user_msg), "title_provisional": False} @override diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py index 37c937c2..777eb0ea 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py @@ -285,6 +285,13 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): if not isinstance(last_message, HumanMessage): return None + # Only turns with fresh upload metadata need the uploads directory. + # Avoid resolving the thread owner (which may hit the DB) for ordinary + # no-file chat turns, including ephemeral open-chat requests. + kwargs_files = (last_message.additional_kwargs or {}).get("files") + if not isinstance(kwargs_files, list) or not kwargs_files: + return None + # Resolve uploads directory for existence checks thread_id = (runtime.context or {}).get("thread_id") if thread_id is None: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py index 5dbc8565..2192395a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py @@ -42,6 +42,12 @@ logger = logging.getLogger(__name__) _owner_cache: dict[str, str] = {} _owner_cache_lock = threading.Lock() _OWNER_CACHE_MAX = 4096 +_LOCAL_ONLY_THREAD_PREFIXES = ("open-", "__skill-review__") + + +def _is_local_only_thread(thread_id: str | None) -> bool: + """Return True for synthetic threads that are never persisted in threads_meta.""" + return bool(thread_id) and str(thread_id).startswith(_LOCAL_ONLY_THREAD_PREFIXES) def _cache_get(thread_id: str) -> str | None: @@ -112,7 +118,7 @@ def resolve_path_user_id(thread_id: str | None) -> str: share the conversation read/write the same physical directory. For every other case it is the current login (``get_effective_user_id``). """ - if not thread_id: + if not thread_id or _is_local_only_thread(thread_id): return get_effective_user_id() cached = _cache_get(thread_id) if cached is not None: @@ -132,7 +138,7 @@ def resolve_path_user_id(thread_id: str | None) -> str: async def aresolve_path_user_id(thread_id: str | None) -> str: """Async variant of :func:`resolve_path_user_id` for FastAPI handlers.""" - if not thread_id: + if not thread_id or _is_local_only_thread(thread_id): return get_effective_user_id() cached = _cache_get(thread_id) if cached is not None: