diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 2e497b12d8e6..a47ba24eaefa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5524,10 +5524,13 @@ def _persist_migration(config: Dict[str, Any]) -> None: Every migration step MUST route its write through this helper instead of calling ``save_config`` directly. It is a thin wrapper over - ``save_config(config)`` (default-stripping ON); centralising the call makes - the invariant impossible to regress one migration at a time. Correctness - across seeds, non-default values, behaviour flips, and data transforms is - verified by the migration parity tests. + ``save_config(config)`` (default-stripping ON, no ``merge_existing``); + centralising the call makes the invariant impossible to regress one + migration at a time. Callers must pass the full raw config returned by + ``read_raw_config()`` after in-place mutations (including key removals); + deep-merging the on-disk file back in would resurrect keys the migration + just deleted. Partial-save preservation for unrelated top-level sections + belongs on ``save_config(..., merge_existing=True)``, not here. """ save_config(config) @@ -6302,6 +6305,25 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A return results +def _merge_partial_save(raw: dict, override: dict) -> dict: + """Merge *override* over *raw* for partial ``save_config`` writes. + + Top-level sections omitted from *override* are preserved from *raw*. + Shared top-level dict sections are deep-merged so a caller can update one + nested key without dropping sibling keys from disk. Intentional key + removals within a section are not supported here — migration writes must + route through ``_persist_migration`` with a full ``read_raw_config()`` dict + instead. + """ + result = copy.deepcopy(override) + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + elif isinstance(result.get(key), dict) and isinstance(value, dict): + result[key] = _deep_merge(value, result[key]) + return result + + def _deep_merge(base: dict, override: dict) -> dict: """Recursively merge *override* into *base*, preserving nested defaults. @@ -7184,6 +7206,7 @@ def save_config( *, strip_defaults: bool = True, preserve_keys: Optional[Set[Tuple[str, ...]]] = None, + merge_existing: bool = False, ): """Save configuration to ~/.hermes/config.yaml.\n @@ -7192,6 +7215,12 @@ def save_config( before any normalisation). This prevents config.yaml from being contaminated with schema defaults on every save, which makes future default changes invisible to users. + + When ``merge_existing`` is True, the on-disk raw config is deep-merged + under *config* before writing so partial callers (migration steps via + ``_persist_migration``) cannot drop unrelated sections the caller omitted. + Full-document replacement callers (dashboard raw YAML editor, callers that + already deep-merge) must leave this False so intentional deletions survive. """ with _CONFIG_LOCK: if is_managed(): @@ -7226,11 +7255,17 @@ def save_config( explicit_raw_paths: Optional[Set[Tuple[str, ...]]] = ( _explicit_config_paths(_raw_for_paths) if _raw_for_paths else None ) + if merge_existing and _raw_for_paths: + config = _merge_partial_save(_raw_for_paths, config) # ---------------------------------------------------------------- current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) normalized = current_normalized - raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config())) + raw_existing = ( + _normalize_root_model_keys(_normalize_max_turns_config(_raw_for_paths)) + if _raw_for_paths + else {} + ) if raw_existing: normalized = _preserve_env_ref_templates( normalized, @@ -7278,6 +7313,7 @@ def save_config( extra_content="".join(parts) if parts else None, ) _secure_file(config_path) + _RAW_CONFIG_CACHE.pop(str(config_path), None) _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 175ab7550e81..406e18f5ca31 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -14148,7 +14148,9 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None if not isinstance(parsed, dict): raise HTTPException(status_code=400, detail="YAML must be a mapping") with _profile_scope(body.profile or profile): - save_config(parsed) + # Full-document replacement: the editor owns the whole file; do not + # merge omitted sections back from disk (#62723). + save_config(parsed, merge_existing=False) return {"ok": True} except yaml.YAMLError as e: raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")