mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
fix(dashboard): merge PUT /api/config with existing on-disk config
The dashboard form is built from CONFIG_SCHEMA, which doesn't enumerate every root-level key the YAML supports. Most visibly, `custom_providers` is in `_KNOWN_ROOT_KEYS` but is absent from the schema — so the frontend never sends it in the PUT body. The previous full-replace save() then silently wiped the key from disk every time the user clicked anything that triggered a save. Other casualties (less visible because defaults re-mask them on load) include `agent.personalities`, `agent.reasoning_effort`, `terminal.lifetime_seconds`, etc. Fix: read the raw on-disk config and deep-merge the incoming PUT body on top of it before saving. The frontend can only overwrite what it explicitly sends; everything else is preserved verbatim. Reuses the existing `_deep_merge` helper from `hermes_cli.config`. Tests: - `test_round_trip_preserves_custom_providers` exercises the exact bug: seed config with custom_providers, GET → drop the key → PUT, assert it's still on disk. - `test_round_trip_preserves_schema_invisible_nested_keys` covers the shallow-vs-deep-merge case for nested dicts under `agent` etc. Both fail on current main; both pass with this patch.
This commit is contained in:
parent
ec769e49d2
commit
76af2456a2
2 changed files with 79 additions and 1 deletions
|
|
@ -56,6 +56,7 @@ from hermes_cli.config import (
|
|||
get_hermes_home,
|
||||
load_config,
|
||||
load_env,
|
||||
read_raw_config,
|
||||
save_config,
|
||||
save_env_value,
|
||||
remove_env_value,
|
||||
|
|
@ -65,6 +66,7 @@ from hermes_cli.config import (
|
|||
recommended_update_command_for_method,
|
||||
redact_key,
|
||||
write_platform_config_field,
|
||||
_deep_merge,
|
||||
)
|
||||
from hermes_cli.memory_providers import (
|
||||
MemoryProvider,
|
||||
|
|
@ -4324,7 +4326,15 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
|
||||
try:
|
||||
with _profile_scope(body.profile or profile):
|
||||
save_config(_denormalize_config_from_web(body.config))
|
||||
# The dashboard form is schema-driven (see CONFIG_SCHEMA). Any root
|
||||
# key absent from the schema — most visibly ``custom_providers``, but
|
||||
# also ``agent.personalities``, ``terminal.lifetime_seconds``, etc. —
|
||||
# is not sent in the PUT body. A full-replace save would silently
|
||||
# drop those keys. Deep-merge incoming over what's on disk so the
|
||||
# frontend can only overwrite what it explicitly sends.
|
||||
existing = read_raw_config()
|
||||
incoming = _denormalize_config_from_web(body.config)
|
||||
save_config(_deep_merge(existing, incoming))
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -2917,6 +2917,74 @@ class TestConfigRoundTrip:
|
|||
web_config["agent"]["max_turns"] = original_turns
|
||||
self.client.put("/api/config", json={"config": web_config})
|
||||
|
||||
def test_round_trip_preserves_custom_providers(self):
|
||||
"""``custom_providers`` is not in the dashboard schema, so the
|
||||
frontend never sends it in PUT bodies. Saving must still preserve
|
||||
it on disk — otherwise every dashboard click that saves silently
|
||||
wipes the user's custom endpoints."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
save_config({
|
||||
"model": {"default": "test/model", "provider": "custom:myprov"},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "myprov",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"key_env": "MYPROV_API_KEY",
|
||||
"api_mode": "chat_completions",
|
||||
"model": "test/model",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
# Frontend behaviour: GET full config, then PUT without keys the
|
||||
# schema doesn't know about (custom_providers is the prime example).
|
||||
web_config = self.client.get("/api/config").json()
|
||||
web_config.pop("custom_providers", None)
|
||||
resp = self.client.put("/api/config", json={"config": web_config})
|
||||
assert resp.status_code == 200
|
||||
|
||||
after = load_config()
|
||||
cps = after.get("custom_providers")
|
||||
assert isinstance(cps, list) and len(cps) == 1, \
|
||||
f"custom_providers wiped by lossy PUT: {cps!r}"
|
||||
assert cps[0].get("name") == "myprov"
|
||||
assert cps[0].get("base_url") == "https://example.invalid/v1"
|
||||
|
||||
def test_round_trip_preserves_schema_invisible_nested_keys(self):
|
||||
"""Nested keys that aren't in CONFIG_SCHEMA must also survive a
|
||||
round-trip. Deep-merge is required — a shallow merge would drop
|
||||
``agent.<custom_key>`` when the frontend sends a partial ``agent``
|
||||
dict containing only schema-known sub-fields."""
|
||||
from hermes_cli.config import load_config, read_raw_config, save_config
|
||||
|
||||
# Seed config with a key under `agent` that isn't in the schema.
|
||||
# Use a sentinel name to avoid colliding with future schema fields.
|
||||
save_config({
|
||||
"agent": {
|
||||
"max_turns": 50,
|
||||
"x_dashboard_invisible_test_key": {"nested": "value"},
|
||||
},
|
||||
})
|
||||
|
||||
# PUT only schema-known agent fields, exactly like the dashboard.
|
||||
web_config = self.client.get("/api/config").json()
|
||||
web_config.setdefault("agent", {})
|
||||
web_config["agent"]["max_turns"] = 75
|
||||
# Strip our sentinel so we're sending what the schema-driven form
|
||||
# would send.
|
||||
web_config["agent"].pop("x_dashboard_invisible_test_key", None)
|
||||
|
||||
resp = self.client.put("/api/config", json={"config": web_config})
|
||||
assert resp.status_code == 200
|
||||
|
||||
on_disk = read_raw_config()
|
||||
assert on_disk.get("agent", {}).get("max_turns") == 75
|
||||
assert on_disk.get("agent", {}).get("x_dashboard_invisible_test_key") \
|
||||
== {"nested": "value"}, \
|
||||
"Shallow-merge regression: agent.x_dashboard_invisible_test_key " \
|
||||
"was wiped when the frontend sent a partial agent dict."
|
||||
|
||||
def test_schema_types_match_config_values(self):
|
||||
"""Every schema field should have a matching-type value in the config."""
|
||||
config = self.client.get("/api/config").json()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue