hermes - ✅(Solved) Fix hermes config set destroys YAML list fields (e.g. custom_providers) — _set_nested assumes all nodes are dict [2 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#17876Fetched 2026-05-01 05:55:23
View on GitHub
Comments
0
Participants
1
Timeline
10
Reactions
0
Author
Participants
Timeline (top)
labeled ×4referenced ×3cross-referenced ×2closed ×1

Root Cause

Two places in hermes_cli/config.py have the same bug:

1. _set_nested() (line ~2294)

for part in parts[:-1]:
    if part not in current or not isinstance(current.get(part), dict):
        current[part] = {}  # ← replaces list with empty dict
    current = current[part]

2. set_config_value() (line ~4410) — duplicates the same logic inline instead of calling _set_nested:

for part in parts[:-1]:
    if part not in current or not isinstance(current.get(part), dict):
        current[part] = {}  # ← same bug
    current = current[part]

When part is a numeric index (e.g. "0") and current is a list, isinstance(current, dict) is False, so the code overwrites the list with {}.

Fix Action

Fix

_set_nested should detect when the current value is a list and navigate by numeric index:

def _set_nested(config: dict, dotted_key: str, value):
    parts = dotted_key.split(".")
    current = config
    for part in parts[:-1]:
        if isinstance(current, list):
            idx = int(part)
            current = current[idx]
        elif isinstance(current, dict):
            if part not in current or not isinstance(current.get(part), (dict, list)):
                current[part] = {}
            current = current[part]
        else:
            raise TypeError(f"Cannot navigate into {type(current).__name__}")
    if isinstance(current, list):
        current[int(parts[-1])] = value
    else:
        current[parts[-1]] = value

And set_config_value() should call _set_nested() instead of duplicating the navigation logic.

PR fix notes

PR #17892: fix(config): preserve list-typed YAML fields in hermes config set (#17876)

Description (problem / solution / changelog)

Summary

  • hermes config set custom_providers.0.api_key X was destroying the entire custom_providers list and replacing it with {"0": {"api_key": "X"}} — wiping every other provider and every other field on the targeted provider.
  • Root cause: _set_nested overwrote any non-dict intermediate (including lists) with {}, and set_config_value duplicated the same walk inline.
  • Fix: teach _set_nested to navigate into existing lists by integer index, and route set_config_value through the helper so both paths share one implementation.

The bug

hermes_cli/config.py:_set_nested walked the dotted key with:

for part in parts[:-1]:
    if part not in current or not isinstance(current.get(part), dict):
        current[part] = {}
    current = current[part]

When current is a list (e.g. custom_providers), isinstance(..., dict) is False, so the helper unconditionally replaces the list with {}. The final dotted component ("0") then becomes a string key on a dict.

set_config_value (~line 4424) inlined the same walk, so the bug was present on the user-facing CLI path too. Reproduced on origin/main (8d302e37a).

The fix

_set_nested now:

  • Navigates into existing list intermediates by int(part) instead of flattening them.
  • Treats both dict and list as valid intermediates so already-list fields are not overwritten.
  • Sets the leaf via list indexing when the parent is a list.

set_config_value now calls _set_nested(user_config, key, value) instead of duplicating the walk.

Dict-only call sites (_set_nested(config, "tts.provider", ...), skill-config keys at _set_nested(config, "skills.config.<x>", ...)) are behavior-equivalent — the new code only changes behavior when an intermediate is a list.

Test plan

  • Focused regression test — tests/hermes_cli/test_set_config_value.py::TestListNavigation (5 new tests):
    • _set_nested indexes into an existing list and updates the right element field, leaving siblings/other elements intact.
    • _set_nested can replace a whole list element (custom_providers.1).
    • _set_nested preserves dict-only paths (no behavioral change for tts.provider etc.).
    • set_config_value updates a list element field and round-trips through YAML without flattening.
    • set_config_value regression guard: YAML output still uses list syntax (custom_providers:\n- ...).
  • Adjacent suite — tests/hermes_cli/test_set_config_value.py 37/37 pass; tests/tools/test_terminal_config_env_sync.py 4/4 pass.
  • Regression guard — running the pre-fix _set_nested against the same input ({"custom_providers": [{"name": "a", "api_key": "old"}, {"name": "b"}]}, _set_nested(c, "custom_providers.0.api_key", "new")) produces {"custom_providers": {"0": {"api_key": "new"}}} (list collapsed to dict, second entry destroyed). The new tests assert list structure preservation, so they fail without this fix.

Related

  • Fixes #17876

Changed files

  • hermes_cli/config.py (modified, +47/-15)
  • tests/hermes_cli/test_set_config_value.py (modified, +123/-1)

PR #17893: fix(config): preserve YAML lists in hermes config set (#17876)

Description (problem / solution / changelog)

Closes #17876.

Problem

hermes config set custom_providers.0.api_key NEW silently destroys every sibling entry in the list plus every field inside entry 0 the user didn't explicitly write. _set_nested unconditionally overwrote any non-dict value with {} while walking the dotted path, so as soon as a segment was a numeric index into a list, the whole list was collapsed to a single-key dict.

Reported by @Andy283 with an exact repro. Affects custom_providers, platform_toolsets, and every other list-typed config field.

Fix

hermes_cli/config.py

  • _set_nested now detects list nodes (numeric index parsing + bounds delegation), and preserves dicts AND lists at intermediate positions. Scalars are still replaced with a fresh dict so bare-scalar → nested overrides still work.
  • set_config_value drops its duplicated navigation logic and calls _set_nested instead — single source of truth for the rules.

Validation

scripts/run_tests.sh tests/hermes_cli/test_set_config_value.py
35 passed in 0.85s

New regression tests:

  • test_indexed_set_preserves_sibling_list_entries — exact #17876 repro
  • test_indexed_set_preserves_non_targeted_fields — inner-dict fields survive
  • test_deeper_nesting_through_list — dict → list → dict → scalar path

E2E-verified against a real on-disk config.yaml with the issue's repro: list stays a list, entry 0 updated, entry 1 intact, inner models dict preserved.

Before / after

BeforeAfter
custom_providers[0]{api_key: new_key} (everything else dropped){name: ..., api_key: new_key, base_url: ..., models: {...}}
custom_providers[1]destroyeduntouched
type(custom_providers)dictlist

Credit to @Andy283 for the clear report + suggested fix direction.

Changed files

  • hermes_cli/config.py (modified, +50/-18)
  • tests/hermes_cli/test_set_config_value.py (modified, +85/-0)

Code Example

# config.yaml has:
# custom_providers:
# - name: provider-a
#   api_key: key1
#   base_url: https://a.com
# - name: provider-b
#   api_key: key2
#   base_url: https://b.com

hermes config set custom_providers.0.api_key "new_key"

---

for part in parts[:-1]:
    if part not in current or not isinstance(current.get(part), dict):
        current[part] = {}  # ← replaces list with empty dict
    current = current[part]

---

for part in parts[:-1]:
    if part not in current or not isinstance(current.get(part), dict):
        current[part] = {}  # ← same bug
    current = current[part]

---

def _set_nested(config: dict, dotted_key: str, value):
    parts = dotted_key.split(".")
    current = config
    for part in parts[:-1]:
        if isinstance(current, list):
            idx = int(part)
            current = current[idx]
        elif isinstance(current, dict):
            if part not in current or not isinstance(current.get(part), (dict, list)):
                current[part] = {}
            current = current[part]
        else:
            raise TypeError(f"Cannot navigate into {type(current).__name__}")
    if isinstance(current, list):
        current[int(parts[-1])] = value
    else:
        current[parts[-1]] = value
RAW_BUFFERClick to expand / collapse

Bug

hermes config set corrupts YAML list-typed config fields by replacing the list with a dict.

Reproduce

# config.yaml has:
# custom_providers:
# - name: provider-a
#   api_key: key1
#   base_url: https://a.com
# - name: provider-b
#   api_key: key2
#   base_url: https://b.com

hermes config set custom_providers.0.api_key "new_key"

Expected: Only custom_providers[0].api_key changes; list structure and other fields preserved.

Actual: custom_providers is replaced with {"0": {"api_key": "new_key"}}. All other fields (name, base_url, models, etc.) and the second provider are destroyed.

Root Cause

Two places in hermes_cli/config.py have the same bug:

1. _set_nested() (line ~2294)

for part in parts[:-1]:
    if part not in current or not isinstance(current.get(part), dict):
        current[part] = {}  # ← replaces list with empty dict
    current = current[part]

2. set_config_value() (line ~4410) — duplicates the same logic inline instead of calling _set_nested:

for part in parts[:-1]:
    if part not in current or not isinstance(current.get(part), dict):
        current[part] = {}  # ← same bug
    current = current[part]

When part is a numeric index (e.g. "0") and current is a list, isinstance(current, dict) is False, so the code overwrites the list with {}.

Fix

_set_nested should detect when the current value is a list and navigate by numeric index:

def _set_nested(config: dict, dotted_key: str, value):
    parts = dotted_key.split(".")
    current = config
    for part in parts[:-1]:
        if isinstance(current, list):
            idx = int(part)
            current = current[idx]
        elif isinstance(current, dict):
            if part not in current or not isinstance(current.get(part), (dict, list)):
                current[part] = {}
            current = current[part]
        else:
            raise TypeError(f"Cannot navigate into {type(current).__name__}")
    if isinstance(current, list):
        current[int(parts[-1])] = value
    else:
        current[parts[-1]] = value

And set_config_value() should call _set_nested() instead of duplicating the navigation logic.

Impact

Any custom_providers, platform_toolsets, or other list-typed config fields are silently destroyed by hermes config set. The custom_providers field is especially common since users with multiple providers need it.

Verification

Reproduced on latest main (5b85a7d35). The bug is in the original implementations — set_config_value (commit 619c72e56) and _set_nested (commit 613493988) — not introduced by any local patches. Confirmed via git stash to upstream code.

extent analysis

TL;DR

The bug in hermes config set can be fixed by modifying the _set_nested function to correctly handle list-typed config fields and ensuring set_config_value calls this updated function.

Guidance

  • Update the _set_nested function as shown in the provided fix to handle navigation into lists using numeric indices.
  • Modify set_config_value to call the updated _set_nested function instead of duplicating the navigation logic.
  • Verify the fix by running the reproduce steps and checking that the list structure and other fields are preserved.
  • Review other parts of the codebase for similar logic that might be affected by this bug.

Example

def _set_nested(config: dict, dotted_key: str, value):
    parts = dotted_key.split(".")
    current = config
    for part in parts[:-1]:
        if isinstance(current, list):
            idx = int(part)
            current = current[idx]
        elif isinstance(current, dict):
            if part not in current or not isinstance(current.get(part), (dict, list)):
                current[part] = {}
            current = current[part]
        else:
            raise TypeError(f"Cannot navigate into {type(current).__name__}")
    if isinstance(current, list):
        current[int(parts[-1])] = value
    else:
        current[parts[-1]] = value

Notes

This fix assumes that the issue is solely with the _set_nested function and its usage in set_config_value. Additional testing should be performed to ensure no other parts of the codebase are affected by similar logic.

Recommendation

Apply the workaround by updating the _set_nested function and modifying set_config_value to use it, as this directly addresses the identified bug and prevents silent destruction of list-typed config fields.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix hermes config set destroys YAML list fields (e.g. custom_providers) — _set_nested assumes all nodes are dict [2 pull requests, 1 participants]