mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
b34e565957
commit
0a940972f4
2 changed files with 122 additions and 2 deletions
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue