refactor(state): extract shared model_config merge helper

archive_and_compact's new model_config_patch block was the third
near-identical SELECT -> tolerant-parse -> merge -> UPDATE copy in
hermes_state.py (update_session_runtime_lock and set_session_yolo carry
the other two). Extract _merge_model_config_json(conn, sid, patch,
on_missing=...) and route all three through it, preserving each
caller's missing-row policy (flag setters skip, archive raises).

Also adds the two small accessors the compressor needs:
- patch_session_model_config(): standalone atomic merge for callers
  that must update model_config without rewriting the transcript
- get_session_model_config_value(): tolerant single-key read

Follow-up to the salvaged #79286 commit, per the repo's
extend-don't-duplicate rule.
This commit is contained in:
kshitij
2026-08-06 02:09:40 +05:30
committed by kshitij
parent bf6a210ab9
commit 565b2c42eb
+114 -59
View File
@@ -170,6 +170,12 @@ def _delegate_from_json(col: str = "model_config") -> str:
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"
# Sentinel returned by SessionDB._merge_model_config_json when the session row
# doesn't exist and on_missing="skip" — distinguishes "no row" from the legal
# None result ("merged config is empty → store NULL").
_MODEL_CONFIG_ROW_MISSING = object()
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]
@@ -4349,6 +4355,97 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)
def _merge_model_config_json(
self,
conn,
session_id: str,
patch: Dict[str, Any],
*,
on_missing: str = "skip",
):
"""SELECT + tolerant-parse + merge ``patch`` into a session's model_config.
Shared by every model_config writer (``update_session_runtime_lock``,
``set_session_yolo``, ``archive_and_compact``,
``patch_session_model_config``) so the merge discipline that keeps
lineage markers like ``_branched_from`` / ``_delegate_from`` alive
lives in exactly one place. A ``None`` patch value deletes that key.
Must run inside an open write transaction (callers own the UPDATE).
Returns the serialized merged JSON — ``None`` when the merged dict is
empty (matching ``create_session``'s NULL convention) — or the
``_MODEL_CONFIG_ROW_MISSING`` sentinel when the row doesn't exist and
``on_missing == "skip"``; ``on_missing == "raise"`` raises ValueError.
"""
row = conn.execute(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
if on_missing == "raise":
raise ValueError(f"Session not found: {session_id}")
return _MODEL_CONFIG_ROW_MISSING
raw = row["model_config"] if isinstance(row, sqlite3.Row) else row[0]
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except (json.JSONDecodeError, TypeError):
config = {}
elif isinstance(raw, dict):
config = dict(raw)
for key, value in patch.items():
if value is None:
config.pop(key, None)
else:
config[key] = value
return json.dumps(config) if config else None
def patch_session_model_config(
self, session_id: str, patch: Dict[str, Any]
) -> None:
"""Merge ``patch`` into a session's model_config JSON atomically.
A ``None`` patch value removes that key. No-op when the session row
doesn't exist or the patch is empty. This is the standalone setter for
callers that need to update model_config *without* rewriting the
transcript (the transcript-coupled path is ``archive_and_compact``'s
``model_config_patch``, which shares the same merge helper).
"""
if not session_id or not patch:
return
def _do(conn):
merged = self._merge_model_config_json(conn, session_id, patch)
if merged is _MODEL_CONFIG_ROW_MISSING:
return
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = ?",
(merged, session_id),
)
self._execute_write(_do)
def get_session_model_config_value(
self, session_id: str, key: str, default: Any = None
) -> Any:
"""Read one key out of a session's model_config JSON (tolerant parse)."""
session = self.get_session(session_id) or {}
raw = session.get("model_config")
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except (json.JSONDecodeError, TypeError):
config = {}
elif isinstance(raw, dict):
config = raw
return config.get(key, default)
def update_session_runtime_lock(
self,
session_id: str,
@@ -4375,24 +4472,11 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
}
def _do(conn):
row = conn.execute(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
merged = self._merge_model_config_json(
conn, session_id, {"browser_model_lock": lock}
)
if merged is _MODEL_CONFIG_ROW_MISSING:
return
raw = row["model_config"] if isinstance(row, sqlite3.Row) else row[0]
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except Exception:
config = {}
elif isinstance(raw, dict):
config = dict(raw)
config["browser_model_lock"] = lock
conn.execute(
"""UPDATE sessions SET
model_config = ?,
@@ -4400,7 +4484,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
system_prompt = NULL,
system_prompt_hash = NULL
WHERE id = ?""",
(json.dumps(config), model, session_id),
(merged, model, session_id),
)
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)
@@ -4421,27 +4505,14 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
return
def _do(conn):
row = conn.execute(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
merged = self._merge_model_config_json(
conn, session_id, {"yolo_mode": bool(enabled)}
)
if merged is _MODEL_CONFIG_ROW_MISSING:
return
raw = row["model_config"] if isinstance(row, sqlite3.Row) else row[0]
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except Exception:
config = {}
elif isinstance(raw, dict):
config = dict(raw)
config["yolo_mode"] = bool(enabled)
conn.execute(
"UPDATE sessions SET model_config = ? WHERE id = ?",
(json.dumps(config), session_id),
(merged, session_id),
)
self._execute_write(_do)
@@ -6897,29 +6968,13 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
def _do(conn):
patched_model_config = None
if model_config_patch is not None:
row = conn.execute(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
raise ValueError(f"Session not found: {session_id}")
raw = row["model_config"] if isinstance(row, sqlite3.Row) else row[0]
config: Dict[str, Any] = {}
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
config = parsed
except (json.JSONDecodeError, TypeError):
config = {}
elif isinstance(raw, dict):
config = dict(raw)
for key, value in model_config_patch.items():
if value is None:
config.pop(key, None)
else:
config[key] = value
patched_model_config = json.dumps(config) if config else 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"
)
# Soft-archive the live turns: active=0 hides them from the live
# context load, compacted=1 marks them as "summarized away" (vs