Files
hermes-agent/hermes_state_titles.py
nicecuprani 06475d1138 fix(session-titles): resolve the canonical Bot Chat by exact title, never a "#N" sibling
`SessionDB.resolve_session_by_title()` prefers a "`<title> #N`" continuation over the
exact match. For the canonical Bot Chat that preference is wrong: Bot Mode identifies
that session BY its exact title, and this lookup is the only one every DM transport
shares (`hermes -p <bot> chat --in ~ -c "Bot Chat"` — used by `message_agent`,
`bot_relay` and cron delivery).

Once a numbered sibling exists (a Desktop branch of the canonical chat,
`title_source='derived'`), every teammate DM resolves to that sibling: a visible,
unmanaged session where the `message_agent` gate is off, so the addressed bot cannot
answer and the sender gets silence instead of an error.

Return the exact match when the requested title IS the canonical Bot Chat title; every
other title keeps the "newest continuation wins" behaviour untouched.

(cherry picked from commit e92638b4c53c97c48db4d9bbb2d3b6b580d289a7)
2026-09-20 10:32:40 -07:00

199 lines
11 KiB
Python

"""Session title mixin for SessionDB: sanitizing, auto/user provenance ranking, and
lineage-aware lookups."""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, Optional
from agent.message_sanitization import _sanitize_surrogates
from hermes_state_common import _COMPRESSION_CHILD_SQL, escape_like as _escape_like
# caplog tests pin the "hermes_state" logger name.
logger = logging.getLogger("hermes_state")
# ASCII controls (keeping \t \n \r for the whitespace collapse), then zero-width,
# bidi override, object-replacement and interlinear-annotation code points.
_TITLE_CONTROL_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
_TITLE_INVISIBLE_RE = re.compile(r'[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]')
_NUMBERED_TITLE_RE = re.compile(r'^(.*?) #(\d+)$')
class SessionTitlesMixin:
"""Sanitizing, ranking auto/user titles, lineage-aware lookups."""
@classmethod
def _title_rank(cls, source: Optional[str]) -> int:
"""Rank a stored title_source. NULL (pre-provenance rows) is indistinguishable from a
manual ``/title`` of that era, so it ranks as ``user``."""
rank = cls._TITLE_SOURCE_RANK
return rank[cls.TITLE_SOURCE_USER] if source is None else rank.get(str(source), 0)
@staticmethod
def sanitize_title(title: Optional[str]) -> Optional[str]:
"""Strip control/zero-width/bidi chars (and lone surrogates sqlite3 cannot bind),
collapse whitespace, normalize empty to None. ValueError past MAX_TITLE_LENGTH."""
from hermes_state import SessionDB
if not title:
return None
cleaned = _TITLE_INVISIBLE_RE.sub('', _TITLE_CONTROL_RE.sub('', _sanitize_surrogates(title)))
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
if not cleaned:
return None
if len(cleaned) > SessionDB.MAX_TITLE_LENGTH:
raise ValueError(f"Title too long ({len(cleaned)} chars, max {SessionDB.MAX_TITLE_LENGTH})")
return cleaned
def _is_compression_ancestor(self, conn, *, ancestor_id: str, descendant_id: str) -> bool:
"""True if *ancestor_id* is a compression predecessor of *descendant_id*, via the
canonical continuation edge ``_COMPRESSION_CHILD_SQL`` (excludes delegate/branch
children that also carry ``parent_session_id``)."""
if not ancestor_id or not descendant_id or ancestor_id == descendant_id:
return False
edge = _COMPRESSION_CHILD_SQL.format(a="child")
return conn.execute(f"""
WITH RECURSIVE ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE {edge}
)
SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1
""", (descendant_id, ancestor_id, descendant_id)).fetchone() is not None
def _set_session_title(self, session_id: str, title: str, *, source: str) -> bool:
"""Write a title, enforcing provenance precedence. A ``user`` write always lands;
``derived``/``llm`` land only when the row is untitled or holds strictly lower
authority (derived upgrades to llm exactly once, nothing overwrites a user name,
re-running the titler on an llm row is a no-op). No writer may move a hidden
canonical Bot Chat off its title. Read and write are one compare-and-swap
transaction, so a manual ``/title`` racing an in-flight generation is not clobbered."""
title = self.sanitize_title(title)
is_user = source == self.TITLE_SOURCE_USER
new_rank = self._title_rank(source) if not is_user else None
def _do(conn):
current = conn.execute(
"SELECT title, title_source, hidden FROM sessions WHERE id = ?", (session_id,),
).fetchone()
if current is None:
return 0
# The canonical Bot Chat's NAME is its identity (Bot Mode resolves it by
# exact-title lookup on every open), so a rename orphans the conversation. Hidden
# is the discriminator: canonical chats are born hidden; a visible session merely
# named "Bot Chat" stays renameable. Provenance-blind.
if ((current["title"] or "") == self.CANONICAL_BOT_CHAT_TITLE and bool(current["hidden"])
and title != self.CANONICAL_BOT_CHAT_TITLE):
if is_user:
raise ValueError("This is the bot's canonical Bot Chat — its name is its "
"identity, and renaming it would orphan the conversation. "
"To start fresh, create a new bot instead.")
return 0
if not is_user and current["title"] is not None and self._title_rank(current["title_source"]) >= new_rank:
return 0
if title:
conflict = conn.execute(
"SELECT id, archived, hidden FROM sessions WHERE title = ? AND id != ?", (title, session_id),
).fetchone()
if conflict:
conflict_id = conflict["id"]
# A hidden compressed ancestor holding the title cannot be freed by the
# user, so transfer it onto the tip (uniqueness + lineage kept).
if self._is_compression_ancestor(conn, ancestor_id=conflict_id, descendant_id=session_id):
conn.execute("UPDATE sessions SET title = NULL WHERE id = ?", (conflict_id,))
# A deliberately archived hidden Bot Chat is a retired registry
# entry, not a live identity. Retire its name in the same title
# transaction so a replacement can become the sole canonical row;
# the old session remains archived and otherwise untouched.
elif (title == self.CANONICAL_BOT_CHAT_TITLE and bool(conflict["archived"])
and bool(conflict["hidden"])):
conn.execute(
"UPDATE sessions SET title = NULL, title_source = NULL WHERE id = ?",
(conflict_id,),
)
else:
raise ValueError(f"Title '{title}' is already in use by session {conflict_id}")
# CAS on the values just read (``IS`` is NULL-safe): a concurrent write between
# the SELECT and here loses instead of being overwritten.
return conn.execute(
"UPDATE sessions SET title = ?, title_source = ? WHERE id = ? AND title IS ? AND title_source IS ?",
(title, source if title else None, session_id, current["title"], current["title_source"]),
).rowcount
return self._execute_write(_do) > 0
def set_session_title(self, session_id: str, title: str) -> bool:
"""Set a title on the user's behalf (``user`` provenance). Empty clears it. Raises
ValueError on conflict or validation failure."""
return self._set_session_title(session_id, title, source=self.TITLE_SOURCE_USER)
def set_auto_title(self, session_id: str, title: str, *, source: str) -> bool:
"""Set an automatic title; False (untouched) when a higher-authority title holds the row."""
if source not in (self.TITLE_SOURCE_DERIVED, self.TITLE_SOURCE_LLM):
raise ValueError(f"invalid automatic title source: {source!r}")
return self._set_session_title(session_id, title, source=source)
def get_session_title(self, session_id: str) -> Optional[str]:
"""Get the title for a session, or None."""
row = self._read_one("SELECT title FROM sessions WHERE id = ?", (session_id,))
return row["title"] if row else None
def get_session_title_source(self, session_id: str) -> Optional[str]:
"""Get the provenance of a session's title, or None when untitled."""
row = self._read_one("SELECT title, title_source FROM sessions WHERE id = ?", (session_id,))
return row["title_source"] if row and row["title"] is not None else None
def set_session_title_source(self, session_id: str, source: str) -> bool:
"""Overwrite a title's provenance without touching the text (a title copied across a
compression rotation keeps the original's authority)."""
if source not in self._TITLE_SOURCE_RANK:
raise ValueError(f"invalid title source: {source!r}")
return self._write_rowcount(
"UPDATE sessions SET title_source = ? WHERE id = ? AND title IS NOT NULL", (source, session_id)
) > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
row = self._read_one(
"SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved "
"FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
"WHERE s.title = ?", (title,))
return self._session_row_dict(row) if row else None
def resolve_session_by_title(self, title: str) -> Optional[str]:
"""Resolve a title to a session ID, preferring the latest "title #N" continuation."""
exact = self.get_session_by_title(title)
# Exception to the "#N continuation" preference: the canonical Bot Chat's identity
# IS its exact title (Bot Mode re-resolves it by name on every open, no id pointer).
# A "<title> #N" sibling — a Desktop branch or a client-minted numbered row — is NOT
# a Bot Mode session: it is visible, unmanaged, and the message_agent gate is off in
# it. Every DM transport (``hermes -p <bot> chat --in ~ -c "Bot Chat"``: message_agent,
# bot_relay, cron delivery) resolves through here, so letting the numbered row win
# silently routes teammates' messages into a chat whose bot cannot answer back.
if exact is not None and title == self.CANONICAL_BOT_CHAT_TITLE:
return exact["id"]
# Escape LIKE wildcards so "%"/"_" in titles cannot false-match.
numbered = self._read_all(
"SELECT id, title, started_at FROM sessions "
"WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
(f"{_escape_like(title)} #%",))
return numbered[0]["id"] if numbered else (exact["id"] if exact else None)
def get_next_title_in_lineage(self, base_title: str) -> str:
"""Next title in a lineage ("my session" -> "my session #2"): strip any " #N" suffix,
then increment the highest existing number."""
match = _NUMBERED_TITLE_RE.match(base_title)
base = match.group(1) if match else base_title
rows = self._read_all(
"SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
(base, f"{_escape_like(base)} #%"))
if not rows:
return base
# The unnumbered original counts as #1.
numbers = [int(m.group(2)) for m in (_NUMBERED_TITLE_RE.match(row["title"]) for row in rows) if m]
return f"{base} #{max([1, *numbers]) + 1}"