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 time
import uuid import uuid
from contextlib import closing, contextmanager from contextlib import closing, contextmanager
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal, overload
try: # pragma: no cover - platform-specific imports. try: # pragma: no cover - platform-specific imports.
import fcntl as _fcntl import fcntl as _fcntl
@@ -8107,6 +8109,43 @@ def _json_loads_if_string(value):
return 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( def get_state_db_session_messages(
sid, sid,
*, *,
@@ -8115,7 +8154,33 @@ def get_state_db_session_messages(
since_timestamp=None, since_timestamp=None,
include_inactive: bool = False, include_inactive: bool = False,
limit=None, 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. """Read messages for a Hermes session from state.db.
When *profile* is supplied, reads from that profile's state.db; otherwise 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 ``active=0`` archive rows back in resurrects pre-compaction history and can
make every later turn re-trigger compression. Pass ``include_inactive=True`` make every later turn re-trigger compression. Pass ``include_inactive=True``
only for explicit recovery/audit views. 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: try:
import sqlite3 import sqlite3
except ImportError: except ImportError:
return [] return _state_db_session_messages_result([], None, with_revision=with_revision)
if isinstance(profile, str) and profile: if isinstance(profile, str) and profile:
db_path = _get_profile_home(profile) / 'state.db' db_path = _get_profile_home(profile) / 'state.db'
if not db_path.exists():
db_path = _active_state_db_path()
else: else:
db_path = _active_state_db_path() db_path = _active_state_db_path()
if not db_path.exists(): if not db_path.exists():
return [] return _state_db_session_messages_result([], None, with_revision=with_revision)
try: try:
with closing(open_state_db_readonly(db_path)) as conn: 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()} available = {str(row['name']) for row in cur.fetchall()}
required = {'role', 'content', 'timestamp'} required = {'role', 'content', 'timestamp'}
if not required.issubset(available): if not required.issubset(available):
return [] return _state_db_session_messages_result([], None, with_revision=with_revision)
optional = [ optional = [
'tool_call_id', 'tool_call_id',
'tool_calls', 'tool_calls',
@@ -8185,7 +8253,18 @@ def get_state_db_session_messages(
'api_content', 'api_content',
] ]
id_col = ['id'] if 'id' in available else [] 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)] session_chain = [str(sid)]
if stitch_continuations: if stitch_continuations:
@@ -8244,6 +8323,7 @@ def get_state_db_session_messages(
active_clause = "" active_clause = ""
if 'active' in available and not include_inactive: if 'active' in available and not include_inactive:
active_clause = " AND (active IS NULL OR active != 0)" 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 # Defensive row cap (backstop only — see docstring). Applied as a
# SQL LIMIT bound parameter (?) so the tail (newest) rows are # SQL LIMIT bound parameter (?) so the tail (newest) rows are
# retained and a pathological state.db can't materialize unbounded # retained and a pathological state.db can't materialize unbounded
@@ -8255,10 +8335,9 @@ def get_state_db_session_messages(
except (TypeError, ValueError): except (TypeError, ValueError):
limit_int = None limit_int = None
if limit_int is not None: if limit_int is not None:
# The query orders ASC (oldest first); to keep the NEWEST # Keep the newest durable rows under the cap, then restore
# rows under the cap, take a descending-ordered subquery and # canonical append order for the caller.
# re-sort ascending — a plain LIMIT would keep the oldest. limit_clause = f" ORDER BY {durable_order_column} DESC LIMIT ?"
limit_clause = " ORDER BY timestamp DESC, id DESC LIMIT ?"
params.append(limit_int) params.append(limit_int)
if limit_clause: if limit_clause:
cur.execute(f""" cur.execute(f"""
@@ -8269,7 +8348,7 @@ def get_state_db_session_messages(
{since_clause} {since_clause}
{active_clause} {active_clause}
{limit_clause} {limit_clause}
) ORDER BY timestamp ASC, id ASC ) ORDER BY {durable_order_column} ASC
""", params) """, params)
else: else:
cur.execute(f""" cur.execute(f"""
@@ -8278,10 +8357,34 @@ def get_state_db_session_messages(
WHERE session_id IN ({placeholders}) WHERE session_id IN ({placeholders})
{since_clause} {since_clause}
{active_clause} {active_clause}
ORDER BY timestamp ASC, id ASC ORDER BY {durable_order_column} ASC
""", params) """, 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 = [] msgs = []
for row in cur.fetchall(): for row in rows:
msg = { msg = {
'role': row['role'], 'role': row['role'],
'content': row['content'], 'content': row['content'],
@@ -8315,8 +8418,8 @@ def get_state_db_session_messages(
msg['name'] = msg['tool_name'] msg['name'] = msg['tool_name']
msgs.append(msg) msgs.append(msg)
except Exception: except Exception:
return [] return _state_db_session_messages_result([], None, with_revision=with_revision)
return msgs return _state_db_session_messages_result(msgs, revision, with_revision=with_revision)
def get_state_db_session_message_prefix_summary( def get_state_db_session_message_prefix_summary(
@@ -10210,12 +10313,40 @@ def merge_session_messages_append_only(
return merged_messages return merged_messages
@overload
def reconciled_state_db_messages_for_session( def reconciled_state_db_messages_for_session(
session, *, prefer_context: bool = False, state_messages: list | None = None session,
) -> list: *,
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.""" """Return append-only messages reconciled with state.db for a WebUI session."""
if session is None: 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 = [] local_messages = []
using_context_messages = False using_context_messages = False
if prefer_context: if prefer_context:
@@ -10226,7 +10357,28 @@ def reconciled_state_db_messages_for_session(
if not local_messages: if not local_messages:
local_messages = getattr(session, 'messages', None) or [] local_messages = getattr(session, 'messages', None) or []
if state_messages is None: 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 prefer_context and local_messages:
if using_context_messages: if using_context_messages:
sidecar_messages = getattr(session, 'messages', None) or [] 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", "Compressed context for session %s has no compression anchor; using context_messages only",
getattr(session, "session_id", None), 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) anchor_index = _state_db_anchor_index(state_messages, anchor_key)
if anchor_index is None: if anchor_index is None:
logger.debug( logger.debug(
"Compressed context for session %s has an unverifiable compression anchor; using context_messages only", "Compressed context for session %s has an unverifiable compression anchor; using context_messages only",
getattr(session, "session_id", None), 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 = list(state_messages or [])[anchor_index + 1 :]
state_messages = state_db_delta_after_context(local_messages, state_messages) 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, local_messages,
state_messages, state_messages,
truncation_watermark=getattr(session, "truncation_watermark", None), truncation_watermark=getattr(session, "truncation_watermark", None),
truncation_boundary=getattr(session, "truncation_boundary", 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: def get_cli_session_messages(sid, *, profile=None) -> list:
+743 -112
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -285,11 +285,19 @@ def test_auth_401_classification_receives_stringified_probe_text(tmp_path, monke
observed = {} observed = {}
real_classify = streaming._classify_provider_error real_classify = streaming._classify_provider_error
def _spy_classify_provider_error(err_str, exc=None, *, silent_failure=False): def _spy_classify_provider_error(
err_str, exc=None, *, silent_failure=False, result=None
):
observed["err_str"] = err_str observed["err_str"] = err_str
observed["exc"] = exc observed["exc"] = exc
observed["silent_failure"] = silent_failure observed["silent_failure"] = silent_failure
return real_classify(err_str, exc, silent_failure=silent_failure) observed["result"] = result
return real_classify(
err_str,
exc,
silent_failure=silent_failure,
result=result,
)
with mock.patch.object(streaming, "_classify_provider_error", side_effect=_spy_classify_provider_error): with mock.patch.object(streaming, "_classify_provider_error", side_effect=_spy_classify_provider_error):
_run_stream(monkeypatch, session, "stream_auth_probe_text", agent_cls, workspace=str(tmp_path)) _run_stream(monkeypatch, session, "stream_auth_probe_text", agent_cls, workspace=str(tmp_path))
@@ -415,6 +423,77 @@ def test_auth_retry_success_does_not_append_error_turn(tmp_path, monkeypatch):
assert not any(msg.get("_error") for msg in saved.messages) assert not any(msg.get("_error") for msg in saved.messages)
def test_auth_exception_retry_structured_failure_still_emits_error(
tmp_path,
monkeypatch,
):
session = _prepare_session(
"auth_retry_structured_failure",
"stream_auth_retry_structured_failure",
pending_user_message="Please retry",
)
class ExceptionThenStructuredFailureAgent(MockAgent):
runs = 0
def run_conversation(self, **kwargs):
type(self).runs += 1
if type(self).runs == 1:
raise RuntimeError("401 unauthorized")
if self.stream_delta_callback is not None:
self.stream_delta_callback("partial retry output")
return {
"error": {
"type": "authentication_error",
"status_code": 401,
"message": "retry returned a structured failure",
},
"messages": list(kwargs.get("conversation_history") or []),
}
fake_queue = queue.Queue()
streaming.STREAMS["stream_auth_retry_structured_failure"] = fake_queue
config.STREAM_PARTIAL_TEXT["stream_auth_retry_structured_failure"] = ""
heal_rt = {
"provider": "test-provider",
"api_key": "fresh-key",
"base_url": None,
}
with mock.patch.object(streaming, "get_session", return_value=session), \
mock.patch.object(
streaming,
"_get_ai_agent",
return_value=ExceptionThenStructuredFailureAgent,
), \
mock.patch.object(
streaming,
"resolve_model_provider",
return_value=("test-model", "test-provider", None),
), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch.object(
streaming,
"_attempt_credential_self_heal",
return_value=heal_rt,
):
streaming._run_agent_streaming(
session_id=session.session_id,
msg_text=session.pending_user_message,
model="test-model",
workspace=str(tmp_path),
stream_id="stream_auth_retry_structured_failure",
)
saved = Session.load("auth_retry_structured_failure")
assert saved is not None
events = _queue_events(fake_queue)
assert any(event == "apperror" for event, _ in events)
assert not any(event == "done" for event, _ in events)
assert saved.messages[-1]["_error"] is True
def test_success_repeated_assistant_text_stays_successful_current_turn(tmp_path, monkeypatch): def test_success_repeated_assistant_text_stays_successful_current_turn(tmp_path, monkeypatch):
session = _prepare_session("repeat_success", "stream_repeat_success", pending_user_message="Please say it again") session = _prepare_session("repeat_success", "stream_repeat_success", pending_user_message="Please say it again")
_seed_prior_turn( _seed_prior_turn(
+46
View File
@@ -117,3 +117,49 @@ def test_limit_composes_with_since_timestamp(tmp_path, monkeypatch):
msgs = get_state_db_session_messages("s1", since_timestamp=50.0, limit=10) msgs = get_state_db_session_messages("s1", since_timestamp=50.0, limit=10)
assert len(msgs) == 10 assert len(msgs) == 10
assert [m["content"] for m in msgs] == [f"msg{i}" for i in range(90, 100)] assert [m["content"] for m in msgs] == [f"msg{i}" for i in range(90, 100)]
def test_reader_uses_durable_id_order_when_timestamps_are_non_monotonic(
tmp_path,
monkeypatch,
):
db = tmp_path / "state.db"
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY)")
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT,
content TEXT,
timestamp REAL,
active INTEGER DEFAULT 1
)
"""
)
conn.execute("INSERT INTO sessions (id) VALUES ('s1')")
conn.executemany(
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
[
("s1", "user", "id-1 timestamp-300", 300.0),
("s1", "assistant", "id-2 timestamp-100", 100.0),
("s1", "user", "id-3 timestamp-200", 200.0),
],
)
conn.commit()
conn.close()
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
full = get_state_db_session_messages("s1")
limited = get_state_db_session_messages("s1", limit=2)
assert [message["content"] for message in full] == [
"id-1 timestamp-300",
"id-2 timestamp-100",
"id-3 timestamp-200",
]
assert [message["content"] for message in limited] == [
"id-2 timestamp-100",
"id-3 timestamp-200",
]