fix(moa): aggregator resolves reasoning like an acting model when slot is unset (#64756)

The aggregator is MoA's acting model, but the main loop's reasoning
gates key off the virtual moa://local identity and never fire — so with
no per-slot reasoning_effort the aggregator silently ran at the backend
default, ignoring the user's reasoning config entirely (#64187).

New _aggregator_reasoning_config(): slot value > full acting-model
resolution via the shared chokepoint (agent.reasoning_overrides for the
slot's model > global agent.reasoning_effort; YAML False stays
'disabled'). Applied to both aggregator call sites (acting turn +
one-shot /moa synthesis).

Reference advisors intentionally keep slot-or-default: inheriting a
global xhigh into every advisor fan-out would silently multiply cost.

Fixes #64187.
This commit is contained in:
Teknium 2026-07-15 00:11:04 -07:00 committed by GitHub
parent b34e565957
commit 0a940972f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 122 additions and 2 deletions

View file

@ -137,6 +137,36 @@ def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None:
return None
def _aggregator_reasoning_config(aggregator: dict[str, Any]) -> dict[str, Any] | None:
"""Resolve the aggregator's reasoning config: slot > per-model > global.
The aggregator is MoA's ACTING model, so when its slot doesn't pin a
reasoning_effort it must resolve exactly like any other acting model:
through the shared chokepoint (``resolve_reasoning_config``), which
applies ``agent.reasoning_overrides`` for the slot's model first, then
the global ``agent.reasoning_effort``. Without this the main loop's
reasoning gates (keyed to the virtual ``moa://local`` identity) never
fire, so the aggregator silently ran at the backend default (#64187).
Reference advisors intentionally do NOT get this fallback: they are side
calls (like auxiliary tasks), and inheriting a global ``xhigh`` into every
advisor fan-out would silently multiply cost. Their depth is slot-or-
provider-default only.
"""
cfg = _slot_reasoning_config(aggregator)
if cfg is not None:
return cfg
try:
from hermes_cli.config import load_config
from hermes_constants import resolve_reasoning_config
return resolve_reasoning_config(
load_config() or {}, str(aggregator.get("model") or "")
)
except Exception: # pragma: no cover - defensive; bad config must not break MoA
return None
def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
"""Resolve a reference/aggregator slot to real runtime call kwargs.
@ -694,7 +724,7 @@ def aggregate_moa_context(
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
reasoning_config=_slot_reasoning_config(aggregator),
reasoning_config=_aggregator_reasoning_config(aggregator),
**agg_runtime,
)
synthesis = _extract_text(response)
@ -1089,7 +1119,7 @@ class MoAChatCompletions:
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
reasoning_config=_slot_reasoning_config(aggregator),
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)

View file

@ -70,3 +70,93 @@ def test_call_llm_builder_translates_reasoning_config_to_extra_body():
reasoning_config={"enabled": False},
)
assert off["extra_body"]["reasoning"] == {"enabled": False}
class TestAggregatorGlobalFallback:
"""#64187: the aggregator (MoA's acting model) resolves like any acting
model when its slot has no reasoning_effort: per-model override
(agent.reasoning_overrides for the slot's model) > global
agent.reasoning_effort. Reference advisors do NOT get this fallback
(side calls cost containment)."""
def test_slot_value_wins_over_global(self, monkeypatch):
from agent import moa_loop
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"agent": {"reasoning_effort": "medium"}},
)
cfg = moa_loop._aggregator_reasoning_config({"reasoning_effort": "xhigh"})
assert cfg == {"enabled": True, "effort": "xhigh"}
def test_unset_slot_falls_back_to_global(self, monkeypatch):
from agent import moa_loop
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"agent": {"reasoning_effort": "high"}},
)
cfg = moa_loop._aggregator_reasoning_config({"provider": "openrouter", "model": "m"})
assert cfg == {"enabled": True, "effort": "high"}
def test_unset_slot_honors_per_model_override(self, monkeypatch):
"""The aggregator's model gets its agent.reasoning_overrides entry —
same resolution as any acting model, not just the bare global."""
from agent import moa_loop
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"agent": {
"reasoning_effort": "medium",
"reasoning_overrides": {"claude-opus-4.8": "xhigh"},
}
},
)
cfg = moa_loop._aggregator_reasoning_config(
{"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}
)
assert cfg == {"enabled": True, "effort": "xhigh"}
def test_slot_value_beats_per_model_override(self, monkeypatch):
from agent import moa_loop
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"agent": {
"reasoning_effort": "medium",
"reasoning_overrides": {"claude-opus-4.8": "xhigh"},
}
},
)
cfg = moa_loop._aggregator_reasoning_config(
{"model": "anthropic/claude-opus-4.8", "reasoning_effort": "low"}
)
assert cfg == {"enabled": True, "effort": "low"}
def test_global_yaml_false_disables(self, monkeypatch):
from agent import moa_loop
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"agent": {"reasoning_effort": False}},
)
cfg = moa_loop._aggregator_reasoning_config({})
assert cfg == {"enabled": False}
def test_no_slot_no_global_returns_none(self, monkeypatch):
from agent import moa_loop
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
assert moa_loop._aggregator_reasoning_config({}) is None
def test_reference_slots_do_not_inherit_global(self, monkeypatch):
"""Advisors stay slot-or-default: global effort must NOT leak in."""
from agent import moa_loop
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"agent": {"reasoning_effort": "xhigh"}},
)
assert moa_loop._slot_reasoning_config({"provider": "p", "model": "m"}) is None