fix(compression): fence durable transcript revisions (#6554)

Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-08-20 22:01:52 -07:00
1 parent 46c734a721
commit c27f3d67a8
5 files changed
+2554 -137

No files matched your search

+188 -23
View File
@@ -13,7 +13,9 @@ import threading
import time
import uuid
from contextlib import closing, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, overload
try: # pragma: no cover - platform-specific imports.
import fcntl as _fcntl
@@ -8107,6 +8109,43 @@ def _json_loads_if_string(value):
return value
@dataclass(frozen=True)
class StateDBSessionMessagesSnapshot:
"""Internal message projection paired with its durable SQLite revision."""
messages: list
revision: dict | None
def _state_db_session_messages_result(messages, revision, *, with_revision):
if with_revision:
return StateDBSessionMessagesSnapshot(messages=list(messages or []), revision=revision)
return list(messages or [])
def _state_db_active_rows_digest(rows) -> str:
"""Match the Agent fence for model-facing fields mutable in place."""
digest = hashlib.sha256()
stamped = False
for row in rows:
keys = row.keys()
api_content = row['api_content'] if 'api_content' in keys else None
if api_content is None:
continue
stamped = True
digest.update(
json.dumps(
[row['id'], api_content],
ensure_ascii=False,
separators=(',', ':'),
default=str,
).encode('utf-8', errors='surrogatepass')
)
digest.update(b'\n')
return digest.hexdigest() if stamped else ''
@overload
def get_state_db_session_messages(
sid,
*,
@@ -8115,7 +8154,33 @@ def get_state_db_session_messages(
since_timestamp=None,
include_inactive: bool = False,
limit=None,
) -> list:
with_revision: Literal[False] = False,
) -> list: ...
@overload
def get_state_db_session_messages(
sid,
*,
stitch_continuations: bool = False,
profile=None,
since_timestamp=None,
include_inactive: bool = False,
limit=None,
with_revision: Literal[True],
) -> StateDBSessionMessagesSnapshot: ...
def get_state_db_session_messages(
sid,
*,
stitch_continuations: bool = False,
profile=None,
since_timestamp=None,
include_inactive: bool = False,
limit=None,
with_revision: bool = False,
):
"""Read messages for a Hermes session from state.db.
When *profile* is supplied, reads from that profile's state.db; otherwise
@@ -8145,20 +8210,23 @@ def get_state_db_session_messages(
``active=0`` archive rows back in resurrects pre-compaction history and can
make every later turn re-trigger compression. Pass ``include_inactive=True``
only for explicit recovery/audit views.
``with_revision=True`` returns a :class:`StateDBSessionMessagesSnapshot`.
Its revision is derived from the exact rows fetched by the same SQLite
query and is available only for an unbounded, active, current-segment read.
Existing callers keep the historical list return by default.
"""
try:
import sqlite3
except ImportError:
return []
return _state_db_session_messages_result([], None, with_revision=with_revision)
if isinstance(profile, str) and profile:
db_path = _get_profile_home(profile) / 'state.db'
if not db_path.exists():
db_path = _active_state_db_path()
else:
db_path = _active_state_db_path()
if not db_path.exists():
return []
return _state_db_session_messages_result([], None, with_revision=with_revision)
try:
with closing(open_state_db_readonly(db_path)) as conn:
@@ -8168,7 +8236,7 @@ def get_state_db_session_messages(
available = {str(row['name']) for row in cur.fetchall()}
required = {'role', 'content', 'timestamp'}
if not required.issubset(available):
return []
return _state_db_session_messages_result([], None, with_revision=with_revision)
optional = [
'tool_call_id',
'tool_calls',
@@ -8185,7 +8253,18 @@ def get_state_db_session_messages(
'api_content',
]
id_col = ['id'] if 'id' in available else []
selected = id_col + ['role', 'content', 'timestamp'] + [c for c in optional if c in available]
revision_cols = []
if with_revision:
if 'active' in available:
revision_cols.append('active')
if 'api_content' in available:
revision_cols.append('api_content')
selected = (
id_col
+ ['role', 'content', 'timestamp']
+ revision_cols
+ [c for c in optional if c in available]
)
session_chain = [str(sid)]
if stitch_continuations:
@@ -8244,6 +8323,7 @@ def get_state_db_session_messages(
active_clause = ""
if 'active' in available and not include_inactive:
active_clause = " AND (active IS NULL OR active != 0)"
durable_order_column = 'id' if 'id' in available else 'timestamp'
# Defensive row cap (backstop only — see docstring). Applied as a
# SQL LIMIT bound parameter (?) so the tail (newest) rows are
# retained and a pathological state.db can't materialize unbounded
@@ -8255,10 +8335,9 @@ def get_state_db_session_messages(
except (TypeError, ValueError):
limit_int = None
if limit_int is not None:
# The query orders ASC (oldest first); to keep the NEWEST
# rows under the cap, take a descending-ordered subquery and
# re-sort ascending — a plain LIMIT would keep the oldest.
limit_clause = " ORDER BY timestamp DESC, id DESC LIMIT ?"
# Keep the newest durable rows under the cap, then restore
# canonical append order for the caller.
limit_clause = f" ORDER BY {durable_order_column} DESC LIMIT ?"
params.append(limit_int)
if limit_clause:
cur.execute(f"""
@@ -8269,7 +8348,7 @@ def get_state_db_session_messages(
{since_clause}
{active_clause}
{limit_clause}
) ORDER BY timestamp ASC, id ASC
) ORDER BY {durable_order_column} ASC
""", params)
else:
cur.execute(f"""
@@ -8278,10 +8357,34 @@ def get_state_db_session_messages(
WHERE session_id IN ({placeholders})
{since_clause}
{active_clause}
ORDER BY timestamp ASC, id ASC
ORDER BY {durable_order_column} ASC
""", params)
rows = cur.fetchall()
revision = None
if (
with_revision
and 'id' in available
and 'active' in available
and len(session_chain) == 1
and str(session_chain[0]) == str(sid)
and since_timestamp is None
and not include_inactive
and limit is None
and all(row['active'] == 1 for row in rows)
):
revision = {
'session_id': str(sid),
'active_message_count': len(rows),
'max_active_message_id': max(
(int(row['id']) for row in rows),
default=0,
),
}
if 'api_content' in available:
revision['active_rows_digest'] = _state_db_active_rows_digest(rows)
msgs = []
for row in cur.fetchall():
for row in rows:
msg = {
'role': row['role'],
'content': row['content'],
@@ -8315,8 +8418,8 @@ def get_state_db_session_messages(
msg['name'] = msg['tool_name']
msgs.append(msg)
except Exception:
return []
return msgs
return _state_db_session_messages_result([], None, with_revision=with_revision)
return _state_db_session_messages_result(msgs, revision, with_revision=with_revision)
def get_state_db_session_message_prefix_summary(
@@ -10210,12 +10313,40 @@ def merge_session_messages_append_only(
return merged_messages
@overload
def reconciled_state_db_messages_for_session(
session, *, prefer_context: bool = False, state_messages: list | None = None
) -> list:
session,
*,
prefer_context: bool = False,
state_messages: list | StateDBSessionMessagesSnapshot | None = None,
with_revision: Literal[False] = False,
) -> list: ...
@overload
def reconciled_state_db_messages_for_session(
session,
*,
prefer_context: bool = False,
state_messages: list | StateDBSessionMessagesSnapshot | None = None,
with_revision: Literal[True],
) -> StateDBSessionMessagesSnapshot: ...
def reconciled_state_db_messages_for_session(
session,
*,
prefer_context: bool = False,
state_messages: list | StateDBSessionMessagesSnapshot | None = None,
with_revision: bool = False,
):
"""Return append-only messages reconciled with state.db for a WebUI session."""
if session is None:
return []
return _state_db_session_messages_result([], None, with_revision=with_revision)
state_revision = None
if isinstance(state_messages, StateDBSessionMessagesSnapshot):
state_revision = state_messages.revision
state_messages = state_messages.messages
local_messages = []
using_context_messages = False
if prefer_context:
@@ -10226,7 +10357,28 @@ def reconciled_state_db_messages_for_session(
if not local_messages:
local_messages = getattr(session, 'messages', None) or []
if state_messages is None:
state_messages = get_state_db_session_messages(getattr(session, 'session_id', None))
session_id = getattr(session, 'session_id', None)
session_profile = getattr(session, 'profile', None)
if with_revision:
state_result = get_state_db_session_messages(
session_id,
profile=session_profile,
with_revision=True,
)
elif session_profile:
state_result = get_state_db_session_messages(
session_id,
profile=session_profile,
)
else:
# Preserve the historical one-argument call contract for ordinary
# reconciliation and for integrations that replace this reader.
state_result = get_state_db_session_messages(session_id)
if isinstance(state_result, StateDBSessionMessagesSnapshot):
state_revision = state_result.revision
state_messages = state_result.messages
else:
state_messages = state_result
if prefer_context and local_messages:
if using_context_messages:
sidecar_messages = getattr(session, 'messages', None) or []
@@ -10253,22 +10405,35 @@ def reconciled_state_db_messages_for_session(
"Compressed context for session %s has no compression anchor; using context_messages only",
getattr(session, "session_id", None),
)
return list(local_messages)
return _state_db_session_messages_result(
local_messages,
state_revision,
with_revision=with_revision,
)
anchor_index = _state_db_anchor_index(state_messages, anchor_key)
if anchor_index is None:
logger.debug(
"Compressed context for session %s has an unverifiable compression anchor; using context_messages only",
getattr(session, "session_id", None),
)
return list(local_messages)
return _state_db_session_messages_result(
local_messages,
state_revision,
with_revision=with_revision,
)
state_messages = list(state_messages or [])[anchor_index + 1 :]
state_messages = state_db_delta_after_context(local_messages, state_messages)
return merge_session_messages_append_only(
reconciled_messages = merge_session_messages_append_only(
local_messages,
state_messages,
truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", None),
)
return _state_db_session_messages_result(
reconciled_messages,
state_revision,
with_revision=with_revision,
)
def get_cli_session_messages(sid, *, profile=None) -> list: