Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 264 additions & 0 deletions agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,45 @@
"[System: The active model for this chat has changed to ",
)

# Re-title prompt. Unlike the first-exchange prompts above, this one is shown
# the WHOLE conversation plus the current title, and is biased to KEEP the
# existing title unless the durable topic has clearly drifted. This is what
# stops a localized detour (e.g. "write the PDF" inside a long USCIS-RFE
# conversation) from clobbering a title that describes the real subject.
_RETITLE_PROMPT = (
"You are maintaining the title of an ongoing conversation. Below is the conversation "
"so far (it may be condensed to its opening and most recent turns) and its CURRENT title.\n\n"
"Assess whether the current title still captures the conversation's main topic and intent "
"considered as a WHOLE — not just the most recent message.\n\n"
"Rules:\n"
"- If the current title is still accurate, return it UNCHANGED, verbatim.\n"
"- Strongly prefer keeping or only lightly adjusting the current title. Most of the time it "
"does not need to change.\n"
"- Only write a substantially different title if the conversation's main subject has clearly "
"and durably moved away from what the current title describes.\n"
"- Do NOT retitle based on the latest message alone; a brief detour or sub-task is not a "
"topic change.\n"
"- Keep it short (3-7 words). Write the title in the same language the user is writing in.\n"
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)

_RETITLE_PROMPT_PINNED_LANGUAGE = (
"You are maintaining the title of an ongoing conversation. Below is the conversation "
"so far (it may be condensed to its opening and most recent turns) and its CURRENT title.\n\n"
"Assess whether the current title still captures the conversation's main topic and intent "
"considered as a WHOLE — not just the most recent message.\n\n"
"Rules:\n"
"- If the current title is still accurate, return it UNCHANGED, verbatim.\n"
"- Strongly prefer keeping or only lightly adjusting the current title. Most of the time it "
"does not need to change.\n"
"- Only write a substantially different title if the conversation's main subject has clearly "
"and durably moved away from what the current title describes.\n"
"- Do NOT retitle based on the latest message alone; a brief detour or sub-task is not a "
"topic change.\n"
"- Keep it short (3-7 words). Write the title in {language}.\n"
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)


