fix(moa): block the moa virtual provider as a reference or aggregator slot (#53281)

A MoA preset whose reference or aggregator slot points at the moa virtual
provider creates a recursive MoA tree. The runtime guards in moa_loop.py only
surface this mid-turn (references silently skipped, aggregator raises). Reject
it at the config chokepoint (_clean_slot) so it can never be saved, and hide it
from the desktop/dashboard slot pickers so it isn't offered as a dead choice.
This commit is contained in:
Teknium 2026-06-26 14:42:42 -07:00 committed by GitHub
parent 515192c4b9
commit 7e101e553b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 72 additions and 2 deletions

View file

@ -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) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
{moaSlotProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
@ -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>

View file

@ -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}

View file

@ -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

View file

@ -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 {