diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 9fcd18ca098e..396d922fb44c 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -775,6 +775,14 @@ def classify_api_error( if classified is not None: return classified + # Local MoA config drift is deterministic: a persisted session can retain + # a preset name that was later renamed/deleted. Retrying the same lookup + # cannot recover and makes a clear config error look like an API outage. + from agent.errors import MoAPresetNotFoundError + + if isinstance(error, MoAPresetNotFoundError): + return _result(FailoverReason.model_not_found, retryable=False) + # ── 3. Error code classification ──────────────────────────────── if error_code: diff --git a/agent/errors.py b/agent/errors.py index 89afcd3b4c8a..8b3db5a3589c 100644 --- a/agent/errors.py +++ b/agent/errors.py @@ -7,3 +7,7 @@ class EmptyStreamError(RuntimeError): """Raised when a provider closes a stream without yielding a response.""" pass + + +class MoAPresetNotFoundError(ValueError): + """Raised when a persisted MoA preset no longer exists in config.""" diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index 39594ee399eb..a5401c525892 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -295,7 +295,13 @@ def resolve_moa_preset(config: Any, name: str | None = None) -> dict[str, Any]: preset_name = str(name or cfg.get("default_preset") or DEFAULT_MOA_PRESET_NAME).strip() preset = cfg["presets"].get(preset_name) if preset is None: - raise KeyError(preset_name) + from agent.errors import MoAPresetNotFoundError + + available = ", ".join(cfg["presets"]) or "(none)" + raise MoAPresetNotFoundError( + f"MoA preset '{preset_name}' was not found. Available presets: " + f"{available}. Run `hermes moa list`." + ) return deepcopy(preset) diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index c78631f5e24c..e6b4714e2256 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -1,3 +1,6 @@ +import pytest + +from agent.errors import MoAPresetNotFoundError from hermes_cli.moa_config import ( DEFAULT_MOA_AGGREGATOR, DEFAULT_MOA_PRESET_NAME, @@ -205,6 +208,46 @@ def test_resolve_moa_preset_returns_requested_model_set(): ] +def test_resolve_missing_moa_preset_has_actionable_error(): + cfg = { + "default_preset": "日常对话-高峰", + "presets": {"日常对话-高峰": {}, "日常对话-非高峰": {}}, + } + + with pytest.raises(MoAPresetNotFoundError) as exc_info: + resolve_moa_preset(cfg, "日常对话-高峰期") + + message = str(exc_info.value) + assert "日常对话-高峰期" in message + assert "日常对话-高峰" in message + assert "日常对话-非高峰" in message + assert "hermes moa list" in message + + +def test_resolve_missing_moa_preset_does_not_silently_fallback(): + cfg = { + "default_preset": "日常对话-高峰", + "presets": {"日常对话-高峰": {}}, + } + + with pytest.raises(MoAPresetNotFoundError): + resolve_moa_preset(cfg, "renamed-preset") + + +def test_missing_moa_preset_is_non_retryable(): + from agent.error_classifier import FailoverReason, classify_api_error + + result = classify_api_error( + MoAPresetNotFoundError("MoA preset 'old' was not found"), + provider="moa", + model="old", + ) + + assert result.reason == FailoverReason.model_not_found + assert result.retryable is False + assert result.should_fallback is False + + def test_build_moa_turn_prompt_encodes_one_shot_default_preset(): prompt = build_moa_turn_prompt("write a file then inspect it")