mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
fix(config): preserve platforms on partial save_config writes (#62723)
Add merge_existing to save_config (default False for full-document callers like the dashboard YAML editor) and route partial writes through _merge_partial_save. _persist_migration writes the full migrated dict directly so deleted keys are not resurrected from the on-disk file.
This commit is contained in:
parent
569b912d7d
commit
0ab90040ad
2 changed files with 44 additions and 6 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue