Files
hermes-agent/hermes_state_profile_repair.py
teknium1 d956f0ae57 fix: key the tools[] pin by code version; never re-add config-excluded tools
Review follow-up on the byte-identical tools[] pin.

- The pin records the code identity that built it (checkout/build sha, else
  the release version). Written by the same code, every pinned tool that is
  still available keeps its pinned bytes, including tools whose parameters
  are derived per surface (delegate_task, text_to_speech, memory, patch).
  The per-tool "parameters differ -> take current" rule replaced those bytes
  on every surface hop and rewrote the ~44KB pin each time. A pin from other
  code (`hermes update`, legacy name lists) takes the current definitions
  once and is re-pinned.
- A pinned tool this process did not build is carried forward only while
  this agent's toolset selection allows it (enabled minus disabled toolsets
  and role reservations, before check_fn). It must also pass the session
  schema gates on the merged array, so browser_exec never comes back once
  terminal is gone. Client-surface toolsets (desktop_ui, project) still
  carry across hops: no config choice removed them there.
- The rotation compaction child inherits the parent's pin in the publish
  transaction.
- `hermes sessions recover` keeps pin rows in its system_prompts sweep and
  clears dangling pin hashes, as lost-and-found now does too. Profile moves
  carry the pin like the prompt. A continuing session whose pin is missing
  or unreadable (a row swept by an older build) pins the tools it sends on
  that turn, so later hops stay stable.
2026-09-23 15:43:51 -07:00

351 lines
20 KiB
Python

