None:
idx = 0
while True:
base = existing[idx] if idx < len(existing) else None
- refs.append(_pick_slot(base))
+ picked = _pick_slot(base)
+ picked["enabled"] = bool((base or {}).get("enabled", True))
+ refs.append(picked)
idx += 1
choice = _prompt_choice("Add another reference model?", ["Add another", "Done"], 1)
if choice == 1:
diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py
index cfa6826fe1bd..2c49b5508053 100644
--- a/hermes_cli/moa_config.py
+++ b/hermes_cli/moa_config.py
@@ -21,6 +21,10 @@ DEFAULT_MOA_AGGREGATOR: dict[str, str] = {
}
+def _default_reference_models() -> list[dict[str, Any]]:
+ return [{**slot, "enabled": True} for slot in deepcopy(DEFAULT_MOA_REFERENCE_MODELS)]
+
+
def _coerce_float_or_none(value: Any) -> float | None:
"""Coerce to a float, or None when unset/blank/invalid.
@@ -137,7 +141,22 @@ def _clean_reasoning_effort(value: Any) -> str | None:
return parsed.get("effort")
-def _clean_slot(slot: Any) -> dict[str, Any] | None:
+def _coerce_bool(value: Any, default: bool = True) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ text = value.strip().lower()
+ if text in {"0", "false", "no", "off"}:
+ return False
+ if text in {"1", "true", "yes", "on"}:
+ return True
+ return default
+ return bool(value)
+
+
+def _clean_slot(slot: Any, *, include_enabled: bool = False) -> dict[str, Any] | None:
if not isinstance(slot, dict):
return None
provider = str(slot.get("provider") or "").strip()
@@ -163,6 +182,8 @@ def _clean_slot(slot: Any) -> dict[str, Any] | None:
slot_mt = _coerce_int_or_none(slot.get("max_tokens"))
if slot_mt is not None:
clean["max_tokens"] = slot_mt
+ if include_enabled:
+ clean["enabled"] = _coerce_bool(slot.get("enabled"), True)
return clean
@@ -239,7 +260,7 @@ def validate_moa_payload(raw: Any) -> list[str]:
def _default_preset() -> dict[str, Any]:
return {
- "reference_models": deepcopy(DEFAULT_MOA_REFERENCE_MODELS),
+ "reference_models": _default_reference_models(),
"aggregator": deepcopy(DEFAULT_MOA_AGGREGATOR),
# None = temperature omitted from API calls (provider default),
# matching single-model agent behavior.
@@ -268,15 +289,15 @@ def _normalize_preset(raw: Any) -> dict[str, Any]:
# defaults instead of crashing the iteration, mirroring the tolerance
# for the scalar fields below (reference_temperature / max_tokens).
raw_refs = [raw_refs] if isinstance(raw_refs, dict) else []
- refs = [_clean_slot(item) for item in raw_refs]
+ refs = [_clean_slot(item, include_enabled=True) for item in raw_refs]
refs = [item for item in refs if item is not None]
if not refs:
- refs = deepcopy(DEFAULT_MOA_REFERENCE_MODELS)
+ refs = _default_reference_models()
aggregator = _clean_slot(raw.get("aggregator")) or deepcopy(DEFAULT_MOA_AGGREGATOR)
return {
- "enabled": bool(raw.get("enabled", True)),
+ "enabled": _coerce_bool(raw.get("enabled"), True),
"reference_models": refs,
"aggregator": aggregator,
"reference_temperature": _coerce_float_or_none(raw.get("reference_temperature")),
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index d34c6ca467c2..28c48c5cec34 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -1360,6 +1360,7 @@ class MoaModelSlot(BaseModel):
# Optional per-slot reasoning effort. Declared so a client round-tripping
# the GET payload doesn't have it stripped at parse time and wiped on save.
reasoning_effort: Optional[str] = None
+ enabled: bool = True
class MoaPresetPayload(BaseModel):
diff --git a/tests/cli/test_moa_command.py b/tests/cli/test_moa_command.py
index 7a60dd226bc4..4f3469bebd0c 100644
--- a/tests/cli/test_moa_command.py
+++ b/tests/cli/test_moa_command.py
@@ -79,7 +79,9 @@ def test_decode_legacy_encoded_moa_turn_still_works():
encoded = build_moa_turn_prompt("hello", _make_cli().config["moa"], preset="review")
prompt, cfg = decode_moa_turn(encoded)
assert prompt == "hello"
- assert cfg["reference_models"] == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}]
+ assert cfg["reference_models"] == [
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "enabled": True}
+ ]
class TestNormalizeMoaModel:
@@ -129,3 +131,4 @@ class TestNormalizeMoaModel:
requested_provider = override or "deepseek" or "auto"
assert requested_provider == "moa"
assert model == "strategy"
+
diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py
index ee16f873050e..818051ace8fc 100644
--- a/tests/hermes_cli/test_moa_config.py
+++ b/tests/hermes_cli/test_moa_config.py
@@ -35,12 +35,16 @@ def test_moa_slot_picker_excludes_unconfigured_providers(monkeypatch):
assert captured["include_unconfigured"] is False
+def _enabled_refs(refs):
+ return [{**slot, "enabled": True} for slot in refs]
+
+
def test_normalize_moa_config_uses_default_named_preset():
cfg = normalize_moa_config({})
assert cfg["default_preset"] == DEFAULT_MOA_PRESET_NAME
assert list(cfg["presets"]) == [DEFAULT_MOA_PRESET_NAME]
- assert cfg["reference_models"] == DEFAULT_MOA_REFERENCE_MODELS
+ assert cfg["reference_models"] == _enabled_refs(DEFAULT_MOA_REFERENCE_MODELS)
assert cfg["aggregator"] == DEFAULT_MOA_AGGREGATOR
@@ -63,7 +67,45 @@ def test_normalize_moa_config_preserves_named_presets():
assert cfg["default_preset"] == "coding"
assert set(cfg["presets"]) == {"coding", "review"}
- assert cfg["reference_models"] == [{"provider": "openai-codex", "model": "gpt-5.5"}]
+ assert cfg["reference_models"] == [{"provider": "openai-codex", "model": "gpt-5.5", "enabled": True}]
+
+
+def test_normalize_moa_config_defaults_reference_enabled_true():
+ cfg = normalize_moa_config(
+ {
+ "presets": {
+ "review": {
+ "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
+ "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
+ }
+ }
+ }
+ )
+
+ assert cfg["presets"]["review"]["reference_models"] == [
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "enabled": True}
+ ]
+
+
+def test_normalize_moa_config_preserves_disabled_reference():
+ cfg = normalize_moa_config(
+ {
+ "presets": {
+ "review": {
+ "reference_models": [
+ {"provider": "openai-codex", "model": "gpt-5.5", "enabled": False},
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "enabled": "false"},
+ ],
+ "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
+ }
+ }
+ }
+ )
+
+ assert cfg["presets"]["review"]["reference_models"] == [
+ {"provider": "openai-codex", "model": "gpt-5.5", "enabled": False},
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "enabled": False},
+ ]
def test_legacy_flat_config_becomes_default_preset():
@@ -75,7 +117,7 @@ def test_legacy_flat_config_becomes_default_preset():
)
assert cfg["presets"][DEFAULT_MOA_PRESET_NAME]["reference_models"] == [
- {"provider": "openai-codex", "model": "gpt-5.5"}
+ {"provider": "openai-codex", "model": "gpt-5.5", "enabled": True}
]
@@ -110,7 +152,7 @@ def test_normalize_moa_config_tolerates_non_list_reference_models():
cfg = normalize_moa_config(
{"presets": {"broken": {"reference_models": 2}}}
)
- assert cfg["presets"]["broken"]["reference_models"] == DEFAULT_MOA_REFERENCE_MODELS
+ assert cfg["presets"]["broken"]["reference_models"] == _enabled_refs(DEFAULT_MOA_REFERENCE_MODELS)
def test_normalize_moa_config_wraps_bare_dict_reference_models():
@@ -118,7 +160,7 @@ def test_normalize_moa_config_wraps_bare_dict_reference_models():
cfg = normalize_moa_config(
{"presets": {"p": {"reference_models": {"provider": "openai", "model": "gpt-4o"}}}}
)
- assert cfg["presets"]["p"]["reference_models"] == [{"provider": "openai", "model": "gpt-4o"}]
+ assert cfg["presets"]["p"]["reference_models"] == [{"provider": "openai", "model": "gpt-4o", "enabled": True}]
def test_normalize_moa_config_parses_json_string_reference_models():
@@ -250,7 +292,7 @@ def test_resolve_moa_preset_returns_requested_model_set():
)
assert resolve_moa_preset(cfg, "review")["reference_models"] == [
- {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "enabled": True}
]
@@ -300,7 +342,7 @@ def test_build_moa_turn_prompt_encodes_one_shot_default_preset():
decoded_prompt, cfg = decode_moa_turn(prompt)
assert decoded_prompt == "write a file then inspect it"
assert cfg is not None
- assert cfg["reference_models"] == DEFAULT_MOA_REFERENCE_MODELS
+ assert cfg["reference_models"] == _enabled_refs(DEFAULT_MOA_REFERENCE_MODELS)
def test_moa_provider_rejected_as_reference_slot():
@@ -322,7 +364,7 @@ def test_moa_provider_rejected_as_reference_slot():
refs = cfg["presets"]["p"]["reference_models"]
assert {"provider": "moa", "model": "default"} not in refs
- assert refs == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}]
+ assert refs == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "enabled": True}]
def test_moa_provider_rejected_as_aggregator_slot():
diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py
index 1ad39d315c06..ea1087197762 100644
--- a/tests/run_agent/test_moa_loop_mode.py
+++ b/tests/run_agent/test_moa_loop_mode.py
@@ -1006,6 +1006,51 @@ moa:
assert agg_call["messages"][-1]["content"] == "question"
+def test_moa_disabled_reference_is_not_called(monkeypatch, tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ (home / "config.yaml").write_text(
+ """
+moa:
+ default_preset: review
+ presets:
+ review:
+ reference_models:
+ - provider: openai-codex
+ model: gpt-5.5
+ enabled: false
+ - provider: openrouter
+ model: deepseek/deepseek-v4-pro
+ enabled: true
+ aggregator:
+ provider: openrouter
+ model: anthropic/claude-opus-4.8
+""".strip(),
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ calls = []
+
+ def fake_call_llm(**kwargs):
+ calls.append(kwargs)
+ if kwargs["task"] == "moa_reference":
+ return _response(f"reference from {kwargs['provider']}:{kwargs['model']}")
+ return _response("aggregator acted")
+
+ monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm)
+
+ from agent.moa_loop import MoAChatCompletions
+
+ facade = MoAChatCompletions("review")
+ facade.create(messages=[{"role": "user", "content": "question"}], tools=[{"type": "function"}])
+
+ reference_calls = [c for c in calls if c["task"] == "moa_reference"]
+ assert [(c["provider"], c["model"]) for c in reference_calls] == [
+ ("openrouter", "deepseek/deepseek-v4-pro")
+ ]
+ assert calls[-1]["task"] == "moa_aggregator"
+
+
def test_references_run_in_parallel(monkeypatch):
"""References fan out concurrently (delegate-batch semantics), not serially.
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 7474a8322a54..89643a996ac7 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -2333,6 +2333,7 @@ export interface MoaModelSlot {
model: string;
/** Optional per-slot reasoning effort — round-tripped, not edited here. */
reasoning_effort?: string;
+ enabled?: boolean;
}
export interface MoaConfigResponse {
diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx
index e4fb338bc970..c27f045e0be8 100644
--- a/web/src/pages/ModelsPage.tsx
+++ b/web/src/pages/ModelsPage.tsx
@@ -34,6 +34,7 @@ import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
+import { Switch } from "@nous-research/ui/ui/components/switch";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { usePageHeader } from "@/contexts/usePageHeader";
@@ -855,13 +856,30 @@ function MoaModelsModal({
Reference models
{preset.reference_models.map((slot, index) => (
-
+
+
+ updateSelectedPreset((prev) => ({
+ ...prev,
+ reference_models: prev.reference_models.map((s, i) =>
+ i === index ? { ...s, enabled: checked === true } : s
+ ),
+ }))
+ }
+ />
{slotLabel(slot)}
))}
-
+
@@ -895,7 +913,7 @@ function MoaModelsModal({
if (picker.kind === "aggregator") return { ...prev, aggregator: { provider, model } };
return {
...prev,
- reference_models: prev.reference_models.map((slot, i) => i === picker.index ? { provider, model } : slot),
+ reference_models: prev.reference_models.map((slot, i) => i === picker.index ? { ...slot, provider, model } : slot),
};
});
}}