Files
hermes-agent/hermes_state_messages.py
T
Teknium d15c61b5dc refactor(state): split SessionDB into domain mixins and free-function modules; unify SQL boilerplate
hermes_state.py 17,220 -> 6,442 LOC. Behavior-neutral: every moved body is
AST-identical to the original, verified per extraction.

SessionDB core
- _write_sql / _write_rowcount / _read_one / _read_all replace ~120 copies of
  the `def _do(conn): conn.execute(...)` + `_execute_write(_do)` and
  `with self._read_ctx() as conn: row = conn.execute(...).fetchone()` shapes.
- _set_lineage_column replaces four copies of the recursive compression-lineage
  UPDATE (archived / pinned / hidden / last_read_at).
- _read_session_number unifies the three compression counter readers.
- Dead (zero refs repo-wide): restore_rewound, delete_gateway_routing_entries,
  _is_duplicate_replayed_user_message, SessionPortabilityMixin.get_first_assistant_text.

New mixins bound onto SessionDB via the MRO (logger name stays "hermes_state"):
  hermes_state_messages    SessionMessagesMixin       48 methods
  hermes_state_compression SessionCompressionMixin    30
  hermes_state_gateway     SessionGatewayMixin        26
  hermes_state_maintenance SessionMaintenanceMixin    13
  hermes_state_usage       SessionUsageMixin          12
  hermes_state_titles      SessionTitlesMixin         13
  hermes_state_telegram    SessionTelegramTopicsMixin 11
Origin-internal symbols resolve through a lazy `from hermes_state import ...`
inside the few methods that need them (no import cycle).

New free-function modules, every name re-imported into hermes_state so
`hermes_state.<name>` (and test monkeypatches on it) keep working; intra-module
calls to patched helpers go through the lazy origin import:
  hermes_state_repair   repair/backup/preflight (43 defs)
  hermes_state_wal      journal-mode / PRAGMA policy (33 defs)
  hermes_state_dbfile   header probes, zeroed-db quarantine, stats, holders (21 defs)

Existing mixins: search — shared FTS MATCH/LIKE builders, unified rebuild
status/step/finish engines, state_meta helpers; schema — one legacy/v23 FTS init
branch, shared _live_pk_columns, Row/tuple dual access dropped; portability —
shared _PREVIEW_RAW_SUBQUERY_SQL and _rich_row; common — single
stat_db_file_identity (was 3 copies), AUTO_VACUUM_MIN_FREELIST_RATIO.

Docstrings/comments hand-compacted (AST-identical) keeping every invariant,
ordering rule, failure mode and WHY. Schema SQL, migration order and PRAGMAs
untouched. test_repair_path_has_no_bare_connects repointed to hermes_state_repair.
2026-09-02 13:32:13 -07:00

2643 lines
123 KiB
Python