def _title_language() -> str:
"""Return configured title language, or empty string to match the user."""
Expand Down Expand Up @@ -528,6 +567,159 @@ def apply_instant_title(
return None


def _condense_history(
conversation_history: list,
head_turns: int = 1,
tail_turns: int = 3,
per_message: int = 400,
) -> str:
"""Render a conversation into a compact transcript for whole-conversation
title assessment.

Keeps the OPENING ``head_turns`` user/assistant exchanges (they anchor the
conversation's original intent — the thing a good title names) and the most
recent ``tail_turns`` exchanges (they reveal genuine topic drift). Anything
in between is elided with a ``[…]`` marker so the request stays cheap on a
long conversation instead of dumping the entire transcript into the
auxiliary model.

Only ``user`` and ``assistant`` roles are included; system/tool messages
are skipped. Each message is truncated to ``per_message`` chars.
"""
msgs = [
m for m in (conversation_history or [])
if m.get("role") in ("user", "assistant") and (m.get("content") or "").strip()
]
if not msgs:
return ""

# A "turn" here is a single message; head/tail are counted in messages so
# the head captures the opening user+assistant pair at head_turns=1 -> 2.
head_n = max(0, head_turns) * 2
tail_n = max(0, tail_turns) * 2

if len(msgs) <= head_n + tail_n:
selected = list(msgs)
elided_at = -1
else:
head = msgs[:head_n]
tail = msgs[len(msgs) - tail_n:] if tail_n else []
selected = head + tail
elided_at = len(head)

lines = []
for i, m in enumerate(selected):
if i == elided_at:
lines.append("[… earlier turns omitted …]")
role = "User" if m.get("role") == "user" else "Assistant"
content = (m.get("content") or "").strip()
if len(content) > per_message:
content = content[:per_message] + "…"
lines.append(f"{role}: {content}")
return "\n".join(lines)


def _looks_like_title(text: str) -> bool:
"""Return True if ``text`` is shaped like a real title, not prose.

The retitle model is asked "should the title change?" and sometimes answers
CONVERSATIONALLY ("The title remains accurate. The conversation is still
about …") instead of returning a title. Without this guard that sentence is
sanitized, truncated at 80 chars, and stored AS the title — then pushed to
the Discord thread name. A genuine 3-7 word title never trips these signals;
prose reliably does. Multi-signal reject (approved threshold):

- >10 words → prose, not a 3-7 word title
- >80 chars → longer than any legitimate short title
- mid-sentence '. ' → a period followed by more words is a sentence, not a title
- internal newline → multi-line output is never a title
"""
if not text:
return False
if len(text) > 80:
return False
if "\n" in text:
return False
if len(text.split()) > 10:
return False
# A sentence break — a lowercase word ending in '.' followed by a space and
# more text — means prose ("… accurate. The conversation …"). Requiring a
# LOWERCASE letter before the dot avoids false-flagging abbreviations whose
# dotted component is uppercase or single-letter ("U.S. Visa Renewal",
# "e.g. Docker", "Q3 Review"). A trailing period is stripped elsewhere.
if re.search(r"[a-z]\.\s+\S", text):
return False
return True


def regenerate_title(
conversation_history: list,
current_title: str,
timeout: float = 30.0,
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
) -> Optional[str]:
"""Re-assess an existing session title against the WHOLE conversation.

Unlike :func:`generate_title` (which only sees the first exchange), this
reads a condensed view of the entire conversation plus the current title,
and is prompted to KEEP the existing title unless the durable topic has
clearly drifted. Returns the (possibly unchanged) title, or ``None`` on
failure / empty transcript.
"""
transcript = _condense_history(conversation_history)
if not transcript:
return None

language = _title_language()
prompt = (
_RETITLE_PROMPT_PINNED_LANGUAGE.format(language=language)
if language else _RETITLE_PROMPT
)

user_block = (
f"CURRENT TITLE: {current_title or '(none)'}\n\n"
f"CONVERSATION:\n{transcript}"
)
messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": user_block},
]

try:
response = call_llm(
task="title_generation",
messages=messages,
max_tokens=500,
temperature=0.3,
timeout=timeout,
main_runtime=main_runtime,
)
title = (response.choices[0].message.content or "").strip()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sanitize reasoning output with the same strip_think_blocks helper used by generate_title before validating the title. Otherwise a periodic retitle from a think-enabled model can persist `` content into the session and Discord thread name.

title = title.strip('"\'')
if title.lower().startswith("title:"):
title = title[6:].strip()
# Reject conversational / prose output instead of truncating it into a
# title. When the model answers "should this change?" in prose rather
# than returning a title, treat it as "no usable new title" → None,
# which the caller reads as "keep the current title, don't rename".
# (Do NOT fall back to title[:77]+"..." here — that is exactly how a
# 100-char sentence became a Discord thread name.)
if not _looks_like_title(title):
logger.debug("Retitle: rejected non-title output: %r", title[:120])
return None
return title if title else None
except Exception as e:
logger.warning("Title regeneration failed: %s", e)
logger.debug("Title regeneration traceback", exc_info=True)
if failure_callback is not None:
try:
failure_callback("title regeneration", e)
except Exception:
logger.debug("Title regeneration failure_callback raised", exc_info=True)
return None