"""Crossed-profile durable state in ONE ``state.db`` — finders and fixers for
``hermes sessions repair-profiles`` (#88715 PR-6).
Every ``state.db`` belongs to exactly one profile (``<root>/state.db`` → ``default``,
``<root>/profiles/<name>/state.db`` → ``name``) and every gateway session key encodes the profile
that owns the conversation (``agent:main:…`` for the default, ``agent:<name>:…`` for a named one).
The per-profile store model (#88734) is forward-only: it routes NEW writes to the right store but
never touched rows that had already landed in the wrong one, and the identity fences
(``_INHERIT_PARENT_META_SQL``, ``_recovered_row_allowed_for_active_profile``) only refuse to
*widen* damage that already exists. This module is the backward-looking half: it names each crossed
row and can settle it.
Store-level only. Which profiles exist, which store owns which home, whether a live gateway holds the
routing index in memory, and the JSON files outside ``state.db`` are the orchestrator's concern
(``hermes_cli/sessions_repair_profiles.py``).
Moving a session between two stores is two single-store transactions — copy into the target, then
delete from the source — because a crash between them leaves a *duplicate*, which the next run
settles idempotently (:meth:`import_moved_session` reports ``present``; the delete only proceeds when
the target holds at least as many messages). The reverse order would lose the row.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
from hermes_state_common import _id_chunks, _placeholders as _session_ids_placeholders
logger = logging.getLogger(__name__)
def session_key_profile(session_key: Any) -> Optional[str]:
"""Profile encoded in an ``agent:<ns>:…`` gateway key (``default`` for ``main``), or None for a
keyless row (CLI/subagent lineage) or a key another producer minted (hosted rooms, tests)."""
if not isinstance(session_key, str):
return None
parts = session_key.split(":")
if len(parts) < 3 or parts[0] != "agent" or not parts[1]:
return None
from gateway.session import profile_from_session_key_namespace
return profile_from_session_key_namespace(parts[1])
def _stored_profile(value: Any) -> Optional[str]:
"""``sessions.profile_name`` as a comparable label; NULL/blank is "unowned", not a crossing."""
name = str(value or "").strip()
return name or None
def _table_columns(conn, table: str) -> List[str]:
return [row[1] for row in conn.execute(f"PRAGMA table_info('{table}')")]
# Columns a moved message must NOT carry over verbatim: ``id`` is reassigned by the target's
# AUTOINCREMENT and ``display_order`` points at a message id of the SOURCE store — left NULL, the
# ``messages_display_order_insert`` trigger recomputes it from ``display_identity`` (a content hash,
# store-independent) or the new id.
_MESSAGE_MOVE_SKIP = frozenset({"id", "display_order"})
class SessionProfileRepairMixin:
"""Per-store crossed-profile identity: find, relabel, sever, move, and settle routing/topic rows."""
# ── finders ────────────────────────────────────────────────────────────────
def find_crossed_profile_sessions(self, owner: str) -> Dict[str, List[Dict[str, Any]]]:
"""Keyed session rows whose identity disagrees with itself or with this store.
``mislabelled``: ``profile_name`` names a profile other than the one in the row's own key
(the key is what routing, the agent cache and ``_db_for_key`` consult; the label is stamped
after the fact). ``foreign``: the key's profile is not *owner* — the row sits in a store that
the routed profile never reads. ``crossed_parents``: child and parent are both keyed and name
different profiles, so the NULL-fill inheritance fence would have been the only thing standing
between them. A row can appear in more than one list.
"""
rows = self._read_all(
"SELECT s.id, s.session_key, s.profile_name, s.parent_session_id, s.message_count, "
" p.session_key AS parent_session_key "
"FROM sessions s LEFT JOIN sessions p ON p.id = s.parent_session_id "
"WHERE s.session_key LIKE 'agent:%' ORDER BY s.started_at, s.id")
found: Dict[str, List[Dict[str, Any]]] = {"mislabelled": [], "foreign": [], "crossed_parents": []}
for row in rows:
key_profile = session_key_profile(row["session_key"])
if key_profile is None:
continue
label = _stored_profile(row["profile_name"])
if label is not None and label != key_profile:
found["mislabelled"].append({
"id": row["id"], "session_key": row["session_key"],
"profile_name": label, "key_profile": key_profile})
if key_profile != owner:
found["foreign"].append({
"id": row["id"], "session_key": row["session_key"], "key_profile": key_profile,
"message_count": int(row["message_count"] or 0)})
parent_profile = session_key_profile(row["parent_session_key"])
if parent_profile is not None and parent_profile != key_profile:
found["crossed_parents"].append({
"id": row["id"], "key_profile": key_profile,
"parent_session_id": row["parent_session_id"], "parent_profile": parent_profile})
return found
def list_gateway_routing_rows(self) -> List[Dict[str, Any]]:
return [dict(row) for row in self._read_all(
"SELECT scope, session_key, entry_json, updated_at FROM gateway_routing "
"ORDER BY scope, session_key")]
def find_profile_less_telegram_topic_rows(self) -> List[Dict[str, Any]]:
"""``telegram_dm_topic_bindings`` rows labelled ``default`` whose ``session_key`` names a named
profile: written by a multiplexer that had not yet learned to stamp the routed profile
(#76423). Stores whose topic tables predate ``profile_name`` have nothing to relabel."""
def _read(conn):
existing = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name = 'telegram_dm_topic_bindings'")}
if not existing or "profile_name" not in _table_columns(conn, "telegram_dm_topic_bindings"):
return []
return [dict(row) for row in conn.execute(
"SELECT chat_id, thread_id, session_key FROM telegram_dm_topic_bindings "
"WHERE profile_name = 'default' ORDER BY chat_id, thread_id")]
out = []
for row in self._read_retrying_ioerr(_read):
key_profile = session_key_profile(row["session_key"])
if key_profile not in (None, "default"):
out.append({**row, "key_profile": key_profile})
return out
def count_messages_all(self, session_id: str) -> int:
row = self._read_one("SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,))
return int(row[0]) if row else 0
# ── in-store fixers ───────────────────────────────────────────────────────
def relabel_sessions_to_key_profile(self, ids: Iterable[str]) -> int:
"""Set ``profile_name`` to the profile in each row's own key. Re-derives the target inside the
transaction (never trusts a stale report)."""
wanted = list(ids)
if not wanted:
return 0
def _do(conn) -> int:
changed = 0
for chunk in _id_chunks(wanted):
rows = conn.execute(
f"SELECT id, session_key FROM sessions WHERE id IN ({_session_ids_placeholders(chunk)})",
chunk).fetchall()
for session_id, session_key in rows:
target = session_key_profile(session_key)
if target is None:
continue
changed += conn.execute(
"UPDATE sessions SET profile_name = ? WHERE id = ? AND profile_name IS NOT ?",
(target, session_id, target)).rowcount
return changed
return self._execute_write(_do)
def sever_crossed_parents(self, ids: Iterable[str]) -> int:
"""Detach children from a parent keyed under another profile. Only ``parent_session_id`` is
cleared — ``profile_name`` stays the row's own (relabelled separately when it is wrong) — and
only while the crossing still holds."""
wanted = list(ids)
if not wanted:
return 0
def _do(conn) -> int:
severed = 0
for chunk in _id_chunks(wanted):
rows = conn.execute(
"SELECT s.id, s.session_key, p.session_key FROM sessions s "
"JOIN sessions p ON p.id = s.parent_session_id "
f"WHERE s.id IN ({_session_ids_placeholders(chunk)})", chunk).fetchall()
for session_id, key, parent_key in rows:
mine, theirs = session_key_profile(key), session_key_profile(parent_key)
if mine is not None and theirs is not None and mine != theirs:
severed += conn.execute(
"UPDATE sessions SET parent_session_id = NULL WHERE id = ?", (session_id,)).rowcount
return severed
return self._execute_write(_do)
# ── cross-store move ──────────────────────────────────────────────────────
def export_session_for_move(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Everything the target store needs to hold *session_id* as its own: the row (every column),
the resolved system prompt, EVERY message row (inactive and compacted generations included —
a move is not an export) and its usage rows."""
def _read(conn):
session = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if session is None:
return None
def _stored(prompt_hash):
row = conn.execute("SELECT prompt FROM system_prompts WHERE hash = ?", (prompt_hash,)).fetchone()
return row[0] if row else None
prompt = _stored(session["system_prompt_hash"]) if session["system_prompt_hash"] else None
# The tools[] pin is content-addressed the same way; a legacy inline list resolves to None.
tool_pin = _stored(session["tool_names"]) if session["tool_names"] else None
messages = [dict(r) for r in conn.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,))]
usage = [dict(r) for r in conn.execute(
"SELECT * FROM session_model_usage WHERE session_id = ?", (session_id,))]
return {"session": dict(session), "system_prompt": prompt, "tool_pin": tool_pin, "messages": messages,
"usage": usage}
return self._read_retrying_ioerr(_read)
def import_moved_session(self, payload: Dict[str, Any], *, profile_name: str) -> str:
"""Insert a moved session into THIS store as *profile_name*'s. ``present`` when the id already
exists (an earlier run copied but did not delete), else ``imported``. The parent link survives
only when the parent is already here — a moved row must never point across stores or at a
row of another profile. Columns the target schema lacks are dropped, never invented. Titles
are unique per store only, so a title an unrelated row here already holds gets the moved
row's id tail (the :meth:`import_foreign_history` convention); the resident row keeps its
name, since it is the one this profile's clients resolve by title."""
session = dict(payload["session"])
session_id = session["id"]
def _do(conn) -> str:
if conn.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,)).fetchone():
return "present"
session["profile_name"] = profile_name
parent_id = session.get("parent_session_id")
if parent_id and conn.execute("SELECT 1 FROM sessions WHERE id = ?", (parent_id,)).fetchone() is None:
session["parent_session_id"] = None
title = session.get("title")
if title is not None and conn.execute("SELECT 1 FROM sessions WHERE title = ?", (title,)).fetchone():
suffix = f" ({session_id[-12:]})"
session["title"] = title[:self.MAX_TITLE_LENGTH - len(suffix)] + suffix
session["system_prompt_hash"] = self._store_system_prompt(conn, payload.get("system_prompt"))
if payload.get("tool_pin") is not None or len(session.get("tool_names") or "") == 64:
# A pin hash means nothing in this store: re-store the pin, or drop an unresolvable ref.
session["tool_names"] = self._store_system_prompt(conn, payload.get("tool_pin"))
self._insert_row(conn, "sessions", session, skip=frozenset())
for message in payload.get("messages") or []:
self._insert_row(conn, "messages", {**message, "session_id": session_id}, skip=_MESSAGE_MOVE_SKIP)
for usage in payload.get("usage") or []:
self._insert_row(conn, "session_model_usage", {**usage, "session_id": session_id}, skip=frozenset())
return "imported"
return self._execute_write(_do)
@staticmethod
def _insert_row(conn, table: str, values: Dict[str, Any], *, skip: frozenset) -> None:
columns = [c for c in _table_columns(conn, table) if c in values and c not in skip]
conn.execute(
f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({', '.join('?' for _ in columns)})",
[values[c] for c in columns])
def delete_moved_session(self, session_id: str) -> bool:
"""Remove a session this store no longer owns after the target confirmed it. Children left
behind are detached (same FK rule as :meth:`delete_session`); topic bindings on the row
cascade with it."""
def _do(conn) -> bool:
if conn.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,)).fetchone() is None:
return False
conn.execute("UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?", (session_id,))
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM session_model_usage WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
self._delete_unreferenced_system_prompts(conn)
return True
return bool(self._execute_write(_do))
# ── routing index ─────────────────────────────────────────────────────────
def rekey_legacy_main_sessions(self, ids: Iterable[str], profile: str) -> int:
"""``agent:main:…`` → ``agent:<profile>:…`` (+ ``profile_name``) for rows a standalone
gateway wrote before this store's profile was multiplexed (#113884). Only rows still under
the legacy namespace change; a ``main``-named profile is written in its marked form."""
from gateway.session import _session_key_namespace
wanted = list(ids)
if not wanted:
return 0
new_ns = _session_key_namespace(profile) + ":"
def _do(conn) -> int:
changed = 0
for chunk in _id_chunks(wanted):
changed += conn.execute(
"UPDATE sessions SET session_key = ? || substr(session_key, 12), profile_name = ? "
f"WHERE id IN ({_session_ids_placeholders(chunk)}) AND substr(session_key, 1, 11) = 'agent:main:'",
(new_ns, profile, *chunk)).rowcount
return changed
return self._execute_write(_do)
def delete_gateway_routing_rows(self, rows: Iterable[Tuple[str, str]]) -> int:
wanted = list(rows)
if not wanted:
return 0
def _do(conn) -> int:
return sum(conn.execute(
"DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?", (scope, key)).rowcount
for scope, key in wanted)
return self._execute_write(_do)
def insert_gateway_routing_rows_if_absent(self, rows: Iterable[Tuple[str, str, str, float]]) -> int:
"""Adopt ``(scope, session_key, entry_json, updated_at)`` rows another store held for this
one's routing index. An existing key wins — the row the gateway actually loads stays."""
wanted = list(rows)
if not wanted:
return 0
def _do(conn) -> int:
return sum(conn.execute(
"INSERT OR IGNORE INTO gateway_routing (scope, session_key, entry_json, updated_at) "
"VALUES (?, ?, ?, ?)", row).rowcount for row in wanted)
return self._execute_write(_do)
# ── telegram topic tables ─────────────────────────────────────────────────
def relabel_telegram_topic_rows(self, rows: Iterable[Dict[str, Any]]) -> Dict[str, int]:
"""Stamp the key's profile onto ``default``-labelled bindings (and the chat's mode row when
only the default one exists). A binding that would collide with one the named profile already
wrote is the stale duplicate and is removed."""
wanted = list(rows)
counts = {"bindings_relabelled": 0, "bindings_duplicates_removed": 0, "mode_rows_relabelled": 0}
if not wanted:
return counts
def _do(conn) -> Dict[str, int]:
mode_has_profile = "profile_name" in _table_columns(conn, "telegram_dm_topic_mode")
for row in wanted:
target = session_key_profile(row["session_key"])
if target in (None, "default"):
continue
chat_id, thread_id = row["chat_id"], row["thread_id"]
collides = conn.execute(
"SELECT 1 FROM telegram_dm_topic_bindings WHERE profile_name = ? AND chat_id = ? "
"AND thread_id = ?", (target, chat_id, thread_id)).fetchone()
if collides:
counts["bindings_duplicates_removed"] += conn.execute(
"DELETE FROM telegram_dm_topic_bindings WHERE profile_name = 'default' "
"AND chat_id = ? AND thread_id = ?", (chat_id, thread_id)).rowcount
else:
counts["bindings_relabelled"] += conn.execute(
"UPDATE telegram_dm_topic_bindings SET profile_name = ? WHERE profile_name = 'default' "
"AND chat_id = ? AND thread_id = ?", (target, chat_id, thread_id)).rowcount
if mode_has_profile and conn.execute(
"SELECT 1 FROM telegram_dm_topic_mode WHERE profile_name = ? AND chat_id = ?",
(target, chat_id)).fetchone() is None:
counts["mode_rows_relabelled"] += conn.execute(
"UPDATE telegram_dm_topic_mode SET profile_name = ? WHERE profile_name = 'default' "
"AND chat_id = ?", (target, chat_id)).rowcount
return counts
return self._execute_write(_do)
# ── evidence for JSON-file repairs ────────────────────────────────────────
def key_profiles_for_chat(self, platform: str, chat_id: str) -> Set[str]:
"""Profiles whose keyed sessions hold *(platform, chat_id)* in this store — the evidence for
who owns a profile-less ``gateway_voice_mode.json`` entry."""
rows = self._read_all(
"SELECT DISTINCT session_key FROM sessions WHERE source = ? AND chat_id = ? "
"AND session_key LIKE 'agent:%'", (platform, str(chat_id)))
return {p for p in (session_key_profile(r["session_key"]) for r in rows) if p is not None}