feat(moa): add reference model toggles

This commit is contained in:
oppenheimor 2026-07-07 00:30:22 +08:00 committed by Teknium
parent ad6a2ae401
commit ca294d3e62
12 changed files with 181 additions and 22 deletions

View file

@ -880,6 +880,7 @@ def aggregate_moa_context(
so the provider default applies matching single-model agent behavior.
Presets may still pin explicit values.
"""
reference_models = [slot for slot in reference_models if slot.get("enabled", True)]
reference_outputs: list[tuple[str, str, Any]] = []
ref_messages = _reference_messages(api_messages)
reference_outputs = _run_references_parallel(
@ -1267,7 +1268,10 @@ class MoAChatCompletions:
privacy_mode = _moa_privacy_mode(_moa_raw)
self._privacy_mode = privacy_mode
messages = list(api_kwargs.get("messages") or [])
reference_models = preset.get("reference_models") or []
reference_models = [
slot for slot in (preset.get("reference_models") or [])
if slot.get("enabled", True)
]
aggregator = preset.get("aggregator") or {}
# Expose the resolved aggregator slot so session cost accounting can
# price the aggregator's acting turn at its REAL model/provider. The

View file

@ -1078,6 +1078,21 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<div className="grid gap-1">
{currentMoaPreset.reference_models.map((slot, index) => (
<ListRow
action={
<Switch
aria-label={`${slot.enabled !== false ? 'Disable' : 'Enable'} reference ${index + 1}`}
checked={slot.enabled !== false}
disabled={applying}
onCheckedChange={checked =>
updateMoaPreset(prev => ({
...prev,
reference_models: prev.reference_models.map((s, i) =>
i === index ? { ...s, enabled: checked === true } : s
)
}))
}
/>
}
below={
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
<Select
@ -1146,6 +1161,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
</Button>
</div>
}
className={cn(slot.enabled === false && 'opacity-60')}
description={
<span className="font-mono text-[0.68rem]">
{slot.provider} · {slot.model || m.model}
@ -1158,7 +1174,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<Button
disabled={applying}
onClick={() =>
updateMoaPreset(prev => ({ ...prev, reference_models: [...prev.reference_models, prev.aggregator] }))
updateMoaPreset(prev => ({
...prev,
reference_models: [...prev.reference_models, { ...prev.aggregator, enabled: true }]
}))
}
size="sm"
variant="textStrong"

View file

@ -111,7 +111,8 @@ export function ListRow({
hint,
action,
below,
wide = false
wide = false,
className
}: {
title: ReactNode
description?: ReactNode
@ -119,12 +120,13 @@ export function ListRow({
action?: ReactNode
below?: ReactNode
wide?: boolean
className?: string
}) {
return (
// Container-queried, not viewport-queried: the label/control split keys on
// the row's own pane width, so a narrow detail column (messaging, split
// views) stacks instead of squishing the label against minmax(15rem,…).
<div className="@container">
<div className={cn('@container', className)}>
<div
className={cn(
'grid gap-3 py-3',

View file

@ -999,6 +999,7 @@ export interface MoaModelSlot {
model: string
/** Optional per-slot reasoning effort — round-tripped, not edited here. */
reasoning_effort?: string
enabled?: boolean
}
export interface MoaConfigResponse {

View file

@ -111,7 +111,9 @@ def cmd_moa(args) -> 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:

View file

@ -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")),

View file

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

View file

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

View file

@ -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():

View file

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

View file

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

View file

@ -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({
<div className="space-y-2">
<div className="text-display text-xs font-medium tracking-wider">Reference models</div>
{preset.reference_models.map((slot, index) => (
<div key={`${selected}-${slot.provider}-${slot.model}-${index}`} className="flex items-center gap-2 border border-border/50 bg-muted/20 px-3 py-2">
<div
key={`${selected}-${slot.provider}-${slot.model}-${index}`}
className={cn(
"flex items-center gap-2 border border-border/50 bg-muted/20 px-3 py-2",
slot.enabled === false && "opacity-60"
)}
>
<Switch
checked={slot.enabled !== false}
onCheckedChange={(checked) =>
updateSelectedPreset((prev) => ({
...prev,
reference_models: prev.reference_models.map((s, i) =>
i === index ? { ...s, enabled: checked === true } : s
),
}))
}
/>
<div className="min-w-0 flex-1 truncate font-mono text-xs text-text-secondary">{slotLabel(slot)}</div>
<Button size="sm" outlined onClick={() => setPicker({ kind: "reference", index })}>Change</Button>
<Button size="sm" ghost disabled={preset.reference_models.length <= 1} onClick={() => updateSelectedPreset((prev) => ({ ...prev, reference_models: prev.reference_models.filter((_, i) => i !== index) }))}>Remove</Button>
</div>
))}
<Button size="sm" outlined onClick={() => updateSelectedPreset((prev) => ({ ...prev, reference_models: [...prev.reference_models, prev.aggregator] }))}>Add reference model</Button>
<Button size="sm" outlined onClick={() => updateSelectedPreset((prev) => ({ ...prev, reference_models: [...prev.reference_models, { ...prev.aggregator, enabled: true }] }))}>Add reference model</Button>
</div>
<div className="space-y-2">
@ -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),
};
});
}}