"""Middleware for automatic thread title generation.""" import copy import logging import re from pathlib import Path from typing import TYPE_CHECKING, Any, NotRequired, override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware from langgraph.runtime import Runtime from deerflow.config.title_config import get_title_config from deerflow.models import create_chat_model from deerflow.utils.time import now_iso if TYPE_CHECKING: from deerflow.config.app_config import AppConfig from deerflow.config.title_config import TitleConfig logger = logging.getLogger(__name__) _TITLE_DEBUG_LOGGER_NAME = "title_generation_debug" def _get_title_debug_logger() -> logging.Logger: debug_logger = logging.getLogger(_TITLE_DEBUG_LOGGER_NAME) if getattr(debug_logger, "_title_file_configured", False): return debug_logger log_path = Path("logs/title_generation_debug.log") if not log_path.is_absolute(): log_path = Path.cwd() / log_path log_path.parent.mkdir(parents=True, exist_ok=True) handler = logging.FileHandler(log_path, encoding="utf-8") handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) debug_logger.addHandler(handler) debug_logger.setLevel(logging.INFO) debug_logger.propagate = False setattr(debug_logger, "_title_file_configured", True) return debug_logger def log_title_debug(message: str, *args: Any) -> None: logger.warning(message, *args) _get_title_debug_logger().info(message, *args) class TitleMiddlewareState(AgentState): """Compatible with the `ThreadState` schema.""" title: NotRequired[str | None] title_provisional: NotRequired[bool] class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): """Automatically generate a title for the thread after the first user message.""" state_schema = TitleMiddlewareState def __init__(self, *, app_config: "AppConfig | None" = None, title_config: "TitleConfig | None" = None): super().__init__() self._app_config = app_config self._title_config = title_config def _get_title_config(self): if self._title_config is not None: return self._title_config if self._app_config is not None: return self._app_config.title return get_title_config() def _normalize_content(self, content: object) -> str: if isinstance(content, str): return content if isinstance(content, list): parts = [self._normalize_content(item) for item in content] return "\n".join(part for part in parts if part) if isinstance(content, dict): text_value = content.get("text") if isinstance(text_value, str): return text_value nested_content = content.get("content") if nested_content is not None: return self._normalize_content(nested_content) return "" def _message_type(self, message: object) -> str: if isinstance(message, dict): return str(message.get("type") or message.get("role") or "").lower() return str(getattr(message, "type", "") or getattr(message, "role", "") or "").lower() def _message_content(self, message: object) -> object: if isinstance(message, dict): return message.get("content", "") return getattr(message, "content", "") def _should_set_provisional_title(self, state: TitleMiddlewareState) -> bool: """Check if we should set an instant, question-derived placeholder title. Runs in ``before_model`` so the thread shows a sensible title the moment the user sends their first message — eliminating the "untitled" window while the (possibly long) first answer streams. No LLM call, no latency. """ config = self._get_title_config() if not config.enabled: return False # Already has a title (provisional or final) — nothing to do. if state.get("title"): return False # Only on the very first turn, as soon as the user message arrives. messages = state.get("messages", []) user_messages = [m for m in messages if self._message_type(m) in {"human", "user"}] return len(user_messages) == 1 def _should_generate_title(self, state: TitleMiddlewareState) -> bool: """Check if we should generate the final LLM title for this thread.""" config = self._get_title_config() if not config.enabled: return False # Skip only if we already have a *finalized* title. A provisional # placeholder set in before_model should still be upgraded by the LLM. if state.get("title") and not state.get("title_provisional"): return False # Check if this is the first turn (has at least one user message and one assistant response) messages = state.get("messages", []) if len(messages) < 2: return False # Count user and assistant messages user_messages = [m for m in messages if self._message_type(m) in {"human", "user"}] assistant_messages = [m for m in messages if self._message_type(m) in {"ai", "assistant"}] # Generate title after first complete exchange return len(user_messages) == 1 and len(assistant_messages) >= 1 def _build_title_prompt(self, state: TitleMiddlewareState) -> tuple[str, str]: """Extract user/assistant messages and build the title prompt. Returns (prompt_string, user_msg) so callers can use user_msg as fallback. """ config = self._get_title_config() messages = state.get("messages", []) user_msg_content = next((self._message_content(m) for m in messages if self._message_type(m) in {"human", "user"}), "") assistant_msg_content = next((self._message_content(m) for m in messages if self._message_type(m) in {"ai", "assistant"}), "") user_msg = self._normalize_content(user_msg_content) assistant_msg = self._strip_think_tags(self._normalize_content(assistant_msg_content)) prompt = config.prompt_template.format( max_words=config.max_words, user_msg=user_msg[:500], assistant_msg=assistant_msg[:500], ) return prompt, user_msg def _strip_think_tags(self, text: str) -> str: """Remove ... blocks emitted by reasoning models (e.g. minimax, GLM, DeepSeek-R1). Also handles malformed cases: an unclosed `` (response truncated mid-reasoning) or a stray `` (open tag eaten by upstream parsing) — both can otherwise leak raw reasoning into the title. """ text = re.sub(r"[\s\S]*?", "", text, flags=re.IGNORECASE) text = re.sub(r"[\s\S]*$", "", text, flags=re.IGNORECASE) text = re.sub(r"^[\s\S]*?", "", text, flags=re.IGNORECASE) return text.strip() def _parse_title(self, content: object) -> str: """Normalize model output into a clean title string (strips blocks).""" config = self._get_title_config() 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) if len(user_msg) > fallback_chars: return user_msg[:fallback_chars].rstrip() + "..." return user_msg if user_msg else "New Conversation" 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): return None messages = state.get("messages", []) user_msg_content = next((self._message_content(m) for m in messages if self._message_type(m) in {"human", "user"}), "") user_msg = self._normalize_content(user_msg_content) if not user_msg.strip(): return None title = self._fallback_title(user_msg) logger.debug("TitleMiddleware: set provisional title %r before first model call", title) return {"title": title, "title_provisional": True} def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None: """Generate a local fallback title without blocking on an LLM call.""" if not self._should_generate_title(state): return None _, user_msg = self._build_title_prompt(state) return {"title": self._fallback_title(user_msg), "title_provisional": False} def _force_disable_thinking(self, model: object) -> None: """Force-disable thinking on the title model via `extra_body` overrides. Title generation never needs reasoning. The model factory only emits disable params when the model config declares `when_thinking_disabled` or a matching `when_thinking_enabled` shape — many user-added reasoning models (e.g. GLM-4.5/5) only set `supports_thinking: true` and would otherwise keep thinking on, which is slow and leaks tags into the title prompt response. Injecting both common OpenAI-compatible disable shapes covers Zhipu GLM (`thinking.type`) and Qwen/vLLM (`chat_template_kwargs.enable_thinking`); other providers ignore unknown body fields. """ if not hasattr(model, "extra_body"): return current = getattr(model, "extra_body", None) or {} merged_chat_template_kwargs = { **(current.get("chat_template_kwargs") or {}), "enable_thinking": False, } merged = { **current, "thinking": {**(current.get("thinking") or {}), "type": "disabled"}, "chat_template_kwargs": merged_chat_template_kwargs, } try: model.extra_body = merged except Exception: 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: """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 config = self._get_title_config() prompt, user_msg = self._build_title_prompt(state) try: 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: model = create_chat_model(name=config.model_name, **model_kwargs) else: model = create_chat_model(**model_kwargs) self._force_disable_thinking(model) 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 via LLM; falling back to local title", exc_info=True) return {"title": self._fallback_title(user_msg), "title_provisional": False} @override def before_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: return self._provisional_title_result(state) @override async def abefore_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: return self._provisional_title_result(state) @override def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: # Keep the provisional title in place during the main run. The gateway # worker finalizes it in a background task after the answer is complete. return None @override async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: # Keep the provisional title in place during the main async run so the # worker's post-run background finalizer can replace it with the real # LLM-generated title without blocking the input box. return None async def finalize_thread_title( *, checkpointer: Any, thread_store: Any | None, thread_id: str, source_state: dict[str, Any], app_config: "AppConfig | None" = None, ) -> bool: """Finalize a provisional thread title in the background. ``source_state`` must be the checkpoint snapshot captured when the first run completed. That avoids races where the user asks a second question before the background title task starts, which would otherwise make the current checkpoint fail the "first exchange only" predicate and leave the title provisional forever. """ log_title_debug("Title finalize: evaluating background title generation for thread %s", thread_id) middleware = TitleMiddleware(app_config=app_config) source_snapshot = copy.deepcopy(source_state) if not middleware._should_generate_title(source_snapshot): log_title_debug( "Title finalize: skipped for thread %s because source snapshot is not eligible", thread_id, ) return False log_title_debug("Title finalize: starting background title generation for thread %s", thread_id) result = await middleware._agenerate_title_result(source_snapshot) if not result: log_title_debug("Title finalize: no title result produced for thread %s", thread_id) return False read_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} checkpoint_tuple = await checkpointer.aget_tuple(read_config) if checkpoint_tuple is None: log_title_debug("Title finalize: skipped for thread %s because checkpoint was not found", thread_id) return False checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {}) metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {}) channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}) or {}) # If another path already finalized or manually renamed the title, keep it. if channel_values.get("title_provisional") is not True: log_title_debug( "Title finalize: skipped for thread %s because title is no longer provisional", thread_id, ) return False channel_values.update(result) checkpoint["channel_values"] = channel_values metadata["updated_at"] = now_iso() write_config = { "configurable": { "thread_id": thread_id, "checkpoint_ns": "", } } new_versions: dict[str, Any] = {} get_next_version = getattr(checkpointer, "get_next_version", None) if callable(get_next_version): versions = dict(checkpoint.get("channel_versions", {}) or {}) for key in ("title", "title_provisional"): next_version = get_next_version(versions.get(key), None) versions[key] = next_version new_versions[key] = next_version checkpoint["channel_versions"] = versions await checkpointer.aput(write_config, checkpoint, metadata, new_versions) title = result.get("title") if title and thread_store is not None: await thread_store.update_display_name(thread_id, title, user_id=None) log_title_debug("Title finalize: completed for thread %s with title %r", thread_id, title) return True