Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/desktop/src/app/settings/model-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -851,7 +858,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
{moaSlotProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
Expand Down Expand Up @@ -930,7 +937,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
{moaSlotProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
Expand Down
7 changes: 7 additions & 0 deletions hermes_cli/moa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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}


Expand Down
51 changes: 51 additions & 0 deletions tests/hermes_cli/test_moa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,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
5 changes: 5 additions & 0 deletions web/src/pages/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading