fix: surface 'Always Approve' silent-failure when ruamel.yaml is missing (#27660)#27679
Open
xxxigm wants to merge 3 commits into
Open
fix: surface 'Always Approve' silent-failure when ruamel.yaml is missing (#27660)#27679xxxigm wants to merge 3 commits into
xxxigm wants to merge 3 commits into
Conversation
When ``ruamel.yaml`` is absent from the active venv (rare but real for ``pip install -e . --no-deps`` and partial installs), every call to ``atomic_roundtrip_yaml_update`` -- the only writer for user-edited ``config.yaml`` -- raised a bare ``ModuleNotFoundError: No module named 'ruamel'`` that propagated up to ``save_config_value`` in cli.py, which caught ``Exception`` and returned ``False`` to its caller. The caller (typically the gateway's "Always Approve" handler for destructive slash commands) had no way to tell a dependency failure apart from a disk-full or permission error, so the failure was logged as a generic "Failed to save config" line that users never see. This change introduces two pieces of contract: 1. ``utils.MissingYamlRoundtripDependency`` -- a typed ``ImportError`` subclass raised by ``atomic_roundtrip_yaml_update`` when ruamel.yaml is unimportable. The exception message includes the actionable pip-install hint (``pip install ruamel.yaml==0.18.17``) so users hit by the issue can self-heal without grepping source. 2. ``cli.save_config_value_detailed(key, value) -> (bool, str | None)`` -- a new variant of ``save_config_value`` that surfaces the underlying failure reason as a user-presentable string. The existing ``save_config_value`` is preserved as a thin bool-only alias so every call site already in the codebase keeps working without modification. Part of the fix for NousResearch#27660. Behaviour change for callers: opt-in via the new ``save_config_value_detailed`` API.
…e slash + reload-mcp Both ``_maybe_confirm_destructive_slash`` and ``_handle_mcp_reload`` in gateway/run.py contained the same silent-failure pattern (NousResearch#27660): * call ``save_config_value("approvals.X", False)`` * log ``"User opted out..."`` on the next line * unconditionally append a reply note claiming "future runs will skip the prompt" * catch any exception and log it at WARNING level Because ``save_config_value`` returns ``False`` rather than raising when the write fails (e.g. ruamel.yaml missing from the venv), the log + reply note fired for every click of "Always Approve" regardless of whether the opt-out had actually been persisted. Users could click the button indefinitely with no effect and no visible error. This change wires both handlers into the new ``save_config_value_detailed`` API: * The "opted out" INFO log now fires only when persist succeeded. * A WARNING is logged with the underlying reason on failure. * The reply tells the user honestly what happened: success keeps the existing "future runs no confirmation" note; failure replaces it with a "⚠️ Could not save the opt-out preference" block that includes the actual error reason and a manual-edit fallback path. * Unexpected exceptions from ``save_config_value_detailed`` itself (rather than just a ``False`` return) are also surfaced now -- the same warning-block path catches them. Fixes NousResearch#27660.
…ousResearch#27660) Add 11 new regression tests across two files to prevent the NousResearch#27660 silent-failure pattern from coming back. tests/cli/test_cli_save_config_value.py (+8): * TestSaveConfigValueDetailed -- pin the (bool, str|None) contract for the new save_config_value_detailed helper: success returns (True, None); ruamel.yaml missing returns (False, msg) where msg contains both "ruamel.yaml" and the "pip install ruamel.yaml" hint; arbitrary OSError still produces a typed message; the bool-only save_config_value alias keeps returning a bare bool so every existing caller stays compatible. * TestMissingYamlRoundtripDependency -- pin the typed exception surface: subclass of ImportError; message contains the install hint; .original preserves the underlying ImportError; raised end-to-end by atomic_roundtrip_yaml_update when ruamel.yaml is unimportable (via sys.modules['ruamel'] = blocker monkeypatch). tests/gateway/test_destructive_slash_confirm.py (+3): * test_resolve_always_surfaces_persist_failure_to_user -- the direct NousResearch#27660 regression test: when save fails, the user reply contains "Could not save the opt-out preference" plus the underlying reason (e.g. "ruamel.yaml"), AND the misleading "future runs will skip without confirmation" note is absent. * test_resolve_always_no_warning_when_persist_succeeds -- happy path: existing success note is preserved, no "Could not save" warning appears. * test_resolve_always_handles_unexpected_exception -- if save_config_value_detailed *raises* rather than returning False, the handler still produces a coherent user reply (no stack trace escapes). The pre-existing test_resolve_always_persists_opt_out_and_runs_execute was updated to patch save_config_value_detailed (the new symbol the gateway calls) as well as save_config_value (kept for any indirect callers).
This was referenced May 21, 2026
Contributor
|
Thanks for the focused bug fix — I verified the underlying premise still holds on current main. Problems
Suggested changes
Automated hermes-sweeper review. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Fixes the "Always Approve" silent-failure for destructive slash commands (
/new,/reset,/undo,/clear) and the parallel/reload-mcpflow whenruamel.yamlis missing from the active venv.Before this PR:
cli.save_config_value()calledutils.atomic_roundtrip_yaml_update()which importsruamel.yamlat function scope. When the import failed,save_config_valuecaught the bareImportError, logged a generic"Failed to save config"line at ERROR level, and returnedFalse.gateway/run.py's_on_confirmhandler ignored the return value, unconditionally logged"User opted out of destructive slash confirm", and appended"Future /clear, /new, /reset, and /undo will run without confirmation"to the user-visible reply.~/.hermes/config.yamlwas never written. The confirmation prompt reappeared on the next/new, and the user could click "Always Approve" indefinitely with zero effect.After this PR the failure is detected, surfaced, and actionable.
Related Issue
Fixes #27660.
The same code shape exists in the
/reload-mcpconfirmation handler (_handle_mcp_reload), and is fixed in the same PR to keep the two parallel paths in sync.Type of Change
Changes Made
utils.py— typed exception at the import site:MissingYamlRoundtripDependency(ImportError). Its message contains the actionable pip-install hint (pip install ruamel.yaml==0.18.17) and preserves the originalImportErroron.original.atomic_roundtrip_yaml_updatenow wraps the function-localfrom ruamel.yaml import YAMLin atry/except ImportErrorand raisesMissingYamlRoundtripDependencyinstead of the bare module-not-found.cli.py— structured success/failure for callers that need it:save_config_value_detailed(key, value) -> (bool, str | None)that surfaces a user-presentable error string on failure (with the install hint when the cause is missingruamel.yaml; withTypeName: msgfor other exceptions).save_config_valueis preserved as a bool-only alias that delegates tosave_config_value_detailed. Every existing caller stays binary-compatible.gateway/run.py— both destructive-slash and/reload-mcp_on_confirmhandlers:save_config_value_detailed.logger.info("User opted out...")now fires only after persist succeeded; failures get alogger.warningwith the underlying reason."Future /clear, /new, /reset, and /undo will run without confirmation. Re-enable via approvals.destructive_slash_confirm: true"note preserved."⚠️ Could not save the opt-out preference, so the confirmation prompt will keep appearing. Reason: <hint>. Fix: add approvals.destructive_slash_confirm: false to ~/.hermes/config.yaml manually."save_config_value_detaileditself (rather than just aFalsereturn) are also caught and surfaced through the same warning block.Tests — 11 new regression tests, 1 existing test updated:
tests/cli/test_cli_save_config_value.py(+8):TestSaveConfigValueDetailedpins the(bool, str|None)contract for success and the two failure modes;TestMissingYamlRoundtripDependencypins the typed-exception surface (subclass ofImportError, message contents,.originalpreservation, end-to-end viasys.modules['ruamel']blocker).tests/gateway/test_destructive_slash_confirm.py(+3): the direct bug: 'Always Approve' for destructive slash commands silently fails when ruamel.yaml is missing from venv #27660 regression test (failure must surface "Could not save" + the reason, and the misleading "without confirmation" note must be absent); the happy-path regression guard (success keeps the existing note, no warning); a coverage test for theraise-instead-of-return Falsepath.test_resolve_always_persists_opt_out_and_runs_executewas updated to patchsave_config_value_detailed(the new symbol the gateway calls).How to Test
Check out the branch and set up the venv:
Reproduce the original bug: 'Always Approve' for destructive slash commands silently fails when ruamel.yaml is missing from venv #27660 scenario locally:
Expected:
(False, 'ruamel.yaml is required to update user config files atomically (this is a declared core dependency in pyproject.toml). Re-install with: pip install ruamel.yaml==0.18.17. Underlying ImportError: ...')Re-install
ruamel.yamland confirm success:Expected:
(True, None)and~/.hermes/config.yamlupdated.Run the new + existing regression tests:
Expected: 25 passed (14 pre-existing + 11 new).
Run the wider approvals/gates suite to confirm no cross-file regression:
Expected: 35 passed.
(Optional) end-to-end against a Telegram bot: with
ruamel.yamlremoved, send/new, click "Always Approve", confirm the reply now contains the"⚠️ Could not save the opt-out preference"block instead of the misleading success note.Checklist
Code
fix(utils,cli),fix(gateway),test(cli,gateway))scripts/run_tests.sh tests/cli/test_cli_save_config_value.py tests/gateway/test_destructive_slash_confirm.pyand all tests passDocumentation & Housekeeping
docs/, docstrings) — docstrings onMissingYamlRoundtripDependency,save_config_value_detailed, and the updatedsave_config_valuedescribe the new contract.cli-config.yaml.exampleif I added/changed config keys — N/A (no config-key changes; only persistence-path error handling)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Atmp_path+monkeypatchand are hermetic.Screenshots / Logs
Failure path verified manually (with
ruamel.yamluninstalled):