diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 81cda0e0eff..c8ed3aa0568 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -182,6 +182,13 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const providerOptions = providers.length ? providers : NO_PROVIDERS + // MoA reference/aggregator slots must never be the moa virtual provider — + // that would create a recursive MoA tree (the backend rejects it on save). + // Hide it from the slot selectors so it isn't offered as a dead choice. + const moaSlotProviderOptions = providerOptions.filter( + provider => (provider.slug || '').toLowerCase() !== 'moa' + ) + const selectedProviderRow = useMemo( () => providers.find(provider => provider.slug === selectedProvider), [providers, selectedProvider] @@ -851,7 +858,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { - {providerOptions.map(provider => ( + {moaSlotProviderOptions.map(provider => ( {provider.name} @@ -930,7 +937,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { - {providerOptions.map(provider => ( + {moaSlotProviderOptions.map(provider => ( {provider.name} diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index bd74ced49f8..40828cbae5a 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -49,6 +49,13 @@ def _clean_slot(slot: Any) -> dict[str, str] | None: model = str(slot.get("model") or "").strip() if not provider or not model: return None + # MoA is a virtual provider whose presets are themselves MoA runs. Allowing + # one as a reference or aggregator slot would create a recursive MoA tree + # (the runtime guards in moa_loop.py skip references / raise on aggregators, + # but that surfaces only mid-turn). Reject it here so it can never be saved: + # an invalid slot is dropped, falling back to the preset's defaults. + if provider.lower() == "moa": + return None return {"provider": provider, "model": model} diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index 630d183927d..51f80b8067b 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -134,3 +134,54 @@ def test_build_moa_turn_prompt_encodes_one_shot_default_preset(): assert decoded_prompt == "write a file then inspect it" assert cfg is not None assert cfg["reference_models"] == DEFAULT_MOA_REFERENCE_MODELS + + +def test_moa_provider_rejected_as_reference_slot(): + """A reference slot pointing at the moa virtual provider is dropped, so a + preset cannot recursively reference another MoA run.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "moa", "model": "default"}, + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, + ], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + } + } + } + ) + + refs = cfg["presets"]["p"]["reference_models"] + assert {"provider": "moa", "model": "default"} not in refs + assert refs == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}] + + +def test_moa_provider_rejected_as_aggregator_slot(): + """An aggregator slot pointing at the moa virtual provider is dropped and + falls back to the default aggregator, never a recursive MoA aggregator.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}], + "aggregator": {"provider": "moa", "model": "default"}, + } + } + } + ) + + agg = cfg["presets"]["p"]["aggregator"] + assert agg["provider"] != "moa" + assert agg == DEFAULT_MOA_AGGREGATOR + + +def test_moa_provider_rejected_case_insensitive(): + """Case variants like ``MoA`` are also blocked.""" + cfg = normalize_moa_config( + {"presets": {"p": {"aggregator": {"provider": "MoA", "model": "default"}}}} + ) + + assert cfg["presets"]["p"]["aggregator"]["provider"] != "moa" + assert cfg["presets"]["p"]["aggregator"] == DEFAULT_MOA_AGGREGATOR diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 709ed379c27..5bddab251ec 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -846,6 +846,11 @@ function MoaModelsModal({ alwaysGlobal title="Select MoA Model" onApply={async ({ provider, model }) => { + if ((provider || "").toLowerCase() === "moa") { + setError("MoA presets can't reference or aggregate the Mixture of Agents provider (no recursive MoA)."); + return; + } + setError(null); updateSelectedPreset((prev) => { if (picker.kind === "aggregator") return { ...prev, aggregator: { provider, model } }; return {