fix(moa): surface stale presets without retries
Keep invalid persisted preset names fail-closed, list the valid configured choices, and classify the local lookup failure as deterministic so it reaches Desktop immediately.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user