Merge simp/r3-21-B (model_switch/moa)
This commit is contained in:
@@ -12,7 +12,6 @@ from hermes_cli.moa_config import DEFAULT_MOA_PRESET_NAME, normalize_moa_config
|
||||
def _prompt_choice(title: str, rows: list[str], default: int = 0) -> int:
|
||||
try:
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
||||
return curses_radiolist(title, rows, selected=default, cancel_returns=default)
|
||||
except Exception:
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
@@ -37,8 +36,7 @@ def _model_options() -> list[dict[str, Any]]:
|
||||
canonical_order=True,
|
||||
pricing=True,
|
||||
capabilities=True,
|
||||
max_models=200,
|
||||
)
|
||||
max_models=200)
|
||||
providers = payload.get("providers") or []
|
||||
return [p for p in providers if p.get("slug") and str(p.get("slug")).strip().lower() != "moa" and p.get("models")]
|
||||
|
||||
@@ -72,8 +70,7 @@ def _print_config(config: dict[str, Any]) -> None:
|
||||
print(f"Default: {cfg['default_preset']}")
|
||||
print(f"Active in config: {cfg.get('active_preset') or '(off)'}")
|
||||
for name, preset in cfg["presets"].items():
|
||||
marker = "*" if name == cfg["default_preset"] else " "
|
||||
print(f"\n{marker} {name}")
|
||||
print(f"\n{'*' if name == cfg['default_preset'] else ' '} {name}")
|
||||
print(" Reference models:")
|
||||
for idx, slot in enumerate(preset["reference_models"], start=1):
|
||||
print(f" {idx}. {_format_slot(slot)}")
|
||||
@@ -142,8 +139,7 @@ _SUBCOMMANDS = {
|
||||
"ls": _cmd_list,
|
||||
"config": _cmd_configure,
|
||||
"configure": _cmd_configure,
|
||||
"delete": _cmd_delete,
|
||||
}
|
||||
"delete": _cmd_delete}
|
||||
|
||||
|
||||
def cmd_moa(args) -> None:
|
||||
|
||||
+53
-104
@@ -13,13 +13,10 @@ DEFAULT_MOA_PRESET_NAME = "default"
|
||||
|
||||
DEFAULT_MOA_REFERENCE_MODELS: list[dict[str, str]] = [
|
||||
{"provider": "openai-codex", "model": "gpt-5.5"},
|
||||
{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"},
|
||||
]
|
||||
{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}]
|
||||
|
||||
DEFAULT_MOA_AGGREGATOR: dict[str, str] = {
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-opus-4.8",
|
||||
}
|
||||
"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}
|
||||
|
||||
DEFAULT_MOA_REFERENCE_TIMEOUT: float | None = None
|
||||
|
||||
@@ -32,8 +29,7 @@ def _coerce_number(value: Any, cast, default=None, *, positive: bool = False):
|
||||
"""Coerce ``value`` with ``cast`` (float/int); ``default`` when unset/blank/invalid.
|
||||
|
||||
``int`` also accepts float-looking strings ("3.0"). With ``positive`` the result must be > 0
|
||||
(and finite for floats) or ``default`` is returned.
|
||||
"""
|
||||
(and finite for floats) or ``default`` is returned."""
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
@@ -53,27 +49,21 @@ def _coerce_number(value: Any, cast, default=None, *, positive: bool = False):
|
||||
def _coerce_reference_timeout(value: Any) -> float | None:
|
||||
"""Finite positive advisor timeout, or None to inherit ``auxiliary.moa_reference.timeout``.
|
||||
|
||||
No artificial cap: long-thinking advisor models legitimately run far beyond five minutes.
|
||||
"""
|
||||
No artificial cap: long-thinking advisor models legitimately run far beyond five minutes."""
|
||||
if isinstance(value, bool):
|
||||
return DEFAULT_MOA_REFERENCE_TIMEOUT
|
||||
return _coerce_number(value, float, DEFAULT_MOA_REFERENCE_TIMEOUT, positive=True)
|
||||
|
||||
|
||||
def _coerce_fanout(value: Any) -> str:
|
||||
"""Normalize the fan-out cadence; unknown values fall back to default.
|
||||
|
||||
Canonical values are ``per_iteration``, ``user_turn``, and ``every_n:<N>`` (N >= 2); the
|
||||
mapping form ``{mode: every_n, n: N}`` from hand-edited YAML is normalized to the string so the
|
||||
rest of the pipeline sees one shape. ``every_n:1`` collapses to ``per_iteration``; anything
|
||||
unparseable falls back to ``user_turn`` (the cheapest cadence).
|
||||
"""
|
||||
"""Normalize the fan-out cadence to ``per_iteration`` | ``user_turn`` | ``every_n:<N>`` (N >= 2);
|
||||
the mapping form ``{mode: every_n, n: N}`` becomes the string, ``every_n:1`` collapses to
|
||||
``per_iteration``, anything unparseable falls back to ``user_turn`` (the cheapest cadence)."""
|
||||
def _every_n(n: int) -> str:
|
||||
return f"every_n:{n}" if n >= 2 else ("per_iteration" if n == 1 else "user_turn")
|
||||
|
||||
if isinstance(value, dict):
|
||||
# Mapping form: {mode: every_n, n: 3}. Non-every_n mapping modes fall
|
||||
# through to the string path below (e.g. {mode: user_turn}).
|
||||
# Non-every_n mapping modes fall through to the string path (e.g. {mode: user_turn}).
|
||||
mode = str(value.get("mode") or "").strip().lower()
|
||||
if mode == "every_n":
|
||||
return _every_n(_coerce_number(value.get("n"), int, 0))
|
||||
@@ -88,13 +78,11 @@ def _coerce_fanout(value: Any) -> str:
|
||||
|
||||
|
||||
def coerce_privacy_filter(value: Any) -> str:
|
||||
"""Normalize ``moa.privacy_filter`` to '' (off), 'display', or 'full'.
|
||||
"""Normalize ``moa.privacy_filter`` to '' (off, the default), 'display', or 'full'.
|
||||
|
||||
- ``''`` (empty string): filter off — the default. ``false``/``None``/ unknown values land here
|
||||
so a hand-edited config degrades to prior behavior (tolerant-read contract). - ``'display'``:
|
||||
redact user-visible surfaces only — the reference blocks shown in the UI and the saved MoA trace
|
||||
records.
|
||||
"""
|
||||
``false``/``None``/unknown values land on '' so a hand-edited config degrades to prior
|
||||
behavior. 'display' redacts user-visible surfaces only (reference blocks in the UI and saved
|
||||
MoA trace records)."""
|
||||
if value is True:
|
||||
return "full"
|
||||
if value is None or value is False:
|
||||
@@ -104,9 +92,8 @@ def coerce_privacy_filter(value: Any) -> str:
|
||||
|
||||
|
||||
def _clean_reasoning_effort(value: Any) -> str | None:
|
||||
"""Return a canonical per-slot reasoning effort, or None when unset/invalid."""
|
||||
"""Canonical per-slot reasoning effort, or None when unset/invalid."""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
|
||||
parsed = None if value is None or value is True else parse_reasoning_effort(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
@@ -125,12 +112,10 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
|
||||
|
||||
def _slot_problem(slot: Any) -> str | None:
|
||||
"""Return a human-readable problem for a slot ``_clean_slot`` would drop.
|
||||
"""Human-readable problem for a slot ``_clean_slot`` would drop; None when complete and valid.
|
||||
|
||||
None means the slot is complete and valid. Mirrors ``_clean_slot`` exactly so the write-boundary
|
||||
validator (``validate_moa_payload``) and the tolerant runtime normalizer can never disagree
|
||||
about what is acceptable.
|
||||
"""
|
||||
Mirrors ``_clean_slot`` exactly so the write-boundary validator (``validate_moa_payload``) and
|
||||
the tolerant runtime normalizer can never disagree about what is acceptable."""
|
||||
if not isinstance(slot, dict):
|
||||
return "must be an object with 'provider' and 'model'"
|
||||
provider = str(slot.get("provider") or "").strip()
|
||||
@@ -141,29 +126,23 @@ def _slot_problem(slot: Any) -> str | None:
|
||||
return "provider is required"
|
||||
if not model:
|
||||
return f"model is required (provider '{provider}' has no model selected)"
|
||||
# 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.
|
||||
# MoA is a virtual provider whose presets are themselves MoA runs; allowing one as a slot
|
||||
# would create a recursive MoA tree that the runtime guards only catch mid-turn.
|
||||
if provider.lower() == "moa":
|
||||
return "the Mixture of Agents provider cannot be used inside a preset (recursive MoA)"
|
||||
return None
|
||||
|
||||
|
||||
def _clean_slot(slot: Any, *, include_enabled: bool = False) -> dict[str, Any] | None:
|
||||
# Any slot ``_slot_problem`` rejects (non-dict, missing provider/model, recursive
|
||||
# ``moa`` provider) is dropped, falling back to the preset's defaults.
|
||||
# Any slot ``_slot_problem`` rejects is dropped, falling back to the preset's defaults.
|
||||
if _slot_problem(slot) is not None:
|
||||
return None
|
||||
clean: dict[str, Any] = {"provider": str(slot["provider"]).strip(), "model": str(slot["model"]).strip()}
|
||||
effort = _clean_reasoning_effort(slot.get("reasoning_effort"))
|
||||
if effort:
|
||||
clean["reasoning_effort"] = effort
|
||||
# Optional per-slot max_tokens: overrides the preset-level
|
||||
# reference_max_tokens for this specific reference model. None (the
|
||||
# default) = no cap, so existing slots are unaffected. Allows tuning
|
||||
# each advisor's output length independently — useful when one model
|
||||
# is verbose and another is terse.
|
||||
# Optional per-slot max_tokens overrides the preset-level reference_max_tokens for this
|
||||
# advisor; None (default) = no cap.
|
||||
slot_mt = _coerce_number(slot.get("max_tokens"), int, positive=True)
|
||||
if slot_mt is not None:
|
||||
clean["max_tokens"] = slot_mt
|
||||
@@ -172,17 +151,24 @@ def _clean_slot(slot: Any, *, include_enabled: bool = False) -> dict[str, Any] |
|
||||
return clean
|
||||
|
||||
|
||||
def _reference_slots(raw_refs: Any) -> list:
|
||||
"""``reference_models`` as a list: a JSON string (hand-edited config.yaml) is decoded, a single
|
||||
mapping is wrapped, and any other scalar / bad type degrades to ``[]`` instead of crashing."""
|
||||
if isinstance(raw_refs, str):
|
||||
try:
|
||||
raw_refs = json.loads(raw_refs)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
raw_refs = []
|
||||
if not isinstance(raw_refs, list):
|
||||
raw_refs = [raw_refs] if isinstance(raw_refs, dict) else []
|
||||
return raw_refs
|
||||
|
||||
|
||||
def validate_moa_payload(raw: Any) -> list[str]:
|
||||
"""Return the problems ``normalize_moa_config`` would silently paper over.
|
||||
"""Return the problems ``normalize_moa_config`` would silently paper over (empty = safe to save).
|
||||
|
||||
``normalize_moa_config`` is deliberately tolerant: at *read* time a hand-edited config must
|
||||
degrade to defaults rather than crash the agent. That same tolerance at *write* time is a
|
||||
corruption engine — a client that sends a half-filled slot gets its whole preset silently
|
||||
replaced with the hardcoded defaults (#64156).
|
||||
|
||||
Returns a list of human-readable problems; empty means safe to save.
|
||||
"""
|
||||
Read-time tolerance (a hand-edited config degrades to defaults instead of crashing) is a
|
||||
corruption engine at write time: a half-filled slot would silently replace the whole preset."""
|
||||
if not isinstance(raw, dict):
|
||||
return ["MoA config must be an object"]
|
||||
|
||||
@@ -208,7 +194,6 @@ def validate_moa_payload(raw: Any) -> list[str]:
|
||||
agg_issue = _slot_problem(preset.get("aggregator"))
|
||||
if agg_issue:
|
||||
problems.append(f"preset '{label}' aggregator: {agg_issue}")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
@@ -216,20 +201,9 @@ def _normalize_preset(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
raw_refs = raw.get("reference_models")
|
||||
# reference_models may be a JSON string (hand-edited config.yaml) or a list.
|
||||
if isinstance(raw_refs, str):
|
||||
try:
|
||||
raw_refs = json.loads(raw_refs)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
raw_refs = []
|
||||
if not isinstance(raw_refs, list):
|
||||
# A hand-edited scalar / single mapping (or a bad type) must degrade to
|
||||
# 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 = [item for item in (_clean_slot(item, include_enabled=True) for item in raw_refs) if item is not None]
|
||||
|
||||
cleaned = (_clean_slot(item, include_enabled=True) for item in _reference_slots(raw.get("reference_models")))
|
||||
refs = [item for item in cleaned if item is not None]
|
||||
policy = str(raw.get("degraded_reference_policy") or "loud").strip().lower()
|
||||
return {
|
||||
"enabled": _coerce_bool(raw.get("enabled"), True),
|
||||
"reference_models": refs or _default_reference_models(),
|
||||
@@ -239,38 +213,20 @@ def _normalize_preset(raw: Any) -> dict[str, Any]:
|
||||
"aggregator_temperature": _coerce_number(raw.get("aggregator_temperature"), float),
|
||||
"reference_timeout": _coerce_reference_timeout(raw.get("reference_timeout")),
|
||||
# Failed-advisor disclosure policy; unknown values fail loud.
|
||||
"degraded_reference_policy": policy if (policy := str(raw.get("degraded_reference_policy") or "loud").strip().lower()) in {"loud", "silent"} else "loud",
|
||||
"degraded_reference_policy": policy if policy in {"loud", "silent"} else "loud",
|
||||
"max_tokens": _coerce_number(raw.get("max_tokens"), int, 4096),
|
||||
# Optional cap on how much each reference ADVISOR may generate per turn.
|
||||
# None (default) = uncapped: advisors write full-length advice, matching
|
||||
# prior behavior so existing presets are unchanged. Set a value (e.g.
|
||||
# 600) to make advisors give concise advice — the dominant MoA latency
|
||||
# is advisor generation (turn latency correlates ~0.88 with output
|
||||
# tokens), and the aggregator only needs the gist of each advisor's
|
||||
# judgement, so capping roughly halves per-turn wall time. Does NOT cap
|
||||
# the acting aggregator (its output is the user-visible answer).
|
||||
# Per-turn cap on each reference ADVISOR (never the acting aggregator). None = uncapped;
|
||||
# advisor generation dominates MoA latency, so e.g. 600 roughly halves wall time.
|
||||
"reference_max_tokens": _coerce_number(raw.get("reference_max_tokens"), int, positive=True),
|
||||
# When the reference fan-out runs. "user_turn" (default) runs the
|
||||
# advisors ONCE per user turn (the original MoA shape, and the
|
||||
# cheapest cadence — #67199): the aggregator gets their upfront
|
||||
# plan-level advice, then acts alone for the rest of the tool loop.
|
||||
# "per_iteration" re-runs the advisors whenever the advisory view
|
||||
# changes — i.e. every tool iteration, so advice tracks live task
|
||||
# state at the cost of multiplying advisor spend by tool-loop depth.
|
||||
# "every_n:<N>" (N >= 2) is the middle ground: advisors run on the
|
||||
# first iteration of each user turn and every Nth tool iteration
|
||||
# after it; in-between iterations reuse the cached guidance from the
|
||||
# last advisor run. Also accepts the mapping form
|
||||
# {mode: every_n, n: N}, normalized to the canonical string.
|
||||
"fanout": _coerce_fanout(raw.get("fanout")),
|
||||
}
|
||||
# "user_turn" (default, cheapest): advisors run ONCE per user turn; "per_iteration": every
|
||||
# tool iteration; "every_n:<N>": first iteration of each turn and every Nth after.
|
||||
"fanout": _coerce_fanout(raw.get("fanout"))}
|
||||
|
||||
|
||||
_FLAT_PRESET_KEYS = (
|
||||
"reference_models", "aggregator", "reference_temperature", "aggregator_temperature",
|
||||
"reference_timeout", "degraded_reference_policy", "max_tokens", "reference_max_tokens",
|
||||
"fanout", "enabled",
|
||||
)
|
||||
"fanout", "enabled")
|
||||
|
||||
|
||||
def normalize_moa_config(raw: Any) -> dict[str, Any]:
|
||||
@@ -295,18 +251,14 @@ def normalize_moa_config(raw: Any) -> dict[str, Any]:
|
||||
active_name = str(raw.get("active_preset") or "").strip()
|
||||
if active_name not in presets:
|
||||
active_name = ""
|
||||
|
||||
return {
|
||||
"default_preset": default_name,
|
||||
"active_preset": active_name,
|
||||
"presets": presets,
|
||||
# Compatibility/flattened view for existing dashboard/desktop callers.
|
||||
**{key: deepcopy(presets[default_name][key]) for key in _FLAT_PRESET_KEYS},
|
||||
# MoA-level (not per-preset) toggles ride at the top level alongside
|
||||
# save_traces. privacy_filter: '' (off, default) | 'display' | 'full'
|
||||
# — see coerce_privacy_filter for the semantics of each mode.
|
||||
"privacy_filter": coerce_privacy_filter(raw.get("privacy_filter")),
|
||||
}
|
||||
# MoA-level (not per-preset) toggle; see coerce_privacy_filter for the modes.
|
||||
"privacy_filter": coerce_privacy_filter(raw.get("privacy_filter"))}
|
||||
|
||||
|
||||
def resolve_moa_preset(config: Any, name: str | None = None) -> dict[str, Any]:
|
||||
@@ -315,23 +267,20 @@ def resolve_moa_preset(config: Any, name: str | None = None) -> dict[str, Any]:
|
||||
preset = cfg["presets"].get(preset_name)
|
||||
if preset is None:
|
||||
from agent.errors import MoAPresetNotFoundError
|
||||
|
||||
available = ", ".join(cfg["presets"]) or "(none)"
|
||||
raise MoAPresetNotFoundError(
|
||||
f"MoA preset '{preset_name}' was not found. Available presets: "
|
||||
f"{available}. Run `hermes moa list`."
|
||||
)
|
||||
f"{available}. Run `hermes moa list`.")
|
||||
return deepcopy(preset)
|
||||
|
||||
|
||||
def exact_moa_preset_name(config: Any, text: str) -> str | None:
|
||||
"""Return the preset name iff ``text`` exactly matches an *enabled* preset.
|
||||
|
||||
Used by the no-explicit-provider switch path to recognize a bare ``/model <preset>``. Because
|
||||
the match is implicit it honors the per-preset ``enabled`` opt-out: a plain model switch that
|
||||
collides with a disabled preset's name must not silently pivot onto the MoA provider. Explicit
|
||||
``--provider moa`` / picker selection bypasses this, so disabled presets stay reachable.
|
||||
"""
|
||||
Used by the no-explicit-provider switch path for a bare ``/model <preset>``. Because the match
|
||||
is implicit it honors the per-preset ``enabled`` opt-out: a plain model switch that collides
|
||||
with a disabled preset's name must not silently pivot onto the MoA provider. Explicit
|
||||
``--provider moa`` / picker selection bypasses this, so disabled presets stay reachable."""
|
||||
wanted = str(text or "").strip()
|
||||
if not wanted:
|
||||
return None
|
||||
|
||||
+472
-1091
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user