def auto_title_session(
session_db,
session_id: str,
Expand Down Expand Up @@ -700,6 +892,78 @@ def _session_is_untitled(session_db, session_id: str) -> bool:
return False


def maybe_retitle_session(
session_db,
session_id: str,
user_message: str,
assistant_response: str,
conversation_history: list,
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
title_callback: Optional[TitleCallback] = None,
every_n_turns: int = 6,
) -> None:
"""Periodically re-evaluate a session's title to keep it relevant as the
conversation evolves. Fires every ``every_n_turns`` user turns AFTER the
initial auto-title (so first-turn handling stays exclusively with
:func:`maybe_auto_title`).

Cheap path:
- Only runs every Nth turn.
- Only acts once conversation_history has at least 3 user messages.
- Assesses the WHOLE conversation (condensed) against the current title via
:func:`regenerate_title`, which is biased to keep the existing title
unless the durable topic has clearly drifted. Only saves + fires the
callback (which drives the thread rename) when the title actually changes.
"""
if not session_db or not session_id or not user_message or not assistant_response:
return
user_msg_count = sum(1 for m in (conversation_history or []) if m.get("role") == "user")
# First-turn is handled by maybe_auto_title; only act on 3rd+ user turns.
if user_msg_count < 3:
return
if every_n_turns <= 0 or (user_msg_count % every_n_turns) != 0:
return

# Snapshot history now — the background thread must assess the conversation
# as it stands at this turn, not whatever it has mutated into later.
history_snapshot = list(conversation_history or [])

def _runner():
try:
existing = session_db.get_session_title(session_id) or ""
except Exception:
return
new_title = regenerate_title(
history_snapshot, existing,
failure_callback=failure_callback, main_runtime=main_runtime,
)
if not new_title:
return
new_title = new_title.strip()
# regenerate_title is prompted to return the current title verbatim when
# nothing has drifted; treat an unchanged title as a no-op so we don't
# churn the DB or spuriously rename the thread. Compare case- and
# trailing-punctuation-insensitively so "USCIS RFE Response." doesn't
# count as a change from "USCIS RFE Response".
def _norm(t: str) -> str:
return t.strip().rstrip(".!?,;: ").lower()
if not new_title or _norm(new_title) == _norm(existing):
return
try:
session_db.set_session_title(session_id, new_title)
except Exception:
return
if title_callback is not None:
try:
title_callback(new_title)
except Exception:
logger.debug("Retitle callback failed", exc_info=True)

thread = threading.Thread(target=_runner, daemon=True, name="retitle")
thread.start()


def maybe_auto_title(
session_db,
session_id: str,
Expand Down
40 changes: 40 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7390,6 +7390,46 @@ def _approval_notify_sync(approval_data: dict) -> None:
# as `_on_session_title` before the run starts (see
# _attach_session_title_callback), because the titler now fires from
# inside the turn prologue rather than from here.
#
# LOCAL CARRY (periodic retitle, PR #29983 residual delta): after the
# turn completes we re-evaluate the session title against the WHOLE
# (condensed) conversation and rename the thread only when the durable
# topic has drifted. Upstream owns first-turn semantic titling; this is
# the periodic half it still lacks. Reuses the same `_on_session_title`
# rename lane, adapted to its (title, title_source) signature — a
# periodic retitle is a real model-derived title, so it carries the
# "llm" source that the rename callbacks gate on. Fire-and-forget on a
# daemon thread inside maybe_retitle_session; never affects the turn.
try:
from agent.title_generator import maybe_retitle_session
_retitle_cb = getattr(agent, "_on_session_title", None)
_history = (
ctx.result_holder[0].get("messages", [])
if ctx.result_holder[0] else []
)
maybe_retitle_session(
self._session_db,
effective_session_id,
getattr(ctx, "user_message", None) or "",
final_response,
_history,
failure_callback=(
getattr(agent, "_title_failure_callback", None)
or getattr(agent, "_emit_auxiliary_failure", None)
),
main_runtime={
"model": getattr(agent, "model", None),
"provider": getattr(agent, "provider", None),
"base_url": getattr(agent, "base_url", None),
"api_key": getattr(agent, "api_key", None),
"api_mode": getattr(agent, "api_mode", None),
},
title_callback=(
(lambda t: _retitle_cb(t, "llm")) if _retitle_cb else None
),
)
except Exception:
logger.debug("Periodic retitle dispatch failed", exc_info=True)

return {
"final_response": final_response,
Expand Down
Loading