diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 314aaa8b0295..43691fd7f997 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -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), ) diff --git a/tests/agent/test_moa_reasoning_effort.py b/tests/agent/test_moa_reasoning_effort.py index 0aa09f8b4cd3..e2ba25c4f404 100644 --- a/tests/agent/test_moa_reasoning_effort.py +++ b/tests/agent/test_moa_reasoning_effort.py @@ -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