From 3dca75b45c2edf38089ef07c22023afe357aebc1 Mon Sep 17 00:00:00 2001 From: Justin Schille <217401759+justinschille@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:24:42 -0600 Subject: [PATCH] feat(moa): support per-slot reasoning effort --- agent/auxiliary_client.py | 46 +++++++++--- agent/moa_loop.py | 22 +++++- hermes_cli/moa_cmd.py | 10 ++- hermes_cli/moa_config.py | 24 ++++++- tests/agent/test_auxiliary_client.py | 69 ++++++++++++++++++ tests/agent/test_moa_reasoning_effort.py | 72 +++++++++++++++++++ tests/hermes_cli/test_moa_config.py | 23 ++++++ .../user-guide/features/mixture-of-agents.md | 29 ++++++++ 8 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 tests/agent/test_moa_reasoning_effort.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e2fc920d9a39..0a02073f2547 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1286,6 +1286,7 @@ class _AnthropicCompletionsAdapter: model = kwargs.get("model", self._model) tools = kwargs.get("tools") tool_choice = kwargs.get("tool_choice") + reasoning_config = kwargs.get("_reasoning_config") # ZAI's Anthropic-compatible endpoint rejects max_tokens on vision # models (glm-4v-flash etc.) with error code 1210. When the caller # signals this by setting _skip_zai_max_tokens in kwargs, omit it. @@ -1306,17 +1307,18 @@ class _AnthropicCompletionsAdapter: elif choice_type in {"auto", "required", "none"}: normalized_tool_choice = choice_type - # Honor extra_body.reasoning (chat.completions shape) so auxiliary - # tasks configured with auxiliary..reasoning_effort (or an - # explicit extra_body.reasoning) control Anthropic thinking too — + # Reasoning priority: explicit per-call reasoning_config (MoA per-slot, + # passed as _reasoning_config by _build_call_kwargs) wins over an + # extra_body.reasoning dict (auxiliary..extra_body config). # build_anthropic_kwargs translates the config dict into the native # ``thinking`` field and handles models where thinking is mandatory. - _reasoning_cfg = None - _eb = kwargs.get("extra_body") - if isinstance(_eb, dict): - _rc = _eb.get("reasoning") - if isinstance(_rc, dict): - _reasoning_cfg = _rc + _reasoning_cfg = reasoning_config + if _reasoning_cfg is None: + _eb = kwargs.get("extra_body") + if isinstance(_eb, dict): + _rc = _eb.get("reasoning") + if isinstance(_rc, dict): + _reasoning_cfg = _rc anthropic_kwargs = build_anthropic_kwargs( model=model, @@ -6298,6 +6300,7 @@ def _build_call_kwargs( tools: Optional[list] = None, timeout: float = 30.0, extra_body: Optional[dict] = None, + reasoning_config: Optional[dict] = None, base_url: Optional[str] = None, ) -> dict: """Build kwargs for .chat.completions.create() with model/provider adjustments.""" @@ -6383,11 +6386,32 @@ def _build_call_kwargs( # Provider-specific extra_body merged_extra = dict(extra_body or {}) + if reasoning_config and isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: + merged_extra["reasoning"] = {"enabled": False} + else: + effort = reasoning_config.get("effort") or "medium" + merged_extra["reasoning"] = {"enabled": True, "effort": effort} if provider == "nous": merged_extra.setdefault("tags", []).extend(_nous_portal_tags()) if merged_extra: kwargs["extra_body"] = merged_extra + # Native Anthropic Messages adapters do not consume ``extra_body``. Carry + # the normalized Hermes reasoning config through a private kwarg so the + # adapter can pass it into build_anthropic_kwargs(), where provider-aware + # thinking/output_config projection lives. Do not expose this private kwarg + # to ordinary OpenAI-compatible SDK clients, which would reject it. + if reasoning_config and isinstance(reasoning_config, dict): + provider_norm = str(provider or "").strip().lower() + effective_base = base_url or "" + if ( + provider_norm == "anthropic" + or _endpoint_speaks_anthropic_messages(effective_base) + or _is_anthropic_compat_endpoint(provider_norm, effective_base) + ): + kwargs["_reasoning_config"] = dict(reasoning_config) + return kwargs @@ -6497,6 +6521,7 @@ def call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + reasoning_config: Optional[dict] = None, api_mode: str = None, stream: bool = False, stream_options: dict = None, @@ -6520,6 +6545,8 @@ def call_llm( tools: Tool definitions (for function calling). timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config). extra_body: Additional request body fields. + reasoning_config: Optional Hermes reasoning config for direct model calls + such as MoA reference/aggregator slots. stream: When True, return the raw SDK streaming iterator instead of a validated complete response. The caller is responsible for consuming chunks (and for any fallback). Used by the MoA aggregator so its @@ -6624,6 +6651,7 @@ def call_llm( resolved_provider, final_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=_base_info or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 28707b2a4f14..314aaa8b0295 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -120,11 +120,24 @@ _REFERENCE_SYSTEM_PROMPT = ( -def _slot_label(slot: dict[str, str]) -> str: - return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}" +def _slot_label(slot: dict[str, Any]) -> str: + label = f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}" + effort = str(slot.get("reasoning_effort") or "").strip() + return f"{label}[reasoning={effort}]" if effort else label -def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]: +def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None: + """Translate optional per-MoA-slot reasoning_effort into runtime config.""" + effort = slot.get("reasoning_effort") + try: + from hermes_constants import parse_reasoning_effort + + return parse_reasoning_effort(effort) + 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. A MoA slot is just a model selection — it must be called the same way any @@ -276,6 +289,7 @@ def _run_reference( messages=messages, temperature=temperature, max_tokens=max_tokens, + reasoning_config=_slot_reasoning_config(slot), **runtime, ) usage = CanonicalUsage() @@ -680,6 +694,7 @@ def aggregate_moa_context( messages=agg_messages, temperature=aggregator_temperature, max_tokens=max_tokens, + reasoning_config=_slot_reasoning_config(aggregator), **agg_runtime, ) synthesis = _extract_text(response) @@ -1074,6 +1089,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), **stream_kwargs, **_slot_runtime(aggregator), ) diff --git a/hermes_cli/moa_cmd.py b/hermes_cli/moa_cmd.py index 938d6baeacbf..417ce23a11cd 100644 --- a/hermes_cli/moa_cmd.py +++ b/hermes_cli/moa_cmd.py @@ -60,6 +60,12 @@ def _pick_slot(current: dict[str, str] | None = None) -> dict[str, str]: return {"provider": str(provider.get("slug") or ""), "model": str(model)} +def _format_slot(slot: dict[str, Any]) -> str: + label = f"{slot['provider']}:{slot['model']}" + effort = str(slot.get("reasoning_effort") or "").strip() + return f"{label} [reasoning={effort}]" if effort else label + + def _print_config(config: dict[str, Any]) -> None: cfg = normalize_moa_config(config.get("moa") if isinstance(config, dict) else {}) print("Mixture of Agents presets") @@ -71,9 +77,9 @@ def _print_config(config: dict[str, Any]) -> None: print(f"\n{marker} {name}") print(" Reference models:") for idx, slot in enumerate(preset["reference_models"], start=1): - print(f" {idx}. {slot['provider']}:{slot['model']}") + print(f" {idx}. {_format_slot(slot)}") agg = preset["aggregator"] - print(f" Aggregator: {agg['provider']}:{agg['model']}") + print(f" Aggregator: {_format_slot(agg)}") def cmd_moa(args) -> None: diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index af1241418bed..242b45f59e7b 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -73,7 +73,23 @@ def _coerce_fanout(value: Any) -> str: return mode if mode in {"per_iteration", "user_turn"} else "per_iteration" -def _clean_slot(slot: Any) -> dict[str, str] | None: +def _clean_reasoning_effort(value: Any) -> str | None: + """Return a canonical per-slot reasoning effort, or None when unset/invalid.""" + if value is None or value is True: + return None + if value is False: + return "none" + text = str(value or "").strip().lower() + if not text: + return None + if text in {"none", "false", "disabled"}: + return "none" + if text in {"minimal", "low", "medium", "high", "xhigh", "max"}: + return text + return None + + +def _clean_slot(slot: Any) -> dict[str, Any] | None: if not isinstance(slot, dict): return None provider = str(slot.get("provider") or "").strip() @@ -87,7 +103,11 @@ def _clean_slot(slot: Any) -> dict[str, str] | None: # an invalid slot is dropped, falling back to the preset's defaults. if provider.lower() == "moa": return None - return {"provider": provider, "model": model} + clean: dict[str, Any] = {"provider": provider, "model": model} + effort = _clean_reasoning_effort(slot.get("reasoning_effort")) + if effort: + clean["reasoning_effort"] = effort + return clean def _default_preset() -> dict[str, Any]: diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 2a88d0f6b39e..11cad1f1b3ec 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -3897,6 +3897,75 @@ class TestAuxiliaryPoolRotationRetry: mock_fallback.assert_not_called() +class TestAnthropicAuxiliaryReasoningTranslation: + """Native Anthropic aux adapters must receive normalized Hermes reasoning. + + MoA slot reasoning is carried through call_llm as a Hermes + ``reasoning_config``. The native Anthropic Messages path cannot consume the + generic OpenAI-style ``extra_body.reasoning`` fallback, so assert the final + ``messages.create`` kwargs contain Anthropic's provider-aware wire shape. + """ + + @staticmethod + def _build_adapter(model="claude-fable-5"): + from agent.auxiliary_client import _AnthropicCompletionsAdapter + + captured = {} + + class _Messages: + def create(self, **kwargs): + captured.update(kwargs) + return SimpleNamespace( + content=[SimpleNamespace(type="text", text="ok")], + stop_reason="end_turn", + usage=SimpleNamespace(input_tokens=1, output_tokens=1, total_tokens=2), + ) + + real_client = SimpleNamespace(messages=_Messages()) + return _AnthropicCompletionsAdapter(real_client, model), captured + + def test_reasoning_config_reaches_native_anthropic_wire_kwargs(self): + adapter, captured = self._build_adapter() + + adapter.create( + model="claude-fable-5", + messages=[{"role": "user", "content": "hi"}], + _reasoning_config={"enabled": True, "effort": "medium"}, + ) + + assert captured["thinking"] == {"type": "adaptive", "display": "summarized"} + assert captured["output_config"] == {"effort": "medium"} + assert "extra_body" not in captured + + def test_build_call_kwargs_private_reasoning_only_for_anthropic_messages(self): + anthropic_kwargs = _build_call_kwargs( + "anthropic", + "claude-fable-5", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "medium"}, + base_url="https://api.anthropic.com/v1", + ) + assert anthropic_kwargs["_reasoning_config"] == {"enabled": True, "effort": "medium"} + + proxy_kwargs = _build_call_kwargs( + "custom", + "claude-fable-5", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "medium"}, + base_url="https://example.test/anthropic/v1", + ) + assert proxy_kwargs["_reasoning_config"] == {"enabled": True, "effort": "medium"} + + openai_wire_kwargs = _build_call_kwargs( + "custom", + "gpt-compatible", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "medium"}, + base_url="https://example.test/v1", + ) + assert "_reasoning_config" not in openai_wire_kwargs + + class TestCodexAdapterReasoningTranslation: """Verify _CodexCompletionsAdapter translates extra_body.reasoning into the Responses API's top-level reasoning + include fields, matching diff --git a/tests/agent/test_moa_reasoning_effort.py b/tests/agent/test_moa_reasoning_effort.py new file mode 100644 index 000000000000..0aa09f8b4cd3 --- /dev/null +++ b/tests/agent/test_moa_reasoning_effort.py @@ -0,0 +1,72 @@ +from types import SimpleNamespace +from unittest.mock import patch + + + +def _response(content="ok"): + message = SimpleNamespace(content=content, tool_calls=[]) + choice = SimpleNamespace(message=message, finish_reason="stop") + return SimpleNamespace(choices=[choice], usage=None, model="fake") + + + +def test_slot_label_includes_reasoning_effort(): + from agent.moa_loop import _slot_label + + assert _slot_label( + {"provider": "openai-codex", "model": "gpt-5.6-sol", "reasoning_effort": "xhigh"} + ) == "openai-codex:gpt-5.6-sol[reasoning=xhigh]" + + + +def test_slot_reasoning_config_parses_effort_and_none(): + from agent.moa_loop import _slot_reasoning_config + + assert _slot_reasoning_config({"reasoning_effort": "high"}) == { + "enabled": True, + "effort": "high", + } + assert _slot_reasoning_config({"reasoning_effort": "none"}) == {"enabled": False} + assert _slot_reasoning_config({}) is None + + + +def test_moa_reference_passes_per_slot_reasoning_config(monkeypatch): + from agent.moa_loop import _run_reference + + captured = {} + + def fake_call_llm(**kwargs): + captured.update(kwargs) + return _response("advice") + + monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm) + with patch("hermes_cli.runtime_provider.resolve_runtime_provider") as mock_resolve: + mock_resolve.return_value = {"provider": "openai-codex", "model": "gpt-5.6-sol"} + _run_reference( + {"provider": "openai-codex", "model": "gpt-5.6-sol", "reasoning_effort": "low"}, + [{"role": "user", "content": "judge this"}], + ) + + assert captured["reasoning_config"] == {"enabled": True, "effort": "low"} + + + +def test_call_llm_builder_translates_reasoning_config_to_extra_body(): + from agent.auxiliary_client import _build_call_kwargs + + kwargs = _build_call_kwargs( + "openai-codex", + "gpt-5.6-sol", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "xhigh"}, + ) + assert kwargs["extra_body"]["reasoning"] == {"enabled": True, "effort": "xhigh"} + + off = _build_call_kwargs( + "openai-codex", + "gpt-5.6-sol", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": False}, + ) + assert off["extra_body"]["reasoning"] == {"enabled": False} diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index eba42dc825a7..7b5dde2e8f62 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -97,6 +97,29 @@ def test_normalize_moa_config_wraps_bare_dict_reference_models(): assert cfg["presets"]["p"]["reference_models"] == [{"provider": "openai", "model": "gpt-4o"}] +def test_normalize_moa_config_preserves_slot_reasoning_effort(): + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openai-codex", "model": "gpt-5.6-sol", "reasoning_effort": "LOW"}, + {"provider": "openai-codex", "model": "gpt-5.6-sol", "reasoning_effort": False}, + {"provider": "openai-codex", "model": "gpt-5.6-sol", "reasoning_effort": "nonsense"}, + ], + "aggregator": {"provider": "openai-codex", "model": "gpt-5.6-sol", "reasoning_effort": "xhigh"}, + } + } + } + ) + + preset = cfg["presets"]["p"] + assert preset["reference_models"][0]["reasoning_effort"] == "low" + assert preset["reference_models"][1]["reasoning_effort"] == "none" + assert "reasoning_effort" not in preset["reference_models"][2] + assert preset["aggregator"]["reasoning_effort"] == "xhigh" + + def test_normalize_moa_config_coerces_numeric_strings(): """Valid numeric strings (e.g. from YAML round-trip) must coerce correctly.""" cfg = normalize_moa_config({"max_tokens": "8192", "reference_temperature": "0.9"}) diff --git a/website/docs/user-guide/features/mixture-of-agents.md b/website/docs/user-guide/features/mixture-of-agents.md index 74566b19f9cf..b6affbe9c580 100644 --- a/website/docs/user-guide/features/mixture-of-agents.md +++ b/website/docs/user-guide/features/mixture-of-agents.md @@ -132,6 +132,35 @@ moa: Leave it unset (or `0`/blank) to keep the prior uncapped behavior. +### Per-slot reasoning effort + +Reference and aggregator slots may also set `reasoning_effort`. Use this when +you want the same model to contribute at different depths, or when the +aggregator should think harder than the advisory references. Valid values match +Hermes' normal reasoning controls: `none`, `minimal`, `low`, `medium`, `high`, +`xhigh`, and `max`. + +```yaml +moa: + presets: + deep_review: + reference_models: + - provider: openai-codex + model: gpt-5.6-sol + reasoning_effort: low + - provider: openai-codex + model: gpt-5.6-sol + reasoning_effort: xhigh + - provider: xai-oauth + model: grok-4.5 + aggregator: + provider: openai-codex + model: gpt-5.6-sol + reasoning_effort: high +``` + +Omit `reasoning_effort` to use the provider/Hermes default for that slot. + ## Terminal preset management ```bash