From 565b2c42eba3afc4bb4ed38371e15f25ff6cb8a4 Mon Sep 17 00:00:00 2001 From: kshitij Date: Thu, 6 Aug 2026 02:09:40 +0530 Subject: [PATCH] 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. --- hermes_state.py | 173 +++++++++++++++++++++++++++++++----------------- 1 file changed, 114 insertions(+), 59 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index ef66dcf307..9c1fa6b054 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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