"""Transcript persistence for SessionDB.
Mixin split out of ``hermes_state.py``; bound onto ``SessionDB`` via the MRO
and built on its ``_read_ctx`` / ``_execute_write`` / ``_write_sql`` /
``_read_one`` / ``_read_all`` primitives. Covers message append / replace /
rewind, reactions, resume-conversation assembly and replayed-user-message
duplicate detection.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
from agent.context_compressor import _DB_PERSISTED_MARKER as _DB_PERSISTED_MARKER_KEY
from agent.memory_manager import sanitize_context
from agent.message_sanitization import _sanitize_surrogates
from hermes_state_common import (
_RESET_END_REASONS,
_RESET_END_REASONS_SQL,
_legacy_reset_child_sql,
)
# Log-record parity with the origin module (caplog tests pin "hermes_state").
logger = logging.getLogger("hermes_state")
class SessionMessagesMixin:
"""Message append/replace/rewind, reactions, resume conversations, replay dedupe."""
def _bump_conversation_generation(self, conn, session_id: str, end_reason: str) -> None:
"""Advance this peer's conversation generation past a boundary.
Called inside the transaction that writes the boundary, so the
generation and the ``end_reason`` that caused it commit together.
Only ``_RESET_END_REASONS`` count: ``compression`` continues one
conversation, and an accidental close is not a replacement. Rows with
no ``session_key`` have no routing peer to advance.
The counter deliberately does NOT read the session rows. An aggregate
over them (COUNT/MAX of boundaries) can return a pair it already
emitted once ``delete_session()`` or bulk pruning removes an ended row,
which would hand a new conversation a retired affinity identity. This
value only ever increments, so a generation is never reused for a peer
even if every row behind it is gone.
"""
if end_reason not in _RESET_END_REASONS:
return
row = conn.execute(
"SELECT source, session_key FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
return
source = str(row["source"] or "").strip()
session_key = str(row["session_key"] or "").strip()
if not source or not session_key:
return
conn.execute(
"""
INSERT INTO conversation_generations (source, session_key, generation)
VALUES (?, ?, 1)
ON CONFLICT(source, session_key) DO UPDATE
SET generation = conversation_generations.generation + 1
""",
(source, session_key),
)
@classmethod
def _encode_content(cls, content: Any) -> Any:
"""Serialize structured (list/dict) message content for sqlite.
sqlite3 can only bind ``str``, ``bytes``, ``int``, ``float``, and ``None``
to query parameters. Multimodal messages have ``content`` as a list of
parts (``[{"type": "text", ...}, {"type": "image_url", ...}]``), which
raises ``ProgrammingError: Error binding parameter N: type 'list' is
not supported`` when bound directly.
Returns the value unchanged when it's already a safe scalar, or a
sentinel-prefixed JSON string for lists/dicts. Paired with
:meth:`_decode_content` on read.
"""
if isinstance(content, str):
# Lone UTF-16 surrogates reach here inside tool results scraped
# from the web/social platforms (the same input that crashed the
# guardrail hasher). The proactive sanitizer upstream only cleans
# the *api_messages* copy, and the recovery sanitizer only runs
# after the API call itself raises — which it no longer does — so
# the canonical history keeps them and this write is where they
# land. Left raw, sqlite3 raises UnicodeEncodeError, the flush is
# abandoned, and the session silently stops persisting for the
# rest of its life. Scrub so persistence never fails.
return _sanitize_surrogates(content)
if content is None or isinstance(content, (bytes, int, float)):
return content
try:
# json.dumps defaults to ensure_ascii=True, which escapes any
# surrogate as \udXXX — already safe to bind.
return cls._CONTENT_JSON_PREFIX + json.dumps(content)
except (TypeError, ValueError):
# Last-resort fallback: stringify so persistence never fails.
return _sanitize_surrogates(str(content))
@classmethod
def _decode_content(cls, content: Any) -> Any:
"""Reverse :meth:`_encode_content`; returns scalars unchanged."""
if isinstance(content, str) and content.startswith(cls._CONTENT_JSON_PREFIX):
try:
return json.loads(content[len(cls._CONTENT_JSON_PREFIX):])
except (json.JSONDecodeError, TypeError):
logger.warning(
"Failed to decode JSON-encoded message content; "
"returning raw string"
)
return content
return content
@staticmethod
def _encode_display_metadata(display_metadata: Any) -> Optional[str]:
"""Serialize ``display_metadata`` for its TEXT column without double-encoding.
Import/replace paths can hand us an already-serialized JSON string (the
same hazard ``tool_calls`` guards against above). ``json.dumps`` on that
string would store a quoted JSON string, and the single ``json.loads``
on read then yields a ``str`` instead of a dict.
"""
if not display_metadata:
return None
if isinstance(display_metadata, str):
try:
parsed = json.loads(display_metadata)
except (json.JSONDecodeError, TypeError):
logger.warning("Ignoring non-JSON display metadata on write")
return None
if not isinstance(parsed, dict):
logger.warning("Ignoring non-object display metadata on write")
return None
return json.dumps(parsed)
if isinstance(display_metadata, dict):
return json.dumps(display_metadata)
logger.warning(
"Ignoring unexpected display metadata type on write: %s",
type(display_metadata).__name__,
)
return None
def _check_transcript_write_guards(
self,
conn,
session_id: str,
compression_lock_holder: Optional[str],
turn_lease_holder: Optional[str] = None,
turn_lease_ttl_seconds: float = 300.0,
reject_active_turn_lease: bool = False,
reject_active_compression_lock: bool = False,
allow_closed_compression_parent: bool = False,
) -> None:
"""Transcript-write admission checks, run INSIDE the write txn.
Shared by :meth:`append_message` and :meth:`append_messages_batch` so
the two writers can never diverge on these correctness invariants
(this guard has already needed targeted fixes — see the #74478 patience
note below). User-initiated transcript mutations may opt in to rejecting
an active unowned turn lease in that same transaction.
"""
from hermes_state import CompressionSessionClosedError, SessionCompressionInProgressError, SessionTurnLeaseLostError, _compression_lock_holder_process_is_dead
# NOTE (#75316 redesign): appends do NOT check compression_locks.
# The lock's job is to stop two COMPRESSIONS colliding, not to fence
# ordinary transcript writes. Concurrent appends during a compression
# are safe by construction: archive_and_compact() commits against a
# watermark captured at compression start and clones every row that
# arrived after it back into the live transcript, in the same write
# transaction. Blocking appends here was the root cause of a whole
# symptom family — turns dying as session_persistence_failed while a
# slow provider summary held the lease (#74568, #77386), including
# stale locks from dead PIDs blocking writes for the full TTL.
# Destructive user mutations are different: a compressor that already
# captured its watermark can otherwise publish the pre-rewind snapshot
# after the mutation and resurrect the removed turn. Keep that narrow
# fence opt-in so ordinary appends retain the watermark behavior.
if reject_active_compression_lock:
active_lock = conn.execute(
"SELECT holder, expires_at FROM compression_locks "
"WHERE session_id = ?",
(session_id,),
).fetchone()
if active_lock is not None:
current_holder = active_lock["holder"]
if (
float(active_lock["expires_at"]) <= time.time()
or _compression_lock_holder_process_is_dead(current_holder)
):
conn.execute(
"DELETE FROM compression_locks "
"WHERE session_id = ? AND holder = ?",
(session_id, current_holder),
)
elif current_holder != compression_lock_holder:
raise SessionCompressionInProgressError(
f"Session {session_id!r} is being compressed by another writer"
)
if turn_lease_holder or reject_active_turn_lease:
conversation_id = self._session_turn_lease_key_on_conn(conn, session_id)
lease = conn.execute(
"SELECT holder, expires_at FROM session_turn_leases "
"WHERE conversation_id = ?",
(conversation_id,),
).fetchone()
now = time.time()
if turn_lease_holder:
if lease is None or lease["holder"] != turn_lease_holder:
raise SessionTurnLeaseLostError(
f"Session turn lease lost; refusing transcript write "
f"for {session_id!r}"
)
if float(lease["expires_at"]) <= now:
# Expiry makes the row reclaimable; it does not prove that a
# takeover occurred. BEGIN IMMEDIATE serializes this renewal
# with acquisition, so a still-matching owner can recover from
# a starved refresher without weakening the foreign-holder fence.
conn.execute(
"UPDATE session_turn_leases SET expires_at = ? "
"WHERE conversation_id = ? AND holder = ?",
(
now + max(0.1, float(turn_lease_ttl_seconds)),
conversation_id,
turn_lease_holder,
),
)
elif lease is not None:
current_holder = lease["holder"]
if (
float(lease["expires_at"]) <= now
or _compression_lock_holder_process_is_dead(current_holder)
):
# Match acquisition semantics: an expired or provably dead
# owner is reclaimable. Deleting it inside this BEGIN IMMEDIATE
# transaction also fences a stale late flush after the mutation.
conn.execute(
"DELETE FROM session_turn_leases "
"WHERE conversation_id = ? AND holder = ?",
(conversation_id, current_holder),
)
else:
raise SessionTurnLeaseLostError(
f"Session has an active turn lease; refusing transcript "
f"mutation for {session_id!r}"
)
session = conn.execute(
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if (
session is not None
and session["ended_at"] is not None
and session["end_reason"] == "compression"
and not allow_closed_compression_parent
):
raise CompressionSessionClosedError(session_id)
@staticmethod
def _decode_display_metadata(raw: Any) -> Optional[Dict[str, Any]]:
"""Decode a ``display_metadata`` column into the dict every reader expects.
Every message read path must go through this. Returning the raw TEXT
instead reaches the desktop as a string, where ``'task_count' in meta``
throws and fails the whole resume. Rows written before the encode guard
landed are double-encoded, so unwrap a second layer when we find one.
"""
if raw is None:
return None
try:
meta = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(meta, str):
meta = json.loads(meta)
except (json.JSONDecodeError, TypeError):
logger.warning("Ignoring invalid display metadata on message row")
return None
if not isinstance(meta, dict):
logger.warning("Ignoring non-object display metadata on message row")
return None
return meta
@staticmethod
def _reasoning_json_text(value: Any) -> Optional[str]:
"""Serialize a structured reasoning field for its TEXT column.
``reasoning_details`` / ``codex_reasoning_items`` / ``codex_message_items``
arrive as list/dict structures from the live runtime, but callers that
round-trip stored rows — ``get_messages`` straight into
``replace_messages``, e.g. the POST /api/sessions/{id}/fork handler —
hand back the raw TEXT these columns already hold, because
``get_messages`` only deserializes ``content`` and ``tool_calls``.
Re-dumping that TEXT double-encodes it, and the forked session's next
``get_messages_as_conversation`` json.loads then yields the inner
string instead of the original list, so every reasoning-replay consumer
(all of which check ``isinstance(..., list)``) silently drops it.
Strings are therefore stored as-is; structures are dumped.
"""
if not value:
return None
if isinstance(value, str):
return value
return json.dumps(value)
def append_message(
self,
session_id: str,
role: str,
content: str = None,
tool_name: str = None,
tool_calls: Any = None,
tool_call_id: str = None,
token_count: int = None,
finish_reason: str = None,
reasoning: str = None,
reasoning_content: str = None,
reasoning_details: Any = None,
codex_reasoning_items: Any = None,
codex_message_items: Any = None,
platform_message_id: str = None,
observed: bool = False,
effect_disposition: Optional[str] = None,
_compressed_summary: bool = False,
timestamp: Any = None,
api_content: Optional[str] = None,
display_kind: Optional[str] = None,
display_metadata: Optional[Dict[str, Any]] = None,
compression_lock_holder: Optional[str] = None,
turn_lease_holder: Optional[str] = None,
turn_lease_ttl_seconds: float = 300.0,
) -> int:
"""
Append a message to a session. Returns the message row ID.
Also increments the session's message_count (and tool_call_count
if role is 'tool' or tool_calls is present).
``platform_message_id`` is the external messaging platform's own
message ID (e.g. Telegram update_id, Yuanbao msg_id). It is
independent of the SQLite autoincrement primary key and is used by
platform-specific flows like yuanbao's recall guard to redact a
message by its platform-side identifier.
``api_content`` is the exact content string sent to the API for this
message when it differs from ``content`` (ephemeral memory/plugin
injections, persist overrides). It is a byte-fidelity sidecar for
prompt-cache-stable replay — stored as sent, except lone surrogates
(which sqlite3 cannot bind and which the conversation loop scrubs
from every outgoing payload anyway, so the scrubbed form IS the
wire bytes).
"""
from hermes_state import _scrub_surrogates
# Display metadata is presentation-only and never changes the model
# context role/content replayed to providers.
display_metadata_json = self._encode_display_metadata(display_metadata)
# Serialize structured fields to JSON before entering the write txn
reasoning_details_json = self._reasoning_json_text(reasoning_details)
codex_items_json = self._reasoning_json_text(codex_reasoning_items)
codex_message_items_json = self._reasoning_json_text(codex_message_items)
# tool_calls may arrive as a Python list (from the live agent) or
# as a JSON string (from import/export). Parse first to avoid
# double-encoding.
if isinstance(tool_calls, str):
try:
tool_calls = json.loads(tool_calls)
except (json.JSONDecodeError, TypeError):
tool_calls = []
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
# Multimodal content (list of parts) must be JSON-encoded: sqlite3
# cannot bind list/dict parameters directly.
stored_content = self._encode_content(content)
message_timestamp = time.time()
if timestamp is not None:
try:
if hasattr(timestamp, "timestamp"):
message_timestamp = float(timestamp.timestamp())
else:
message_timestamp = float(timestamp)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", timestamp)
# Pre-compute tool call count
num_tool_calls = 0
if tool_calls is not None:
num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1
def _do(conn):
self._check_transcript_write_guards(
conn,
session_id,
compression_lock_holder,
turn_lease_holder=turn_lease_holder,
turn_lease_ttl_seconds=turn_lease_ttl_seconds,
)
cursor = conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
codex_message_items, platform_message_id, observed, _compressed_summary, active, api_content, display_kind, display_metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
stored_content,
tool_call_id,
tool_calls_json,
_scrub_surrogates(tool_name),
effect_disposition,
message_timestamp,
token_count,
finish_reason,
_scrub_surrogates(reasoning),
_scrub_surrogates(reasoning_content),
reasoning_details_json,
codex_items_json,
codex_message_items_json,
platform_message_id,
1 if observed else 0,
1 if _compressed_summary else 0,
1,
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
_scrub_surrogates(display_kind) if isinstance(display_kind, str) else None,
display_metadata_json,
),
)
msg_id = cursor.lastrowid
# Update counters
if num_tool_calls > 0:
conn.execute(
"""UPDATE sessions SET message_count = message_count + 1,
tool_call_count = tool_call_count + ? WHERE id = ?""",
(num_tool_calls, session_id),
)
else:
conn.execute(
"UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
(session_id,),
)
return msg_id
# Transcript append is THE critical write: its failure aborts the
# user's turn (session_persistence_failed). Use the long patience so
# a sibling process legitimately holding the write lock for seconds
# (VACUUM, TRUNCATE checkpoint at close, an older pre-bounded-merge
# process's FTS optimize) can't destroy a healthy turn (#74478).
return self._execute_write(
_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S
)
def append_messages_batch(
self,
session_id: str,
messages: List[Dict[str, Any]],
compression_lock_holder: Optional[str] = None,
turn_lease_holder: Optional[str] = None,
chunk_rows: Optional[int] = None,
turn_lease_ttl_seconds: float = 300.0,
) -> int:
"""Append multiple messages atomically in ONE write transaction.
``messages`` is a list of dicts in the same shape
:meth:`_insert_message_rows` already consumes for replace/compact/
import (role, content, tool_name, tool_calls, tool_call_id,
finish_reason, reasoning*, codex_*, timestamp, api_content,
display_kind, display_metadata, ...). Reusing that helper keeps ONE
row-serialization path for every multi-row writer.
A turn-boundary flush writes the whole turn (user + assistant + tool
rows, typically 3-8 messages) as one BEGIN IMMEDIATE / commit pair
instead of one transaction (and, off WAL, one fsync) per row.
Atomicity contract: all rows land or none do (the caller re-flushes
unstamped messages on the next attempt). The same admission guards
as :meth:`append_message` run once for the batch — same session,
same instant.
``chunk_rows`` bounds the transaction size for LARGE copies (branch
seeds can be thousands of rows; measured: 10k rows ≈ 2.4s inside one
BEGIN IMMEDIATE because the FTS triggers run per row, which would
monopolize the write lock and starve concurrent writers). When set,
the batch commits in chunks of at most that many rows — same
recovery semantics as the old per-row loops (a mid-copy failure
leaves a partial seed), just with bounded lock holds. A turn flush
never needs it. Returns the inserted row count.
"""
if not messages:
return 0
if chunk_rows is not None and len(messages) > chunk_rows:
inserted_total = 0
for start in range(0, len(messages), chunk_rows):
inserted_total += self.append_messages_batch(
session_id,
messages[start:start + chunk_rows],
compression_lock_holder=compression_lock_holder,
turn_lease_holder=turn_lease_holder,
turn_lease_ttl_seconds=turn_lease_ttl_seconds,
)
return inserted_total
def _do(conn):
self._check_transcript_write_guards(
conn,
session_id,
compression_lock_holder,
turn_lease_holder=turn_lease_holder,
turn_lease_ttl_seconds=turn_lease_ttl_seconds,
)
from agent.transcript_repair import resolve_and_repair_transcript_batch
inserted_rows = resolve_and_repair_transcript_batch(
conn,
session_id,
messages,
encode_content_fn=self._encode_content,
decode_content_fn=self._decode_content,
)
inserted = 0
tool_calls_total = 0
if inserted_rows:
inserted, tool_calls_total = self._insert_message_rows(
conn, session_id, inserted_rows
)
# One aggregated counter update for the newly inserted rows.
if tool_calls_total > 0:
conn.execute(
"""UPDATE sessions SET message_count = message_count + ?,
tool_call_count = tool_call_count + ? WHERE id = ?""",
(inserted, tool_calls_total, session_id),
)
elif inserted > 0:
conn.execute(
"UPDATE sessions SET message_count = message_count + ? WHERE id = ?",
(inserted, session_id),
)
return inserted
# Same criticality as append_message: this IS the turn's transcript.
return self._execute_write(
_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S
)
def set_latest_matching_message_display_kind(
self, session_id: str, *, role: str, content: str, display_kind: str,
display_metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Stamp presentation metadata on this turn's freshly persisted row.
The model still receives ``role`` and ``content`` unchanged. Gateway and
CLI synthetic inputs call this immediately after their serial turn has
flushed, preserving producer provenance without classifying by content
during transcript rendering.
"""
from hermes_state import _scrub_surrogates
if not session_id or not content or not display_kind:
return False
def _do(conn):
row = conn.execute(
"SELECT id FROM messages WHERE session_id = ? AND role = ? "
"AND content = ? AND active = 1 ORDER BY id DESC LIMIT 1",
(session_id, role, self._encode_content(content)),
).fetchone()
if row is None:
return False
conn.execute(
"UPDATE messages SET display_kind = ?, display_metadata = ? WHERE id = ?",
(
_scrub_surrogates(display_kind),
self._encode_display_metadata(display_metadata),
row[0],
),
)
return True
return bool(self._execute_write(_do))
def set_message_reaction(
self,
session_id: str,
message_row_id: int,
emoji: Optional[str],
*,
author: str = "user",
) -> Optional[List[Dict[str, Any]]]:
"""Set (or with ``emoji=None`` clear) *author*'s reaction on one message.
iOS Tapback semantics: one reaction per author per message. Re-sending
the same emoji clears it, a different emoji replaces it. Returns the
message's full reaction list after the write, or ``None`` when the row
doesn't exist or isn't part of *session_id*.
"""
from hermes_state import _scrub_surrogates
if not session_id or message_row_id is None:
return None
def _do(conn):
row = conn.execute(
"SELECT display_metadata FROM messages WHERE id = ? AND session_id = ?",
(message_row_id, session_id),
).fetchone()
if row is None:
return None
meta = self._decode_display_metadata(row[0]) or {}
existing = meta.get(self.REACTIONS_METADATA_KEY)
reactions = [
r
for r in (existing if isinstance(existing, list) else [])
if isinstance(r, dict) and r.get("author") != author
]
previous = next(
(
r
for r in (existing if isinstance(existing, list) else [])
if isinstance(r, dict) and r.get("author") == author
),
None,
)
# Tapping the live reaction again retracts it.
toggling_off = (
emoji is not None and previous is not None and previous.get("emoji") == emoji
)
if emoji and not toggling_off:
reactions.append(
{"emoji": _scrub_surrogates(emoji), "author": author, "at": time.time()}
)
if reactions:
meta[self.REACTIONS_METADATA_KEY] = reactions
else:
meta.pop(self.REACTIONS_METADATA_KEY, None)
conn.execute(
"UPDATE messages SET display_metadata = ? WHERE id = ?",
(self._encode_display_metadata(meta) if meta else None, message_row_id),
)
return reactions
return self._execute_write(_do)
def get_message_reactions(
self, session_id: str, message_row_id: int
) -> List[Dict[str, Any]]:
"""Return the reaction list persisted on one message row (never ``None``)."""
if not session_id or message_row_id is None:
return []
row = self._read_one(
"SELECT display_metadata FROM messages WHERE id = ? AND session_id = ?",
(message_row_id, session_id),
)
if row is None:
return []
meta = self._decode_display_metadata(row[0]) or {}
reactions = meta.get(self.REACTIONS_METADATA_KEY)
return [r for r in reactions if isinstance(r, dict)] if isinstance(reactions, list) else []
def take_unseen_reactions(
self, session_id: str, *, author: str = "user"
) -> List[Dict[str, Any]]:
"""Return *author*'s not-yet-surfaced reactions and mark them seen.
Powers the cache-safe model-context path: reactions are announced on the
NEXT user turn (never by rewriting the message that was reacted to), and
the ``seen`` stamp guarantees each one is announced exactly once.
"""
if not session_id:
return []
def _do(conn):
rows = conn.execute(
"SELECT id, role, content, display_metadata FROM messages "
"WHERE session_id = ? AND active = 1 AND display_metadata IS NOT NULL "
"ORDER BY id",
(session_id,),
).fetchall()
pending = []
for row in rows:
meta = self._decode_display_metadata(row["display_metadata"])
if not meta:
continue
reactions = meta.get(self.REACTIONS_METADATA_KEY)
if not isinstance(reactions, list):
continue
changed = False
for reaction in reactions:
if (
not isinstance(reaction, dict)
or reaction.get("author") != author
or reaction.get("seen")
):
continue
reaction["seen"] = True
changed = True
content = self._decode_content(row["content"])
pending.append(
{
"row_id": row["id"],
"role": row["role"],
"emoji": reaction.get("emoji") or "",
"text": content if isinstance(content, str) else "",
}
)
if changed:
conn.execute(
"UPDATE messages SET display_metadata = ? WHERE id = ?",
(self._encode_display_metadata(meta), row["id"]),
)
return pending
return self._execute_write(_do) or []
def latest_message_row_id(
self, session_id: str, *, role: str = "user", offset: int = 0, require_text: bool = True
) -> Optional[int]:
"""Row id of the most recent active message with *role*, or ``None``.
Two callers, same need — "the message I mean, without an id": the agent
defaulting to the turn that triggered it, and the desktop reacting to a
live message that hasn't round-tripped through a resume yet.
``offset`` steps to earlier turns (1 = the one before the latest) so a
reaction can land retroactively — "two messages ago" is how the caller
thinks about it.
``require_text`` (default) skips rows with no plain-text content —
tool-call-only assistant turns and attachment stubs don't render as
bubbles, so "the latest message" as a HUMAN means it must never
resolve to one (a reaction landing on an invisible row looks dropped,
and its annotation quotes an empty string).
"""
if not session_id or role not in {"user", "assistant"} or offset < 0:
return None
text_filter = (
"AND content IS NOT NULL AND TRIM(content) != '' " if require_text else ""
)
row = self._read_one(
"SELECT id FROM messages WHERE session_id = ? AND role = ? "
f"AND active = 1 {text_filter}ORDER BY id DESC LIMIT 1 OFFSET ?",
(session_id, role, int(offset)),
)
return row[0] if row else None
def latest_user_message_row_id(self, session_id: str) -> Optional[int]:
"""Row id of the most recent active user message, or ``None``.
The agent's default reaction target: "the message that triggered me",
so the model never has to thread row ids through a tool call (mirrors
the photon adapter's ``_record_last_inbound``).
"""
return self.latest_message_row_id(session_id, role="user")
def get_message_role(self, session_id: str, row_id: int) -> Optional[str]:
"""Role of the active message at *row_id* in *session_id*, or ``None``.
Lets a reaction event carry the target's role so a renderer can match
a live message that doesn't know its durable row id yet.
"""
if not session_id:
return None
row = self._read_one(
"SELECT role FROM messages WHERE id = ? AND session_id = ? AND active = 1",
(int(row_id), session_id),
)
return row[0] if row else None
def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, Any]]) -> tuple[int, int]:
"""Insert *messages* as fresh active rows for *session_id*.
Shared by :meth:`replace_messages` (delete-then-insert) and
:meth:`archive_and_compact` (soft-archive-then-insert). Runs inside the
caller's write transaction (takes the live ``conn``). Returns
``(inserted_count, tool_call_count)``. Does NOT touch sessions.* counters
— the caller owns that, since the two flows reconcile counts differently.
"""
from hermes_state import _scrub_surrogates
now_ts = time.time()
inserted = 0
tool_calls_total = 0
for msg in messages:
role = msg.get("role", "unknown")
tool_calls = msg.get("tool_calls")
message_timestamp = now_ts
if msg.get("timestamp") is not None:
try:
ts_value = msg.get("timestamp")
if hasattr(ts_value, "timestamp"):
message_timestamp = float(ts_value.timestamp())
else:
message_timestamp = float(ts_value)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", msg.get("timestamp"))
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
codex_reasoning_items = (
msg.get("codex_reasoning_items") if role == "assistant" else None
)
codex_message_items = (
msg.get("codex_message_items") if role == "assistant" else None
)
reasoning_details_json = self._reasoning_json_text(reasoning_details)
codex_items_json = self._reasoning_json_text(codex_reasoning_items)
codex_message_items_json = self._reasoning_json_text(codex_message_items)
# tool_calls may arrive as a Python list (from the live agent)
# or as a JSON string (from import_sessions / export_session,
# which store it as TEXT). json.dumps on an already-serialized
# string double-encodes it, so parse first.
if isinstance(tool_calls, str):
try:
tool_calls = json.loads(tool_calls)
except (json.JSONDecodeError, TypeError):
tool_calls = []
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
# Accept either `platform_message_id` (new explicit name) or
# `message_id` (yuanbao's existing convention on message dicts).
platform_msg_id = (
msg.get("platform_message_id") or msg.get("message_id")
)
api_content = msg.get("api_content")
cur = conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
codex_message_items, platform_message_id, observed, _compressed_summary, active, api_content, display_kind, display_metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
self._encode_content(msg.get("content")),
msg.get("tool_call_id"),
tool_calls_json,
_scrub_surrogates(msg.get("tool_name")),
msg.get("effect_disposition"),
message_timestamp,
msg.get("token_count"),
msg.get("finish_reason"),
_scrub_surrogates(msg.get("reasoning")) if role == "assistant" else None,
_scrub_surrogates(msg.get("reasoning_content")) if role == "assistant" else None,
reasoning_details_json,
codex_items_json,
codex_message_items_json,
platform_msg_id,
1 if msg.get("observed") else 0,
1 if msg.get("_compressed_summary") else 0,
1,
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
_scrub_surrogates(msg.get("display_kind")) if isinstance(msg.get("display_kind"), str) else None,
self._encode_display_metadata(msg.get("display_metadata")),
),
)
if isinstance(msg, dict) and cur.lastrowid is not None:
msg["_row_id"] = cur.lastrowid
inserted += 1
if tool_calls is not None:
tool_calls_total += (
len(tool_calls) if isinstance(tool_calls, list) else 1
)
now_ts = max(now_ts + 1e-6, message_timestamp + 1e-6)
return inserted, tool_calls_total
def replace_messages(
self,
session_id: str,
messages: List[Dict[str, Any]],
active_only: bool = False,
archive_dropped: bool = False,
reject_active_turn_lease: bool = False,
) -> None:
"""Atomically replace the stored messages for a session.
Used by transcript-rewrite flows such as /retry, /undo, and /compress.
The delete + reinsert sequence must commit as one transaction so a
mid-rewrite failure does not leave SQLite with a partial transcript.
DESTRUCTIVE by default: every row for the session is DELETEd (and drops
out of the FTS index). For compaction that must preserve the
pre-compaction transcript under the same id, use
:meth:`archive_and_compact` instead.
Pass ``active_only=True`` to replace ONLY the live (``active = 1``) rows,
leaving soft-archived rows (``active = 0`` — e.g. the ``compacted = 1``
turns that :meth:`archive_and_compact` keeps on disk for #38763
durability, or rewind/undo rows) untouched. Callers that share a session
id with an agent already running in-place compaction must use this so a
full-history rewrite doesn't wipe the rows the agent deliberately
archived. ``message_count``/``tool_call_count`` then track the live set,
matching :meth:`archive_and_compact`.
Pass ``archive_dropped=True`` to SOFT-archive the live rows instead of
DELETEing them: the replaced turns stay on disk with ``active = 0``,
``compacted = 0`` — the same "the user took it back" marking
:meth:`rewind_to_message` applies — and stay readable via
:meth:`get_messages` with ``include_inactive=True``. This is the mode a
rewind/edit/regenerate must use: those flows overwrite a transcript the
user may not have meant to drop, and a plain DELETE also evicts the rows
from the FTS index, leaving nothing to recover from (#82756). It implies
active-only handling — already-archived rows are never touched — so
``active_only`` is redundant with it. The rewritten set is inserted as
fresh active rows exactly as in the destructive path, so the live view
is identical either way; only the durability of the dropped turns
differs.
Pass ``reject_active_turn_lease=True`` for user-initiated rewrites that
do not already own the cross-process turn lease. The lease check and
transcript mutation then share one write transaction, so a second
process cannot archive or replace a turn that is still being produced.
"""
from hermes_state import CompressionSessionClosedError
active_clause = " AND active = 1" if active_only else ""
def _do(conn):
if reject_active_turn_lease:
self._check_transcript_write_guards(
conn,
session_id,
None,
reject_active_turn_lease=True,
reject_active_compression_lock=True,
)
else:
session = conn.execute(
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if (
session is not None
and session["ended_at"] is not None
and session["end_reason"] == "compression"
):
raise CompressionSessionClosedError(session_id)
if archive_dropped:
# Content-preserving UPDATE: the rows keep their FTS entries
# (the messages_fts triggers fire on INSERT / DELETE / UPDATE
# of content columns, not on `active`), so the replaced turns
# stay readable via get_messages(include_inactive=True) and
# searchable with include_inactive=True after the rewrite.
conn.execute(
"UPDATE messages SET active = 0 "
"WHERE session_id = ? AND active = 1",
(session_id,),
)
else:
conn.execute(
f"DELETE FROM messages WHERE session_id = ?{active_clause}",
(session_id,),
)
conn.execute(
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
(session_id,),
)
total_messages, total_tool_calls = self._insert_message_rows(
conn, session_id, messages
)
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
(total_messages, total_tool_calls, session_id),
)
self._execute_write(_do)
def has_archived_messages(self, session_id: str) -> bool:
"""Return True if the session has any soft-archived (``active = 0``) rows.
Cheap existence probe — does not load rows. NOTE: production rewrite
paths no longer branch on this (they pass ``active_only=True``
unconditionally — a probe can fail open or race a concurrent
``archive_and_compact``, #80216); kept for tests and diagnostics.
"""
return self._read_one(
"SELECT 1 FROM messages WHERE session_id = ? AND active = 0 LIMIT 1",
(session_id,),
) is not None
def get_active_message_watermark(self, session_id: str) -> int:
"""MAX(id) of the session's active rows — the compression watermark.
Captured at compression START (before the slow provider summary call).
Every active row with ``id > watermark`` at commit time arrived
concurrently and must survive the compaction verbatim. Returns 0 for
an empty/unknown session.
"""
if not session_id:
return 0
row = self._read_one(
"SELECT COALESCE(MAX(id), 0) FROM messages "
"WHERE session_id = ? AND active = 1",
(session_id,),
)
return int(row[0]) if row else 0
def archive_and_compact(
self,
session_id: str,
compacted_messages: List[Dict[str, Any]],
model_config_patch: Optional[Dict[str, Any]] = None,
watermark: Optional[int] = None,
lock_holder: Optional[str] = None,
tail_count: int = 0,
) -> int:
"""Non-destructive in-place compaction for a single durable session id.
Soft-archives the active messages (``active = 0``) and inserts
*compacted_messages* as fresh active rows — atomically, in one write
transaction. The conversation keeps ONE session id for life (#38763)
WITHOUT destroying history:
- The live-context load (:meth:`get_messages_as_conversation`,
:meth:`get_messages`) filters ``active = 1`` by default, so the model
reloads ONLY the compacted set.
- The archived pre-compaction turns stay on disk (active=0) and stay
DISCOVERABLE: they are marked compacted=1, and search_messages()
includes compacted=1 rows by default — so session_search still finds
them, unlike rewind/undo rows (active=0, compacted=0) which stay
hidden. They remain in the FTS index (the messages_fts* triggers
index on INSERT / drop on DELETE and don't key on active/compacted;
flipping to active=0 is a content-preserving UPDATE) and are
recoverable via get_messages(..., include_inactive=True).
Concurrent-append safety (#75316): when *watermark* is provided (the
value of :meth:`get_active_message_watermark` captured at compression
START), rows that arrived during the slow provider summary call
(``id > watermark``) are NOT summarized away. They are re-sequenced
after the compacted set by a pure-SQL column clone (every column
except ``id`` — content, api_content, platform_message_id, token
counts, reasoning sidecars all survive byte-exact, and the FTS
triggers index the clones naturally), and the originals are archived.
NOTE: re-sequencing assigns the tail rows fresh ids; consumers that
reference durable row ids re-resolve by content (see 3e8ab0610).
``watermark=None`` preserves the historical archive-everything
behavior.
Commit-fence safety: when *lock_holder* is provided, the commit
verifies INSIDE the transaction that the compression lock is still
held by that holder and unexpired — a compression whose lease was
reclaimed (crash cleanup, TTL expiry, competing writer) fails the
commit instead of clobbering the winner's transcript.
*tail_count* (default 0) names how many of the LAST rows of
*compacted_messages* are the verbatim carried-forward tail the
compressor protected rather than summarized (#86366). Those rows'
ORIGINALS — which this call archives as a side effect of the blanket
soft-archive — are superseded byte-identical duplicates, not
"summarized away" content, so they are stamped rewind-style
(``active=0, compacted=0``, hidden from search_messages) instead of
``compacted=1``. Without this the tail originals satisfy the recall
filter alongside their live clones and session_search returns every
carried-forward message once per compaction. Callers that cannot know
their tail shape keep the historical archive-everything behavior.
``message_count`` is set to the ACTIVE count after commit, matching
what the live load returns. ``model_config_patch`` is merged into the
session's JSON config in the same transaction; a ``None`` value
removes that key. Returns the new active count.
"""
from hermes_state import SessionCompressionInProgressError
def _do(conn):
if lock_holder is not None:
lock_row = conn.execute(
"SELECT holder, expires_at FROM compression_locks "
"WHERE session_id = ?",
(session_id,),
).fetchone()
if (
lock_row is None
or lock_row["holder"] != lock_holder
or float(lock_row["expires_at"]) <= time.time()
):
raise SessionCompressionInProgressError(
f"Compression lease for {session_id!r} lost before "
"commit; refusing to publish a stale compaction"
)
patched_model_config = None
if model_config_patch is not None:
# on_missing="raise": a prune/compaction must not commit
# against a vanished session row (the compressor's caller
# converts the raised error into a safe keep-the-original
# no-op), unlike the flag setters which tolerate missing rows.
patched_model_config = self._merge_model_config_json(
conn, session_id, model_config_patch, on_missing="raise"
)
# Concurrent tail: active rows that arrived after the watermark.
# Snapshot their ids and tool_calls now — the clone below needs a
# stable id list, and the tool-call count keeps sessions.* honest.
tail_ids: list[int] = []
tail_tool_calls = 0
if watermark is not None:
for row in conn.execute(
"SELECT id, tool_calls FROM messages "
"WHERE session_id = ? AND active = 1 AND id > ? "
"ORDER BY id",
(session_id, int(watermark)),
).fetchall():
tail_ids.append(int(row["id"]))
raw = row["tool_calls"]
if raw:
try:
parsed = json.loads(raw) if isinstance(raw, str) else raw
tail_tool_calls += len(parsed) if isinstance(parsed, list) else 0
except (TypeError, ValueError):
pass
# Soft-archive the live turns: active=0 hides them from the live
# context load, compacted=1 marks them as "summarized away" (vs
# rewind/undo's active=0+compacted=0, which means "user took it
# back"). search_messages includes compacted=1 rows by default so
# the pre-compaction transcript stays discoverable; live-context
# loads (active=1 only) still exclude them. Tail originals whose
# verbatim clones ride inside *compacted_messages* (tail_count)
# are superseded duplicates instead (#86366): they get the
# rewind-style flags so they stop matching the recall filter.
# Rewind-target ids: the originals of the carried-forward tail
# rows (tail_count), captured BEFORE any flag flips. Named apart
# from the watermark `tail_ids` below on purpose — the two are
# different sets (#86366): rewind targets sit AT/BELOW the
# watermark (the compressor only saw rows up to it), while
# `tail_ids` are concurrent appends ABOVE it. Without the bound,
# a concurrent append would steal a LIMIT slot and leave a real
# carried-forward original stamped compacted=1.
rewind_tail_ids: Optional[list[int]] = None
if tail_count > 0:
if watermark is not None:
tail_rows = conn.execute(
"SELECT id FROM messages "
"WHERE session_id = ? AND active = 1 AND id <= ? "
"ORDER BY id DESC LIMIT ?",
(session_id, int(watermark), int(tail_count)),
).fetchall()
else:
tail_rows = conn.execute(
"SELECT id FROM messages "
"WHERE session_id = ? AND active = 1 ORDER BY id DESC LIMIT ?",
(session_id, int(tail_count)),
).fetchall()
rewind_tail_ids = [int(row["id"]) for row in tail_rows]
# The watermark clone below re-inserts `tail_ids` rows byte-exact
# as live rows — their originals are the SAME superseded-duplicate
# class as the carried-forward tail (#86366), so they take the
# rewind flags too instead of double-matching the recall filter.
rewind_ids = [*(rewind_tail_ids or []), *tail_ids]
if rewind_ids:
placeholders = ",".join("?" for _ in rewind_ids)
conn.execute(
"UPDATE messages SET active = 0, compacted = 0 "
f"WHERE session_id = ? AND id IN ({placeholders})",
[session_id, *rewind_ids],
)
conn.execute(
"UPDATE messages SET active = 0, compacted = 1 "
"WHERE session_id = ? AND active = 1 "
f"AND id NOT IN ({placeholders})",
[session_id, *rewind_ids],
)
else:
conn.execute(
"UPDATE messages SET active = 0, compacted = 1 "
"WHERE session_id = ? AND active = 1",
(session_id,),
)
inserted, tool_calls_total = self._insert_message_rows(
conn, session_id, compacted_messages
)
if tail_ids:
# Re-sequence the concurrent tail after the compacted set via
# a pure-SQL column clone: no decode/re-encode round trip, no
# field drift — new id, active=1, compacted=0, all else exact.
placeholders = ",".join("?" for _ in tail_ids)
clone_cols = [
c for c in self._message_column_names(conn)
if c not in ("id", "active", "compacted")
]
col_list = ", ".join(clone_cols)
conn.execute(
f"INSERT INTO messages ({col_list}, active, compacted) "
f"SELECT {col_list}, 1, 0 FROM messages "
f"WHERE id IN ({placeholders}) ORDER BY id",
tail_ids,
)
inserted += len(tail_ids)
tool_calls_total += tail_tool_calls
# message_count / tool_call_count reflect the LIVE (active) set —
# the archived rows are still on disk but not part of the live count.
if model_config_patch is None:
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
(inserted, tool_calls_total, session_id),
)
else:
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ?, "
"model_config = ? WHERE id = ?",
(inserted, tool_calls_total, patched_model_config, session_id),
)
return inserted
return self._execute_write(_do)
def _message_column_names(self, conn) -> List[str]:
"""Column names of the messages table, cached per-connection era."""
cached = getattr(self, "_message_columns_cache", None)
if cached:
return cached
cols = [r[1] for r in conn.execute("PRAGMA table_info(messages)").fetchall()]
self._message_columns_cache = cols
return cols
def set_latest_user_api_content(
self, session_id: str, content: Any, api_content: str
) -> int:
"""Backfill the ``api_content`` sidecar onto the newest ACTIVE user row.
In-place preflight compaction (:meth:`archive_and_compact`) inserts the
current turn's user row BEFORE the turn prologue composes the
prefetch/plugin sidecar, and the subsequent crash persist identity-skips
every compacted dict — without this backfill the stamped sidecar would
never land in the DB and any reload would replay clean content,
re-introducing the prompt-cache divergence the sidecar exists to close.
The ``content`` match is a defensive guard: if the newest active user
row is not the message the caller stamped (racing rewrite, unexpected
tail shape), nothing is written. Returns the number of rows updated
(0 or 1).
"""
from hermes_state import _scrub_surrogates
encoded = self._encode_content(content)
return self._write_rowcount(
"UPDATE messages SET api_content = ? WHERE id = ("
"SELECT id FROM messages "
"WHERE session_id = ? AND role = 'user' AND active = 1 "
"ORDER BY id DESC LIMIT 1"
") AND content IS ?",
(_scrub_surrogates(api_content), session_id, encoded),
)
def _dedupe_display_generations(self, rows):
"""Collapse compaction generations so each message appears once.
Compaction epochs copy the protected tail into each new generation, so
one logical message can exist as several rows (identical
role/content/timestamp) with different ``active`` flags and ids. A
display read must surface each exactly once: prefer the live row, then
the newest generation.
This is the ONE definition shared by every display projection —
:meth:`get_messages` (REST), :meth:`get_resume_conversations` and
:meth:`get_ancestor_display_prefix` (gateway resume), and
:meth:`get_messages_as_conversation` (warm-session payload) — so the
surfaces cannot disagree about the same transcript. *rows* must already
be ordered by ``id``; the returned list keeps that order.
"""
seen: Dict[Tuple[Any, ...], Any] = {}
for row in rows:
dedupe_content = row["content"]
if row["role"] == "user":
from agent.context_compressor import split_user_originated_turn
candidate = {
"role": "user",
"content": self._decode_content(row["content"]),
"display_kind": row["display_kind"],
"display_metadata": self._decode_display_metadata(
row["display_metadata"]
),
}
handoff, live_view = split_user_originated_turn(candidate)
if handoff is not None and live_view is not None:
dedupe_content = self._encode_content(live_view.get("content"))
# Tool fields participate in the dedupe key: compaction copies them
# verbatim, so identical tool messages across generations still
# collapse, while distinct tool calls that happen to share
# role/content/timestamp are never merged.
key = (
row["role"],
dedupe_content,
row["timestamp"],
row["tool_call_id"],
row["tool_calls"],
row["tool_name"],
)
cur = seen.get(key)
if cur is None or (row["active"], row["id"]) > (cur["active"], cur["id"]):
seen[key] = row
return sorted(seen.values(), key=lambda r: r["id"])
def get_messages(
self,
session_id: str,
include_inactive: bool = False,
include_compacted: bool = False,
limit: Optional[int] = None,
offset: int = 0,
latest: bool = False,
after_id: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Load messages for a session in insertion order.
By default only active messages are returned. Pass
``include_inactive=True`` to load soft-deleted rows (e.g. for
audit / debug views of rewound history). See
:meth:`rewind_to_message` for the soft-delete mechanic.
Pass ``include_compacted=True`` to additionally load rows preserved
by in-place context compaction (``active=0, compacted=1``). Those are
durable display history, not soft-deleted rows — a user-visible
transcript read must not drop them, or earlier turns silently become
unreachable once the UI exhausts its active-only window. Soft-deleted
Undo/Rewind rows (``active=0, compacted=0``) stay excluded; use
``include_inactive`` for those.
Ordered by AUTOINCREMENT id (true insertion order) rather than
timestamp — see c03acca50 for the WSL2 clock-regression rationale.
When ``limit`` is provided, returns at most ``limit`` messages
starting from ``offset`` (0-based, in insertion order). Enables
pagination for the API endpoint to avoid loading entire transcripts.
With ``latest=True``, the offset is measured back from the newest
message and the selected page is still returned in chronological
order. ``offset`` alone (without ``limit``) also pages — SQLite
requires a LIMIT clause for OFFSET, so it's emitted as ``LIMIT -1``
(unbounded).
``after_id`` enables keyset pagination (``id > after_id``): O(1)
page seeks on huge transcripts where OFFSET degrades to O(n) per
page. Ascending order only (incompatible with ``latest``/``offset``).
"""
if after_id is not None and (latest or offset):
raise ValueError("after_id is incompatible with latest/offset paging")
if after_id is not None and include_compacted:
raise ValueError("after_id is incompatible with include_compacted (deduped display reads use offset paging)")
if include_inactive:
# Audit / debug reads: every row, including soft-deleted.
active_clause = ""
elif include_compacted:
# Display history: active rows plus rows preserved by in-place
# compaction (active=0, compacted=1), but never soft-deleted
# Undo/Rewind rows (active=0, compacted=0).
active_clause = " AND (active = 1 OR compacted = 1)"
else:
active_clause = " AND active = 1"
keyset_clause = " AND id > ?" if after_id is not None else ""
sql = (
"SELECT * FROM messages WHERE session_id = ?"
f"{active_clause}{keyset_clause} ORDER BY id {'DESC' if latest else 'ASC'}"
)
params: list = [session_id]
if after_id is not None:
params.append(after_id)
if include_compacted:
# Read the full display set (a session's rows are bounded; the
# UI-level 500-row cap lives in the endpoint, not here), dedupe
# generations, then apply paging.
all_rows = self._read_all(
"SELECT * FROM messages WHERE session_id = ?" + active_clause
+ " ORDER BY id ASC",
[session_id],
)
rows = self._dedupe_display_generations(all_rows)
if latest:
rows = rows[::-1]
rows = rows[offset:]
if limit is not None:
rows = rows[:limit]
if latest:
rows = rows[::-1]
else:
if limit is not None or offset:
# SQLite's OFFSET requires LIMIT; -1 means "no limit".
sql += " LIMIT ? OFFSET ?"
params.extend([-1 if limit is None else limit, offset])
rows = self._read_all(sql, params)
if latest:
rows.reverse()
result = []
for row in rows:
msg = dict(row)
if msg.pop("_compressed_summary", 0):
msg["_compressed_summary"] = True
if "content" in msg:
msg["content"] = self._decode_content(msg["content"])
if msg.get("tool_calls"):
try:
msg["tool_calls"] = json.loads(msg["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
msg["tool_calls"] = []
if msg.get("display_metadata") is not None:
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
result.append(msg)
return result
def find_pr_url_messages(self, session_ids: List[str]) -> List[Dict[str, Any]]:
"""Tool results in these sessions that mention a GitHub PR url.
A candidate scan, deliberately loose: it hands back every tool result
containing ``/pull/`` and leaves the caller to decide which ones make a
claim (see the desktop's PR recovery, which only accepts an output that
is a bare PR url — the signature of ``gh pr create``). Ordered
oldest-first per session so the caller can take the last match.
"""
found: List[Dict[str, Any]] = []
ids = [s for s in session_ids if s]
for start in range(0, len(ids), 900): # SQLite's bound-variable ceiling.
chunk = ids[start : start + 900]
placeholders = ",".join("?" * len(chunk))
rows = self._read_all(
f"""SELECT session_id, content FROM messages
WHERE session_id IN ({placeholders})
AND role = 'tool' AND content LIKE '%/pull/%'
ORDER BY id ASC""",
chunk,
)
found.extend({"session_id": row[0], "content": row[1]} for row in rows)
return found
def get_messages_around(
self,
session_id: str,
around_message_id: int,
window: int = 5,
) -> Dict[str, Any]:
"""Load a window of messages anchored on a specific message id.
Returns a dict with:
- ``window``: up to ``window`` messages before the anchor, the anchor
itself, and up to ``window`` messages after, ordered by id ascending.
- ``messages_before``: count of messages strictly before the anchor
still in the session (== window unless we hit the start).
- ``messages_after``: count of messages strictly after the anchor
still in the session (== window unless we hit the end).
Used by ``session_search`` for both the discovery shape (anchored on the
FTS5 match) and the scroll shape (anchored on any message id). The
``messages_before`` / ``messages_after`` counts let the caller detect
session boundaries: when either is less than ``window``, the agent has
reached one end of the session.
Returns an empty window when ``around_message_id`` is not a real id in
``session_id`` — callers decide how to surface that.
"""
if window < 0:
window = 0
with self._read_ctx() as conn:
# Confirm the anchor exists in this session.
anchor_exists = conn.execute(
"SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1",
(around_message_id, session_id),
).fetchone()
if not anchor_exists:
return {"window": [], "messages_before": 0, "messages_after": 0}
# Two queries: anchor + before (DESC, take window+1), and after
# (ASC, take window). Final order is id ASC.
before_rows = conn.execute(
"SELECT * FROM messages "
"WHERE session_id = ? AND id <= ? "
"ORDER BY id DESC LIMIT ?",
(session_id, around_message_id, window + 1),
).fetchall()
after_rows = conn.execute(
"SELECT * FROM messages "
"WHERE session_id = ? AND id > ? "
"ORDER BY id ASC LIMIT ?",
(session_id, around_message_id, window),
).fetchall()
# before_rows is DESC; reverse so it's ASC, then concatenate after_rows.
rows = list(reversed(before_rows)) + list(after_rows)
result = []
for row in rows:
msg = dict(row)
if "content" in msg:
msg["content"] = self._decode_content(msg["content"])
if msg.get("tool_calls"):
try:
msg["tool_calls"] = json.loads(msg["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning(
"Failed to deserialize tool_calls in get_messages_around, falling back to []"
)
msg["tool_calls"] = []
if msg.get("display_metadata") is not None:
msg["display_metadata"] = self._decode_display_metadata(msg["display_metadata"])
result.append(msg)
# before_rows includes the anchor itself; subtract 1 for the count of
# messages strictly before the anchor in the returned slice.
messages_before = max(0, len(before_rows) - 1)
messages_after = len(after_rows)
return {
"window": result,
"messages_before": messages_before,
"messages_after": messages_after,
}
def resolve_resume_session_id(self, session_id: str) -> str:
"""Redirect a resume target to the descendant session that holds the messages.
Context compression ends the current session and forks a new child session
(linked via ``parent_session_id``). The flush cursor is reset, so the
child is where new messages actually land — the parent ends up with
``message_count = 0`` rows unless messages had already been flushed to
it before compression. See #15000.
This helper walks ``parent_session_id`` forward from ``session_id`` and
returns the descendant in the chain that has the **most recent** messages.
Unlike the original logic, it does NOT short-circuit when the starting
session already has messages — a descendant that was created by
compression may hold the continuation content and should be preferred
by the WebUI and gateway for ``--resume`` and session loading.
If no descendant (including the starting session) has any messages,
the original ``session_id`` is returned unchanged.
The chain is always walked via the child whose ``started_at`` is
latest; that matches the single-chain shape that compression creates.
A depth cap (32) guards against accidental loops in malformed data.
"""
if not session_id:
return session_id
# Follow the compression-continuation chain forward to the live tip
# FIRST. Auto-compression ends the current session and forks a
# continuation child, but a long-lived parent keeps its own flushed
# message rows — so the empty-head walk below never redirects it, and
# resuming the parent id reloads the pre-compression transcript while
# the turns generated *after* compression (and their responses) sit in
# the continuation. ``get_compression_tip`` is lineage-aware: it only
# follows children whose parent ended with ``end_reason='compression'``
# (created after the parent was ended), so delegation / branch children
# never hijack the resume. This is the fix for the desktop "I came back
# and the reply isn't there" report on large sessions.
try:
tip = self.get_compression_tip(session_id)
except Exception:
tip = session_id
if tip and tip != session_id:
session_id = tip
with self._read_ctx() as conn:
current = session_id
seen = {current}
best = None # tracks the last (deepest) node with messages
for _ in range(32):
# Check if the current node has messages.
try:
row = conn.execute(
"SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
(current,),
).fetchone()
except Exception:
return session_id
if row is not None:
best = current
# Walk to the most-recently-started child — but skip explicit
# branch (`_branched_from`), delegate/subagent (`_delegate_from`),
# reset-continuation (`_reset_from` or the legacy same-key
# heuristic — a post-reset conversation must never be reached
# by resuming the parent the user reset away), and tool
# children. They also carry a ``parent_session_id`` yet
# are NOT compression continuations; following them would hijack
# the resume target to an unrelated session (e.g. a subagent
# run). This mirrors the child-exclusion in ``get_compression_tip``.
try:
child_row = conn.execute(
"SELECT id FROM sessions AS child "
"WHERE child.parent_session_id = ? "
" AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL "
" AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL "
" AND json_extract(COALESCE(child.model_config, '{}'), '$._reset_from') IS NULL "
f" AND NOT {_legacy_reset_child_sql('child', _RESET_END_REASONS_SQL)} "
" AND COALESCE(child.source, '') != 'tool' "
"ORDER BY child.started_at DESC, child.id DESC LIMIT 1",
(current,),
).fetchone()
except Exception:
return session_id
if child_row is None:
break
child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0]
if not child_id or child_id in seen:
break
seen.add(child_id)
current = child_id
return best if best is not None else session_id
def get_messages_as_conversation(
self,
session_id: str,
include_ancestors: bool = False,
include_inactive: bool = False,
repair_alternation: bool = False,
include_row_ids: bool = False,
include_compacted: bool = False,
) -> List[Dict[str, Any]]:
"""
Load messages in the OpenAI conversation format (role + content dicts).
Used by the gateway to restore conversation history.
By default only active messages are returned. Pass
``include_inactive=True`` to load soft-deleted (rewound) rows
as well. See :meth:`rewind_to_message`.
``include_compacted=True`` additionally loads rows preserved by
in-place compaction (``active=0, compacted=1``), deduped by
:meth:`_dedupe_display_generations`. DISPLAY reads want this; the
model-fed restore must NOT pass it, or a resumed session regrows the
very history compaction just summarized away.
``repair_alternation=True`` runs ``repair_message_sequence`` over the
loaded list before returning it. Callers that restore a session for
LIVE REPLAY should pass it: a durable alternation violation (e.g. a
``user;user`` pair left by a turn that persisted no assistant row)
otherwise re-triggers the pre-request defensive repair on every
single request for the rest of the session's life — the repair
mutates only the per-request list, never the stored transcript.
Inspection/export consumers keep the default and see the transcript
verbatim.
"""
session_ids = [session_id]
if include_ancestors and not self._is_explicit_branch_session(session_id):
session_ids = self._session_lineage_root_to_tip(session_id)
if include_inactive:
active_clause = ""
elif include_compacted:
active_clause = " AND (active = 1 OR compacted = 1)"
else:
active_clause = " AND active = 1"
with self._read_ctx() as conn:
placeholders = ",".join("?" for _ in session_ids)
rows = conn.execute(
f"SELECT {self._CONVERSATION_ROW_COLUMNS} "
f"FROM messages WHERE session_id IN ({placeholders})"
# Order by AUTOINCREMENT id (true insertion order), NOT timestamp:
# append_message stamps rows with time.time(), which is not
# monotonic (WSL2, NTP steps, VM/laptop sleep resume). A later
# row can carry an earlier timestamp than its predecessor, and
# ORDER BY timestamp would then sort an assistant tool_calls row
# after its tool response, breaking tool-call/response adjacency
# and triggering an HTTP 400 on replay. This matches get_messages
# — see c03acca50 for the original fix.
f"{active_clause} ORDER BY id",
tuple(session_ids),
).fetchall()
if include_compacted:
rows = self._dedupe_display_generations(rows)
return self._rows_to_conversation(
rows,
session_id=session_id,
include_ancestors=include_ancestors,
repair_alternation=repair_alternation,
include_row_ids=include_row_ids,
)
def _rows_to_conversation(
self,
rows,
*,
session_id: str,
include_ancestors: bool,
repair_alternation: bool,
include_row_ids: bool = False,
include_summary_markers: bool = False,
) -> List[Dict[str, Any]]:
"""Decode fetched message rows into the OpenAI conversation format.
Extracted from get_messages_as_conversation so get_resume_conversations
can build the model-fed and display views from one SELECT. ``rows`` must
already be ordered by ``id`` (insertion order) and filtered to the
desired session set / active state by the caller.
"""
from hermes_state import _strip_background_review_harness, _strip_stale_tool_call_markers
messages = []
# Watermark rotation column-clones concurrent tail rows into the child
# after the new summary, so the copies need not be adjacent. Index the
# exact durable clone identity while decoding instead of rescanning the
# whole accumulated lineage for every user row.
exact_user_clones: Dict[Tuple[Any, str], Dict[str, Any]] = {}
for row in rows:
content = self._decode_content(row["content"])
if row["role"] in {"user", "assistant"} and isinstance(content, str):
content = sanitize_context(content).strip()
msg = {"role": row["role"], "content": content}
# Born durable (#92231): this dict is materialized FROM a durable
# row, so stamp the persistence marker at the source instead of
# relying on every restore caller to thread the loaded list back
# through a flush as ``conversation_history=`` — any
# identity-losing handoff (compression's durable-snapshot
# adoption, incremental persists with no history arg) would
# otherwise re-append the ENTIRE transcript on flush.
# Underscore-prefixed like ``_row_id``: every transport strips it
# before the wire, and compression's assembly copies deliberately
# strip it so rotated child handoffs still flush (see
# _fresh_compaction_message_copy).
msg[_DB_PERSISTED_MARKER_KEY] = True
# Durable per-message identity for surfaces that need to address a
# specific row later (desktop reactions). OPT-IN: only the gateway
# asks for it — every other consumer (ACP restore, export,
# inspection) gets the transcript in its historical shape.
# Underscore-prefixed so every transport's convert_messages()
# strips it before the wire.
if include_row_ids and row["id"] is not None:
msg["_row_id"] = row["id"]
# api_content is the byte-fidelity sidecar: the exact string sent
# to the API when it differed from the clean content. Returned
# VERBATIM — no sanitize_context, no strip — because the replay
# path substitutes it for content to keep the provider prompt
# cache prefix byte-stable across turns. Cleaning it here would
# re-introduce the divergence it exists to remove.
if row["api_content"]:
msg["api_content"] = row["api_content"]
if row["display_kind"]:
msg["display_kind"] = row["display_kind"]
if row["display_metadata"]:
decoded = self._decode_display_metadata(row["display_metadata"])
if decoded is not None:
msg["display_metadata"] = decoded
if include_summary_markers and row["_compressed_summary"]:
msg["_compressed_summary"] = True
if row["timestamp"]:
msg["timestamp"] = row["timestamp"]
if row["tool_call_id"]:
msg["tool_call_id"] = row["tool_call_id"]
if row["tool_name"]:
msg["tool_name"] = row["tool_name"]
if row["effect_disposition"]:
msg["effect_disposition"] = row["effect_disposition"]
if row["tool_calls"]:
try:
msg["tool_calls"] = json.loads(row["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
msg["tool_calls"] = []
# Surface the platform-side message id (e.g. yuanbao msg_id,
# telegram update_id) so platform-specific flows like recall
# can match by external identifier instead of having to fall
# back to content-match heuristics. Exposed as ``message_id``
# for backward compatibility with the JSONL transcript shape.
if row["platform_message_id"]:
msg["message_id"] = row["platform_message_id"]
if row["observed"]:
msg["observed"] = True
# Restore reasoning fields on assistant messages so providers
# that replay reasoning (OpenRouter, OpenAI, Nous) receive
# coherent multi-turn reasoning context.
if row["role"] == "assistant":
if row["finish_reason"]:
msg["finish_reason"] = row["finish_reason"]
if row["reasoning"]:
msg["reasoning"] = row["reasoning"]
if row["reasoning_content"] is not None:
msg["reasoning_content"] = row["reasoning_content"]
if row["reasoning_details"]:
try:
msg["reasoning_details"] = json.loads(row["reasoning_details"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize reasoning_details, falling back to None")
msg["reasoning_details"] = None
if row["codex_reasoning_items"]:
try:
msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
msg["codex_reasoning_items"] = None
if row["codex_message_items"]:
try:
msg["codex_message_items"] = json.loads(row["codex_message_items"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize codex_message_items, falling back to None")
msg["codex_message_items"] = None
if include_ancestors:
canonical_content, _is_composite = (
self._canonical_replayed_user_content(msg)
)
exact_clone_key = self._exact_replayed_user_clone_key(
msg.get("timestamp"), canonical_content
)
previous_exact = (
exact_user_clones.get(exact_clone_key)
if exact_clone_key is not None
else None
)
duplicate = None
if previous_exact is not None:
previous_index = next(
(
index
for index, candidate in enumerate(messages)
if candidate is previous_exact
),
None,
)
if previous_index is not None:
duplicate = (previous_index, True)
if duplicate is None:
duplicate = self._find_duplicate_replayed_user_message(
messages, msg
)
if duplicate is not None:
duplicate_index, prefer_current = duplicate
if prefer_current:
# A rotated compression child can carry the same live
# ask as the parent row plus the only surviving summary
# scaffold. Keep the child carrier (and its durable row
# id), not the simpler ancestor copy.
messages.pop(duplicate_index)
else:
continue
messages.append(msg)
if include_ancestors and exact_clone_key is not None:
exact_user_clones[exact_clone_key] = msg
# DEFENSE-IN-DEPTH against background-review session pollution: a forked
# skill/memory review that (in older builds, before the _persist_disabled
# fix) shared the parent's session_id wrote its harness turn into this
# real session. The harness is a user/system message instructing the
# agent to "Review the conversation above and update the skill library /
# save to memory" under a hard tool restriction; re-loading it as live
# history makes the agent adopt the curator role and refuse the user's
# actual task. Strip any such harness message AND the curator-mode
# assistant reply immediately following it, so a polluted session
# resumes clean even if stray rows exist.
messages = _strip_background_review_harness(messages)
# DEFENSE-IN-DEPTH against #78148: before that fix, a bare tool-call
# marker (e.g. "[memory]") could get cached as a fallback and
# persisted as if it were the model's real answer. Sessions written
# before the fix can still carry those rows — clear the stray
# content on load so replaying history doesn't re-teach the model
# to keep emitting the marker. No-op for unaffected sessions.
messages = _strip_stale_tool_call_markers(messages)
if repair_alternation and messages:
# Lazy import: hermes_state already depends on agent.* (see
# sanitize_context above), but keep this optional path from
# widening the import surface at module load.
from agent.agent_runtime_helpers import repair_message_sequence
repaired = repair_message_sequence(None, messages)
if repaired:
logger.info(
"Repaired %d message-alternation violation(s) while "
"restoring session %s — durable transcript kept them, "
"see repair_message_sequence",
repaired,
session_id,
)
return messages
def get_resume_conversations(
self, session_id: str
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Return ``(model_history, display_history)`` for a session resume in ONE SELECT.
``session.resume`` needs two projections of the same lineage:
- ``model_history`` — the tip session's active rows, alternation-repaired
(the live-replay working conversation). Equivalent to
``get_messages_as_conversation(session_id, repair_alternation=True)``.
- ``display_history`` — the full compression lineage (ancestors → tip),
verbatim, with replayed-user dedup. Explicit ``/branch`` sessions are
excluded from this lineage because their own rows already contain the
copied transcript; including the live parent's rows would let messages
written to the original after the fork leak into the branch.
The display projection also includes rows preserved by IN-PLACE
compaction (``active=0, compacted=1``), deduped by
:meth:`_dedupe_display_generations`. Without them a compacted
conversation resumes showing only its summary plus the carried-forward
tail — the user's own turns read as deleted even though every row is
still on disk, and the REST transcript read (which has always included
them) disagreed with this one about the same session (#92080).
The display fetch already reads a superset of the model fetch (the tip
rows are part of the lineage), so serving both from one lineage SELECT
halves the resume's DB work versus two separate calls, with byte-identical
output (see test_get_resume_conversations_matches_separate_reads).
"""
session_ids = self._resume_lineage_ids(session_id)
with self._read_ctx() as conn:
placeholders = ",".join("?" for _ in session_ids)
rows = conn.execute(
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
f"FROM messages WHERE session_id IN ({placeholders}) "
# Compaction-archived rows (active=0, compacted=1) are display
# history; Undo/Rewind rows (active=0, compacted=0) are not.
"AND (active = 1 OR compacted = 1) "
# ORDER BY id (insertion order) — see get_messages_as_conversation
# for why timestamp ordering is unsafe.
"ORDER BY id",
tuple(session_ids),
).fetchall()
# Tip rows are exactly the model-fed set (get_messages_as_conversation
# with session_ids=[session_id]); filtering the lineage fetch preserves
# their relative id order. The model projection stays active-only — it
# is the compressed working context and must not regrow the history
# compaction just summarized away.
tip_rows = [r for r in rows if r["session_id"] == session_id and r["active"]]
model_history = self._rows_to_conversation(
tip_rows,
session_id=session_id,
include_ancestors=False,
repair_alternation=True,
include_row_ids=True,
# Pre-compress checkpointing: the resumed model history must keep
# the summary marker so checkpoint providers can exclude derivative
# summaries after a process restart (marker survives restart).
include_summary_markers=True,
)
display_history = self._rows_to_conversation(
self._dedupe_display_generations(rows),
session_id=session_id,
include_ancestors=True,
repair_alternation=False,
include_row_ids=True,
)
return model_history, display_history
def _resume_lineage_ids(self, session_id: str) -> List[str]:
"""Session ids a full (display) resume materializes for *session_id*.
Compression continuations need their ended ancestors' rows for the
display transcript; an explicit ``/branch`` copy already owns its
transcript, so its lineage is itself alone. This is the ONE definition
shared by the resume readers (``get_resume_conversations``,
``get_ancestor_display_prefix``) and the resume guard
(``assert_resume_safe`` / ``get_resume_message_count``) — the guard must
count exactly the rows a resume would load, never a superset.
"""
if self._is_explicit_branch_session(session_id):
return [session_id]
return self._session_lineage_root_to_tip(session_id)
def get_resume_message_count(
self, session_id: str, *, tip_only: bool = False
) -> int:
"""Count the rows that a resume would materialize.
``tip_only=True`` counts the tip segment's ACTIVE rows — the set a
model-history restore loads (``get_messages_as_conversation`` without
ancestors, or the deferred Desktop resume that pages the display
transcript over REST and never materializes the ancestor prefix in
memory).
Otherwise this counts the full-lineage DISPLAY set — active rows plus
the compaction-archived rows ``get_resume_conversations`` now loads
for the transcript. Counting only active rows here would let a
heavily-compacted conversation pass a limit sized for a handful of
live rows and then materialize tens of thousands.
"""
session_ids = [session_id] if tip_only else self._resume_lineage_ids(session_id)
active_clause = "active = 1" if tip_only else "(active = 1 OR compacted = 1)"
placeholders = ",".join("?" for _ in session_ids)
row = self._read_one(
f"SELECT COUNT(*) FROM messages "
f"WHERE session_id IN ({placeholders}) AND {active_clause}",
tuple(session_ids),
)
return int(row[0] if row else 0)
def assert_resume_safe(
self,
session_id: str,
max_messages: Optional[int] = None,
*,
tip_only: bool = False,
) -> int:
"""Return resume row count or reject a transcript too large to load.
``max_messages=None`` resolves the limit from config
(``sessions.max_resume_messages``); 0 disables the guard and returns
the (bounded) count without raising.
``tip_only=True`` bounds only the tip segment's ACTIVE rows, for
callers that never materialize the ancestor lineage or the
compaction archive in memory (tip-only model restore, deferred
Desktop resume whose display history is REST-paginated). A
heavily-compressed conversation — 85 compaction segments and ~29k
lineage rows behind a ~700-row tip — is exactly the shape compression
is supposed to produce; counting its whole lineage against a limit
sized for in-memory materialization rejected the healthiest sessions
(Desktop Bot Chat stuck on "Waking up…" with code 4130) while the
process would only ever have held the tip.
The full (non-``tip_only``) bound counts the DISPLAY set — active plus
compaction-archived rows — because that is what
``get_resume_conversations`` materializes for the transcript.
"""
from hermes_state import SessionResumeTooLargeError, resolved_max_resume_messages
if max_messages is None:
max_messages = resolved_max_resume_messages()
if max_messages < 0:
raise ValueError("max_messages must be non-negative")
if max_messages == 0:
# Guard disabled by config — skip counting entirely. Every live
# caller invokes this for its raise side effect and ignores the
# return value, and an unbounded lineage COUNT here would do the
# exact pathological work the disable exists to avoid.
return 0
session_ids = [session_id] if tip_only else self._resume_lineage_ids(session_id)
active_clause = "active = 1" if tip_only else "(active = 1 OR compacted = 1)"
placeholders = ",".join("?" for _ in session_ids)
row = self._read_one(
"SELECT COUNT(*) FROM ("
f"SELECT 1 FROM messages WHERE session_id IN ({placeholders}) "
f"AND {active_clause} LIMIT ?"
")",
(*session_ids, max_messages + 1),
)
message_count = int(row[0] if row else 0)
if message_count > max_messages:
raise SessionResumeTooLargeError(
message_count,
max_messages,
scope="in its tip segment" if tip_only else "across its lineage",
)
return message_count
def get_ancestor_display_prefix(self, session_id: str) -> List[Dict[str, Any]]:
"""Return the ancestor-only display messages for a session lineage.
These are messages from parent/grandparent sessions (compression
ancestors) that appear in the display transcript but NOT in the
tip session's model-fed history. Used by ``session.resume`` to
build the ``display_history_prefix`` that ``_live_session_payload``
prepends to the live model history.
Previously the prefix was calculated as
``display_history[:len(display) - len(raw)]``, but that overcounts
when ``repair_message_sequence`` removes messages from the MIDDLE
of the tip history (e.g. verification candidates collapsed by the
consecutive-assistant merge) — the length difference includes both
ancestor messages AND repair-removed tip messages, but the slice
only captures the first N display messages (which are tip messages
when there are no ancestors), causing duplication. This method
returns ONLY the genuine ancestor messages, identified by
``session_id != tip_session_id``. (#65919)
"""
session_ids = self._resume_lineage_ids(session_id)
if len(session_ids) <= 1:
return []
with self._read_ctx() as conn:
placeholders = ",".join("?" for _ in session_ids)
rows = conn.execute(
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
f"FROM messages WHERE session_id IN ({placeholders}) "
# Display read: compaction-archived rows included, Undo/Rewind
# rows excluded (see get_resume_conversations).
"AND (active = 1 OR compacted = 1) "
"ORDER BY id",
tuple(session_ids),
).fetchall()
rows = self._dedupe_display_generations(rows)
ancestor_ids = {
int(row["id"])
for row in rows
if row["session_id"] != session_id and row["id"] is not None
}
if not ancestor_ids:
return []
lineage = self._rows_to_conversation(
rows,
session_id=session_id,
include_ancestors=True,
repair_alternation=False,
include_row_ids=True,
)
prefix: List[Dict[str, Any]] = []
for message in lineage:
if message.get("_row_id") not in ancestor_ids:
continue
projected = message.copy()
projected.pop("_row_id", None)
prefix.append(projected)
return prefix
def get_conversation_root(self, session_id: str) -> str:
"""Return the ROOT id of *session_id*'s lineage chain.
The root is the stable "conversation id": context compression
rotates ``session_id`` to a new segment linked via
``parent_session_id``, and delegate subagents hang off their
parent the same way. Walking to the root gives every segment of
one user-facing conversation (and its delegation tree) a single
identifier — used for Nous Portal ``conversation=`` usage tagging.
Returns *session_id* unchanged when it has no recorded parent.
"""
chain = self._session_lineage_root_to_tip(session_id)
return (chain[0] if chain and chain[0] else session_id)
@staticmethod
def _canonical_replayed_user_content(
msg: Dict[str, Any],
) -> Tuple[Any, bool]:
"""Return canonical live content and whether *msg* is composite."""
if msg.get("role") != "user":
return None, False
from agent.context_compressor import split_user_originated_turn
handoff, live_view = split_user_originated_turn(msg)
is_composite = handoff is not None and live_view is not None
return (
live_view.get("content")
if is_composite and live_view is not None
else msg.get("content"),
is_composite,
)
@staticmethod
def _exact_replayed_user_clone_key(
timestamp: Any, content: Any
) -> Optional[Tuple[Any, str]]:
"""Return a hashable key for a column-exact rotation clone."""
if timestamp is None or content in (None, "", []):
return None
try:
encoded = json.dumps(
content,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
except (TypeError, ValueError):
return None
return timestamp, encoded
@staticmethod
def _find_duplicate_replayed_user_message(
messages: List[Dict[str, Any]], msg: Dict[str, Any]
) -> Optional[Tuple[int, bool]]:
"""Return an adjacent replay duplicate and whether *msg* must win.
Compression rotation may persist the current ask once in the parent
and again inside a composite child carrier. Compare the canonical live
payload for that carrier, while retaining the historical exact-string
dedupe for ordinary replayed users. The child carrier wins because it
owns both the current durable row identity and the retained scaffold.
"""
from hermes_state import SessionDB
if msg.get("role") != "user":
return None
content, prefer_current = SessionDB._canonical_replayed_user_content(msg)
if content in (None, "", []):
return None
for index in range(len(messages) - 1, -1, -1):
prev = messages[index]
if prev.get("role") == "user":
prev_content, prev_is_composite = (
SessionDB._canonical_replayed_user_content(prev)
)
if prev_content == content and (
prefer_current
or prev_is_composite
or isinstance(content, str)
):
return index, prefer_current
if prev.get("role") == "assistant" and (prev.get("content") or prev.get("tool_calls")):
return None
return None
def get_active_message_ids(self, session_id: str) -> List[int]:
"""Return the ordered physical ids pinned by rewind CAS checks.
Conversation projections intentionally omit legacy background-review
harness rows. Destructive rewinds must nevertheless pin every active
physical row so the caller snapshot matches the transaction-local
comparison in :meth:`rewind_to_message`.
"""
rows = self._read_all(
"SELECT id FROM messages "
"WHERE session_id = ? AND active = 1 ORDER BY id",
(session_id,),
)
return [int(row[0]) for row in rows]
@staticmethod
def _active_transcript_counts(conn, session_id: str) -> tuple[int, int]:
"""Return active message/tool-call counts inside the caller's txn."""
rows = conn.execute(
"SELECT tool_calls FROM messages "
"WHERE session_id = ? AND active = 1",
(session_id,),
).fetchall()
tool_call_count = 0
for row in rows:
raw = row[0]
if not raw:
continue
try:
decoded = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
continue
if isinstance(decoded, list):
tool_call_count += len(decoded)
elif decoded:
tool_call_count += 1
return len(rows), tool_call_count
def rewind_to_message(
self,
session_id: str,
target_message_id: int,
*,
preserve_compaction_handoff: bool = False,
expected_active_ids: Optional[List[int]] = None,
expected_target_content: Any = None,
) -> Dict[str, Any]:
"""Soft-delete all messages with id >= ``target_message_id`` in *session_id*.
The target message itself becomes inactive as well so the caller
can pre-fill it as the next user prompt without it appearing
twice in the replayed transcript. Rewound rows are kept on
disk with ``active=0`` for audit / forensic inspection — use
:meth:`get_messages` with ``include_inactive=True`` to see them.
Returns a dict::
{
"rewound_count": int, # number of rows newly flipped to active=0
"target_message": dict, # full row dict of the target
"new_head_id": int|None # id of the last still-active row, or None
}
Raises ``ValueError`` if the target message does not exist in
*session_id* or if its role is not ``"user"``. With
``preserve_compaction_handoff=True``, a composite summary carrier is
split inside the same write transaction: its original row is archived
and its canonical hidden handoff scaffold is inserted as the new head.
That opt-in result also contains ``replacement_message_id``.
``expected_active_ids`` optionally pins the ordered active row set.
``expected_target_content`` additionally pins the selected canonical
live-user payload. Both checks run inside the write transaction before
any row or counter mutation. Presentation-only metadata changes (for
example Desktop reactions) deliberately do not invalidate a rewind.
A live cross-process turn lease always refuses the rewind; expired or
provably dead holders are reclaimed inside the mutation transaction.
Always increments ``sessions.rewind_count`` — even when the
target is already inactive — so the counter accurately reflects
the number of rewind operations performed against the session.
Idempotent on the ``active`` flag: re-rewinding past the same
target is a no-op on row state but still bumps the counter.
"""
def _do(conn):
# Rewind changes the active transcript and must honor the same
# compression/closed-parent and cross-process turn guards as
# append writers.
self._check_transcript_write_guards(
conn,
session_id,
None,
reject_active_turn_lease=True,
reject_active_compression_lock=True,
)
if expected_active_ids is not None:
active_rows = conn.execute(
"SELECT id FROM messages "
"WHERE session_id = ? AND active = 1 ORDER BY id",
(session_id,),
).fetchall()
active_ids = [int(active_row[0]) for active_row in active_rows]
if active_ids != expected_active_ids:
raise RuntimeError(
"active transcript changed before the rewind could be persisted"
)
row = conn.execute(
"SELECT * FROM messages WHERE id = ? AND session_id = ?",
(target_message_id, session_id),
).fetchone()
if row is None:
raise ValueError(
f"message {target_message_id} not found in session {session_id}"
)
target_row = dict(row)
if target_row.get("role") != "user":
raise ValueError(
f"rewind target must be a 'user' message (got role="
f"{target_row.get('role')!r}, id={target_message_id})"
)
replacement_message_id: Optional[int] = None
replacement: Optional[Dict[str, Any]] = None
if preserve_compaction_handoff or expected_target_content is not None:
if not target_row.get("active"):
raise ValueError("rewind target is not active")
from agent.context_compressor import split_user_originated_turn
split_target = target_row.copy()
split_target["content"] = self._decode_content(
split_target.get("content")
)
split_target["display_metadata"] = self._decode_display_metadata(
split_target.get("display_metadata")
)
handoff, live_view = split_user_originated_turn(split_target)
if live_view is None:
raise ValueError("rewind target is not a user-originated turn")
live_content = live_view.get("content")
if isinstance(live_content, str):
live_content = sanitize_context(live_content).strip()
if (
expected_target_content is not None
and live_content != expected_target_content
):
raise RuntimeError(
"rewind target changed before it could be persisted"
)
if preserve_compaction_handoff and handoff is None:
raise ValueError(
"preserve_compaction_handoff requires an active composite carrier"
)
replacement = handoff if preserve_compaction_handoff else None
cursor = conn.execute(
"SELECT id FROM messages "
"WHERE session_id = ? AND id >= ? AND active = 1",
(session_id, target_message_id),
)
ids = [r[0] for r in cursor.fetchall()]
if ids:
placeholders = ",".join("?" for _ in ids)
conn.execute(
f"UPDATE messages SET active = 0 WHERE id IN ({placeholders})",
ids,
)
if replacement is not None:
self._insert_message_rows(conn, session_id, [replacement])
inserted = conn.execute("SELECT last_insert_rowid()").fetchone()
replacement_message_id = int(inserted[0])
conn.execute(
"UPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 "
"WHERE id = ?",
(session_id,),
)
message_count, tool_call_count = self._active_transcript_counts(
conn, session_id
)
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? "
"WHERE id = ?",
(message_count, tool_call_count, session_id),
)
head_row = conn.execute(
"SELECT MAX(id) FROM messages WHERE session_id = ? AND active = 1",
(session_id,),
).fetchone()
new_head_id = (
head_row[0] if head_row and head_row[0] is not None else None
)
return target_row, ids, new_head_id, replacement_message_id
target_row, rewound, new_head_id, replacement_message_id = (
self._execute_write(_do)
)
# Decode content for callers (prefill the prompt buffer) without a
# second fallible database operation after the transaction commits.
target_row["content"] = self._decode_content(target_row.get("content"))
result = {
"rewound_count": len(rewound),
"target_message": target_row,
"new_head_id": new_head_id,
}
if preserve_compaction_handoff:
result["replacement_message_id"] = replacement_message_id
return result
def message_count(self, session_id: str = None) -> int:
"""Count messages, optionally for a specific session."""
with self._read_ctx() as conn:
if session_id:
cursor = conn.execute(
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
)
else:
cursor = conn.execute("SELECT COUNT(*) FROM messages")
return cursor.fetchone()[0]
def has_platform_message_id(
self, session_id: str, platform_message_id: str
) -> bool:
"""Check if a message with the given platform_message_id exists.
Uses the idx_messages_platform_msg_id partial index for efficient
lookup. Used by the gateway's transient-failure dedupe guard (#47237)
to skip re-persisting a user message that was already saved on a
prior retry of the same inbound platform message.
"""
return self._read_one(
"SELECT 1 FROM messages "
"WHERE session_id = ? AND platform_message_id = ? LIMIT 1",
(session_id, platform_message_id),
) is not None
def _is_explicit_fork_child_row(self, session: Dict[str, Any]) -> bool:
"""True when ``session`` is a branch, delegate, or tool child of its parent.
Markers only count as a fork when they point at ``parent_session_id``.
Compression copies ``model_config`` onto the continuation
(``publish_compression_child`` callers pass
``agent._session_init_model_config``), so a delegate's continuation
carries ``_delegate_from=<the delegate's own parent>``. Presence-only
matching would treat that real continuation as a fork — the same
misclassification ``_NON_CONTINUATION_CHILD_FILTER_SQL`` already
avoids by binding both markers to the queried parent.
"""
if session.get("source") == "tool":
return True
raw = session.get("model_config")
if not raw:
return False
try:
cfg = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, json.JSONDecodeError):
return False
if not isinstance(cfg, dict):
return False
parent_id = session.get("parent_session_id")
branched = cfg.get("_branched_from")
delegated = cfg.get("_delegate_from")
if parent_id:
return branched == parent_id or delegated == parent_id
return branched is not None or delegated is not None
def is_explicit_fork_child(self, session_id: str) -> bool:
"""True when ``session_id`` is a /branch, delegate, or tool child row.
Read-only public view of :meth:`_is_explicit_fork_child_row` for
callers that must respect the fork boundary without re-implementing
its marker rules (``agent/prompt_cache_scope.py`` keeps a declared
conversation key from crossing it). A missing row is not a fork.
"""
session = self.get_session(session_id)
return bool(session and self._is_explicit_fork_child_row(session))
def latest_conversation_boundary(
self, session_key: str, source: str
) -> Optional[int]:
"""How many conversation boundaries this routing peer has crossed.
A boundary is a row this peer ended at an intentional conversation
break — the ``_RESET_END_REASONS`` set (``/new``, ``/switch``, idle,
daily, suspended, resume_pending_expired). That is the same fence
:meth:`find_latest_gateway_session_for_peer` refuses to reach behind,
so the two agree on where one conversation stops and the next begins
and cannot drift.
The peer is ``(session_key, source)``, the SAME identity tuple recovery
uses — never the key alone. ``X-Hermes-Session-Key`` accepts any
authenticated caller-supplied string, so an API conversation may
legally carry the same key as a Telegram row in one database; keying
on the string alone would let a ``/new`` on that unrelated row rotate
this conversation's affinity identity while recovery correctly refuses
to cross the same line.
Returns the count, or ``None`` when this peer has never been reset.
The value comes from ``conversation_generations``, which
:meth:`_bump_conversation_generation` advances inside the transaction
that writes each boundary — NOT from an aggregate over the session
rows. An aggregate cannot prove non-reuse: ``delete_session()``
orphans children and deletes the row, and bulk prune selects ended
rows, so ``COUNT``/``MAX`` over boundaries can return a pair it already
emitted and hand a new conversation a retired affinity identity. It is
also wall-clock-free, so a backwards NTP correction cannot reorder it.
Databases upgraded mid-conversation start at no generation and take
their first one from the next boundary written; a conversation that
reset before the upgrade shares its predecessor's scope once, which
costs a warm prompt-cache bucket and never crosses an identity.
These rows are never garbage-collected, by design: dropping one resets
the peer to "no generation", so its next boundary writes ``1`` again
and re-issues a scope a retired conversation already used — the ABA
this counter exists to prevent. See the schema comment in
``hermes_state_common.py``.
"""
if not session_key or not source:
return None
row = self._read_one(
"SELECT generation FROM conversation_generations "
"WHERE source = ? AND session_key = ?",
(source, session_key),
)
if row is None or row["generation"] is None:
return None
generation = int(row["generation"])
return generation if generation > 0 else None
def clear_messages(self, session_id: str) -> None:
"""Delete all messages for a session and reset its counters."""
def _do(conn):
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (session_id,)
)
conn.execute(
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
(session_id,),
)
self._execute_write(_do)
def purge_stale_tool_call_markers(
self, *, dry_run: bool = False, backup: bool = True
) -> Dict[str, Any]:
"""Permanently clear bare tool-call marker content (e.g. "[memory]")
left in the ``messages`` table by sessions persisted before the
#78148 fix in ``agent.conversation_loop``.
``_strip_stale_tool_call_markers`` already repairs this in memory on
every session load (see ``_rows_to_conversation``), so running this
is optional — but for long-lived sessions the same rows get
re-scanned and re-repaired on every resume, which is wasted work
and keeps the contaminated bytes sitting in the DB (and in any
downstream cache/backup snapshot of it) indefinitely. This rewrites
the affected rows once, in place.
Only the ``content`` column is touched — ``role``, ``tool_calls``,
and every other column on the row are left exactly as they are, so
provider tool_call/tool_result pairing is unaffected.
Unlike the in-memory repair, this UPDATE is permanent and can't be
undone from within the DB. Since ``backup`` defaults to True, a
timestamped full snapshot is taken via ``VACUUM INTO`` (safe against
a live connection, unlike the raw-copy ``_backup_db_file`` used for
malformed-schema repair) before any row is touched — mirroring
``repair_state_db_schema``'s backup-by-default convention for
destructive state.db operations. No snapshot is taken when there is
nothing to change.
With ``dry_run=True``, reports the affected row count/ids without
writing or backing up (read-only, no write lock taken).
Returns ``{"dry_run": bool, "rows_affected": int, "row_ids": [...],
"backup_path": str|None}``.
"""
from hermes_state import _STALE_TOOL_CALL_MARKER_RE
def _find_affected(conn) -> List[int]:
cursor = conn.execute(
"SELECT id, content FROM messages "
"WHERE role = 'assistant' AND tool_calls IS NOT NULL AND tool_calls != ''"
)
affected: List[int] = []
for row in cursor.fetchall():
content = row["content"]
if isinstance(content, str) and _STALE_TOOL_CALL_MARKER_RE.fullmatch(content.strip()):
affected.append(row["id"])
return affected
with self._read_ctx() as conn:
affected_ids = _find_affected(conn)
if dry_run:
return {
"dry_run": True,
"rows_affected": len(affected_ids),
"row_ids": affected_ids,
"backup_path": None,
}
if not affected_ids:
return {
"dry_run": False,
"rows_affected": 0,
"row_ids": [],
"backup_path": None,
}
backup_path: Optional[str] = None
if backup:
import datetime
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
dest = self.db_path.with_name(
f"{self.db_path.name}.pre-clean-markers-backup-{stamp}"
)
with self._lock:
self._conn.execute("VACUUM INTO ?", (str(dest),))
backup_path = str(dest)
logger.info("Backed up state.db to %s before clean-markers write", backup_path)
def _do(conn):
ids = _find_affected(conn)
if ids:
placeholders = ",".join("?" * len(ids))
conn.execute(
f"UPDATE messages SET content = '' WHERE id IN ({placeholders})",
ids,
)
return ids
affected_ids = self._execute_write(_do)
if affected_ids:
logger.info(
"Permanently cleared %d stale tool-call marker row(s) in state.db (#78148)",
len(affected_ids),
)
return {
"dry_run": False,
"rows_affected": len(affected_ids),
"row_ids": affected_ids,
"backup_path": backup_path,
}