mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
feat(moa): add reference model toggles
This commit is contained in:
parent
ad6a2ae401
commit
ca294d3e62
12 changed files with 181 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue