|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""Chat title refresh service. |
| 3 | +
|
| 4 | +Responsible for generating a new title from recent messages and |
| 5 | +persisting it via the chat manager's compare-and-set mechanism. |
| 6 | +This is a pure chat-layer concern — no knowledge of memory backends. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import asyncio |
| 12 | +import logging |
| 13 | +from typing import TYPE_CHECKING, Any |
| 14 | + |
| 15 | +if TYPE_CHECKING: |
| 16 | + from ...agents.model_factory import _ModelAndFormatter |
| 17 | + from agentscope.message import Msg |
| 18 | + |
| 19 | +logger = logging.getLogger(__name__) |
| 20 | + |
| 21 | + |
| 22 | +class ChatTitleRefreshService: |
| 23 | + """Generate chat titles and persist them via compare-and-set. |
| 24 | +
|
| 25 | + Public API:: |
| 26 | +
|
| 27 | + async def refresh( |
| 28 | + session_id: str, |
| 29 | + recent_messages: list[Msg], |
| 30 | + ) -> None |
| 31 | +
|
| 32 | + All failures are logged and swallowed so title refresh never breaks |
| 33 | + the request path. |
| 34 | + """ |
| 35 | + |
| 36 | + def __init__( |
| 37 | + self, |
| 38 | + chat_manager: Any, |
| 39 | + agent_id: str, |
| 40 | + ) -> None: |
| 41 | + self._chat_manager = chat_manager |
| 42 | + self._agent_id = agent_id |
| 43 | + |
| 44 | + async def refresh( |
| 45 | + self, |
| 46 | + *, |
| 47 | + session_id: str, |
| 48 | + recent_messages: list[Any], |
| 49 | + ) -> None: |
| 50 | + """Re-generate a chat title from the recent conversation slice. |
| 51 | +
|
| 52 | + Called after each auto-memory flush (see ``ChatTitleRefreshMiddleware``). |
| 53 | + The chat is located by runtime session id, the recent messages are fed |
| 54 | + to the LLM for a fresh title, and the name is updated compare-and-set |
| 55 | + so a user-chosen name is never clobbered. |
| 56 | + """ |
| 57 | + if not recent_messages: |
| 58 | + return |
| 59 | + |
| 60 | + from ...config.config import load_agent_config |
| 61 | + from ...exceptions import AppBaseException |
| 62 | + |
| 63 | + try: |
| 64 | + cfg = load_agent_config(self._agent_id).running |
| 65 | + except (ValueError, AppBaseException) as exc: |
| 66 | + logger.info("Auto title refresh skipped: config unavailable (%s)", exc) |
| 67 | + return |
| 68 | + |
| 69 | + title_cfg = cfg.auto_title_config |
| 70 | + if not title_cfg.enabled or not title_cfg.refresh_on_auto_memory: |
| 71 | + logger.info( |
| 72 | + "Auto title refresh skipped: refresh_on_auto_memory disabled " |
| 73 | + "(enabled=%s refresh=%s)", |
| 74 | + title_cfg.enabled, |
| 75 | + title_cfg.refresh_on_auto_memory, |
| 76 | + ) |
| 77 | + return |
| 78 | + |
| 79 | + chat = await self._chat_manager.find_chat_by_session_id(session_id) |
| 80 | + if chat is None: |
| 81 | + logger.info( |
| 82 | + "Auto title refresh skipped: no chat for session %s", |
| 83 | + session_id, |
| 84 | + ) |
| 85 | + return |
| 86 | + |
| 87 | + chat_id = chat.id |
| 88 | + |
| 89 | + transcript = _messages_to_text(recent_messages) |
| 90 | + if not transcript: |
| 91 | + await self._record(chat_id, ok=False, reason="empty transcript") |
| 92 | + return |
| 93 | + |
| 94 | + try: |
| 95 | + from ...agents.model_factory import create_model_and_formatter |
| 96 | + from ...utils.model_response import consume_model_response |
| 97 | + from ..title_generator import REFRESH_TITLE_PROMPT, _clean_title |
| 98 | + from agentscope.message import Msg, TextBlock |
| 99 | + |
| 100 | + try: |
| 101 | + model, _ = create_model_and_formatter( |
| 102 | + agent_id=self._agent_id, |
| 103 | + ) |
| 104 | + except (ValueError, AppBaseException) as exc: |
| 105 | + logger.info( |
| 106 | + "Auto title refresh skipped: no model available for chat %s (%s)", |
| 107 | + chat_id, |
| 108 | + exc, |
| 109 | + ) |
| 110 | + await self._record(chat_id, ok=False, reason=f"no model: {exc}") |
| 111 | + return |
| 112 | + |
| 113 | + messages = [ |
| 114 | + Msg( |
| 115 | + name="system", |
| 116 | + role="system", |
| 117 | + content=[TextBlock(type="text", text=REFRESH_TITLE_PROMPT)], |
| 118 | + ), |
| 119 | + Msg( |
| 120 | + name="user", |
| 121 | + role="user", |
| 122 | + content=[TextBlock(type="text", text=transcript)], |
| 123 | + ), |
| 124 | + ] |
| 125 | + |
| 126 | + raw_title = await asyncio.wait_for( |
| 127 | + consume_model_response(model, messages), |
| 128 | + timeout=title_cfg.timeout_seconds, |
| 129 | + ) |
| 130 | + except Exception: |
| 131 | + logger.exception( |
| 132 | + "Auto title refresh LLM failed for chat %s", |
| 133 | + chat_id, |
| 134 | + ) |
| 135 | + await self._record(chat_id, ok=False, reason="LLM failed") |
| 136 | + return |
| 137 | + |
| 138 | + title = _clean_title(raw_title) |
| 139 | + if not title: |
| 140 | + logger.info( |
| 141 | + "Auto title refresh produced empty output for chat %s", |
| 142 | + chat_id, |
| 143 | + ) |
| 144 | + await self._record(chat_id, ok=False, reason="empty LLM output") |
| 145 | + return |
| 146 | + |
| 147 | + # Compare-and-set: expected name is the last title we set (or the |
| 148 | + # current name for chats created before this feature shipped). If the |
| 149 | + # user renamed the chat manually, the name no longer matches and the |
| 150 | + # update is skipped. |
| 151 | + expected_name = chat.meta.get("auto_title_last") or chat.name |
| 152 | + updated = await self._chat_manager.set_auto_title( |
| 153 | + chat.id, |
| 154 | + title, |
| 155 | + expected_name=expected_name, |
| 156 | + ) |
| 157 | + if updated is None: |
| 158 | + logger.info( |
| 159 | + "Auto title refresh skipped: chat %s renamed manually", |
| 160 | + chat.id, |
| 161 | + ) |
| 162 | + await self._record(chat_id, ok=False, reason="renamed manually") |
| 163 | + return |
| 164 | + logger.info( |
| 165 | + "Auto-refreshed chat %s title to %r (session %s)", |
| 166 | + chat.id, |
| 167 | + title, |
| 168 | + session_id, |
| 169 | + ) |
| 170 | + await self._record(chat_id, ok=True, reason="ok", title=title) |
| 171 | + |
| 172 | + async def _record( |
| 173 | + self, |
| 174 | + chat_id: str, |
| 175 | + *, |
| 176 | + ok: bool, |
| 177 | + reason: str = "", |
| 178 | + title: str = "", |
| 179 | + ) -> None: |
| 180 | + try: |
| 181 | + await self._chat_manager.record_auto_title_refresh( |
| 182 | + chat_id, |
| 183 | + ok=ok, |
| 184 | + reason=reason, |
| 185 | + title=title, |
| 186 | + ) |
| 187 | + except Exception: |
| 188 | + logger.exception( |
| 189 | + "Auto title refresh state record failed for chat %s", |
| 190 | + chat_id, |
| 191 | + ) |
| 192 | + |
| 193 | + |
| 194 | +def _messages_to_text(messages: list[Any]) -> str: |
| 195 | + """Convert a list of message objects into a plain-text transcript.""" |
| 196 | + parts: list[str] = [] |
| 197 | + for msg in messages: |
| 198 | + role = getattr(msg, "role", "") |
| 199 | + content = getattr(msg, "content", "") |
| 200 | + if isinstance(content, list): |
| 201 | + # AgentScope 2.0 Msg — extract text blocks |
| 202 | + for block in content: |
| 203 | + text = getattr(block, "text", "") or "" |
| 204 | + if text: |
| 205 | + parts.append(f"[{role}] {text}") |
| 206 | + elif content: |
| 207 | + parts.append(f"[{role}] {content}") |
| 208 | + return "\n".join(parts) |
0 commit comments