MoA per-slot reasoning effort configuration support
NousResearch/hermes-agent · cluster 53932 · agent
Data as of
Nothing open on GitHub. Fix merged in #64631. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #64631. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #64631. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests all concern adding support for configuring reasoning_effort on a per-slot basis within MoA (Mixture of Agents) reference advisors and presets. The work spans feature requests and implementation across slots, presets, and reference configurations.
Open issues0
Open PRs0
Fixes on record
-
#64631 feat(moa): per-slot reasoning_effort in MoA presets
files:
agent/auxiliary_client.py,agent/moa_loop.py,hermes_cli/config.py,hermes_cli/moa_cmd.py,hermes_cli/moa_config.py,scripts/release.py,tests/agent/test_auxiliary_client.py,tests/agent/test_moa_reasoning_effort.py,tests/hermes_cli/test_moa_config.py,website/docs/user-guide/configuration.md,website/docs/user-guide/features/mixture-of-agents.mdShow the diff
-
agent/auxiliary_client.py@@ -1286,6 +1286,7 @@ def create(self, **kwargs) -> Any: 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 @@ def create(self, **kwargs) -> Any: 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.<task>.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.<task>.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, @@ -3441,6 +3443,7 @@ def _retry_same_provider_sync( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3474,6 +3477,7 @@ def _retry_same_provider_sync( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): @@ -3498,6 +3502,7 @@ async def _retry_same_provider_async( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3531,6 +3536,7 @@ async def _retry_same_provider_async( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): @@ -3650,6 +3656,7 @@ def _call_fallback_candidate_sync( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Optional[Any]: """Call one fallback candidate with stale-credential recovery. @@ -3671,7 +3678,8 @@ def _call_fallback_candidate_sync( fb_label, fb_model, messages, temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, base_url=fb_base) + extra_body=effective_extra_body, reasoning_config=reasoning_config, + base_url=fb_base) try: return _validate_llm_response( fb_client.chat.completions.create(**fb_kwargs), task) @@ -3687,6 +3695,7 @@ def _call_fallback_candidate_sync( temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( @@ -3719,6 +3728,7 @@ async def _call_fallback_candidate_async( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Optional[Any]: """Async mirror of :func:`_call_fallback_candidate_sync`.""" fb_base = str(getattr(fb_client, "base_url", "") or "") @@ -3726,7 +3736,8 @@ async def _call_fallback_candidate_async( fb_label, fb_model, messages, temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, base_url=fb_base) + extra_body=effective_extra_body, reasoning_config=reasoning_config, + base_url=fb_base) try: return _validate_llm_response( await fb_client.chat.completions.create(**fb_kwargs), task) @@ -3743,6 +3754,7 @@ async def _call_fallback_candidate_async( temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( @@ -6163,6 +6175,12 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]: chat.completions passes it through, the Codex Responses adapter maps it to top-level ``reasoning``/``include``, and the Anthropic auxiliary client maps it to ``build_anthropic_kwargs(reasoning_config=...)``. + + MoA tasks are excluded by design: reasoning depth for MoA is a per-slot + setting in the MoA preset (``moa.presets.<name>.reference_models[]. + reasoning_effort`` / ``aggregator.reasoning_effort``), not an + auxiliary-task knob — an ensemble-wide value would override the + per-slot ones. """ task_config = _get_auxiliary_task_config(task) raw = task_config.get("extra_body") @@ -6170,6 +6188,15 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]: if "reasoning" not in result: effort = task_config.get("reasoning_effort") if effort is not None and effort != "": + if task in ("moa_reference", "moa_aggregator"): + logger.warning( + "auxiliary.%s.reasoning_effort is not supported — MoA " + "reasoning depth is per-slot: set reasoning_effort on the " + "preset's reference_models entries / aggregator instead " + "(moa.presets.<name>...). Ignoring.", + task, + ) + return result from hermes_constants import parse_reasoning_effort parsed = parse_reasoning_effort(effort) if parsed is not None: @@ -6288,6 +6315,32 @@ def _convert_openai_images_to_anthropic(messages: list) -> list: return converted +_PROFILE_REASONING_KEYS = { + "reasoning", + "reasoning_effort", + "thinking", + "thinking_config", + "thinkingconfig", + "thinking_budget", + "thinkingbudget", + "enable_thinking", + "think", + "verbosity", +} + + +def _contains_profile_reasoning_fields(value: Any) -> bool: + """Return whether a profile payload contains a reasoning wire control.""" + if not isinstance(value, dict): + return False + for key, nested in value.items(): + normalized = str(key).strip().lower() + if normalized in _PROFILE_REASONING_KEYS: + return True + if _contains_profile_reasoning_fields(nested): + return True + return False + def _build_call_kwargs( provider: str, @@ -6298,6 +6351,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.""" @@ -6381,13 +6435,89 @@ def _build_call_kwargs( _deduped.append(_t) kwargs["tools"] = _deduped - # Provider-specific extra_body + # Build provider-aware reasoning kwargs through the same profile hooks used + # by the standard chat-completions transport. Some providers require + # top-level controls (Kimi/custom ``reasoning_effort``), others use nested + # body fields (Gemini ``thinking_config``), and OpenRouter/Nous use + # ``extra_body.reasoning``. Profiles are the source of truth for those wire + # shapes. Providers without a reasoning-aware profile retain the generic + # ``extra_body.reasoning`` fallback used by Codex-compatible adapters. + effective_base = base_url or ( + _current_custom_base_url() if provider == "custom" else "" + ) + profile_body: Dict[str, Any] = {} + profile_reasoning_extra: Dict[str, Any] = {} + profile_top_level: Dict[str, Any] = {} + profile_handles_reasoning = False + try: + from providers import get_provider_profile + from providers.base import ProviderProfile + + profile = get_provider_profile(str(provider or "").strip().lower()) + if profile is not None: + profile_body = profile.build_extra_body( + model=model, + base_url=effective_base, + reasoning_config=reasoning_config, + ) or {} + profile_reasoning_extra, profile_top_level = ( + profile.build_api_kwargs_extras( + reasoning_config=reasoning_config, + supports_reasoning=reasoning_config is not None, + model=model, + base_url=effective_base, + ) + ) + profile_reasoning_extra = profile_reasoning_extra or {} + profile_top_level = profile_top_level or {} + profile_handles_reasoning = ( + type(profile).build_api_kwargs_extras + is not ProviderProfile.build_api_kwargs_extras + or _contains_profile_reasoning_fields(profile_body) + or _contains_profile_reasoning_fields(profile_reasoning_extra) + or _contains_profile_reasoning_fields(profile_top_level) + ) + except Exception as exc: + logger.debug( + "_build_call_kwargs: provider profile projection failed for %s: %s", + provider, + exc, + ) + + kwargs.update(profile_top_level) merged_extra = dict(extra_body or {}) - if provider == "nous": - merged_extra.setdefault("tags", []).extend(_nous_portal_tags()) + merged_extra.update(profile_body) + merged_extra.update(profile_reasoning_extra) + if ( + reasoning_config + and isinstance(reasoning_config, dict) + and not profile_handles_reasoning + ): + 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" and "tags" not in merged_extra: + merged_extra["tags"] = _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 +6627,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 +6651,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 +6757,7 @@ def call_llm( resolved_provider, final_model, messages, temperature=temperature, max_tokens=[redacted:secret-assignment] 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) @@ -6876,6 +7010,7 @@ def call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) # ── Same-provider credential-pool recovery ───────────────────── @@ -6918,6 +7053,7 @@ def call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) except Exception as retry2_err: # The rotated key also hit a quota/auth wall. Mark it @@ -7039,7 +7175,8 @@ def call_llm( task=task, messages=messages, temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # The candidate had a stale/unrefreshable credential and was @@ -7053,7 +7190,8 @@ def call_llm( task=task, messages=messages, temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # All fallback layers exhausted — emit a single user-visible @@ -7148,6 +7286,7 @@ async def async_call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + reasoning_config: Optional[dict] = None, ) -> Any: """Centralized asynchronous LLM call. @@ -7227,6 +7366,7 @@ async def async_call_llm( resolved_provider, final_model, messages, temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=_client_base or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) @@ -7423,6 +7563,7 @@ async def async_call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) # ── Same-provider credential-pool recovery (mirrors sync) ───── @@ -7460,6 +7601,7 @@ async def async_call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) except Exception as retry2_err: if (_is_payment_error(retry2_err) or _is_auth_error(retry2_err) @@ -7548,7 +7690,8 @@ async def async_call_llm( task=task, messages=messages, temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # Stale/unrefreshable candidate credential — quarantined; walk @@ -7564,7 +7707,8 @@ async def async_call_llm( task=task, messages=messages, temperature=temperature, max_tokens=[redacted:secret-assignment] tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # All fallback layers exhausted — warn before re-raising. (#26882) -
agent/moa_loop.py@@ -120,11 +120,24 @@ def __init__( -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=[redacted:secret-assignment] + 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=[redacted:secret-assignment] + reasoning_config=_slot_reasoning_config(aggregator), **agg_runtime, ) synthesis = _extract_text(response) @@ -1074,6 +1089,7 @@ def create(self, **api_kwargs: Any) -> Any: max_tokens=[redacted:secret-assignment]"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), ) -
hermes_cli/config.py@@ -1756,7 +1756,10 @@ def _ensure_hermes_home_managed(home: Path): "api_key": "", "timeout": 900, "extra_body": {}, - "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) + # NOTE: no reasoning_effort here by design — MoA reasoning depth is + # configured PER SLOT in the MoA preset (moa.presets.<name>. + # reference_models[].reasoning_effort / aggregator.reasoning_effort), + # not at the auxiliary-task level. }, "moa_aggregator": { "provider": "auto", @@ -1765,7 +1768,7 @@ def _ensure_hermes_home_managed(home: Path): "api_key": "", "timeout": 900, "extra_body": {}, - "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) + # NOTE: no reasoning_effort here by design — see moa_reference above. }, }, -
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: -
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]: -
scripts/release.py@@ -330,6 +330,7 @@ "[redacted:email]": "synapsesx", "[redacted:email]": "itsflownium", "[redacted:email]": "dirtyren", + "[redacted:email]": "justinschille", "[redacted:email]": "unsupportedpastels", "[redacted:email]": "John-Lussier", "[redacted:email]": "bytesnail", # PR #60360 salvage (--yolo startup ordering; #60328) -
tests/agent/test_auxiliary_client.py@@ -3331,6 +3331,28 @@ def test_empty_reasoning_effort_is_noop(self): with patch("hermes_cli.config.load_config", return_value=config): assert _get_task_extra_body("session_search") == {} + @pytest.mark.parametrize("moa_task", ["moa_reference", "moa_aggregator"]) + def test_moa_tasks_reject_task_level_reasoning_effort(self, moa_task, caplog): + """MoA reasoning is per-slot in the preset — the auxiliary-task + shorthand is ignored with a warning pointing at the preset config.""" + from agent.auxiliary_client import _get_task_extra_body + + config = {"auxiliary": {moa_task: {"reasoning_effort": "xhigh"}}} + with patch("hermes_cli.config.load_config", return_value=config), \ + caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"): + result = _get_task_extra_body(moa_task) + + assert "reasoning" not in result + assert any("per-slot" in rec.message for rec in caplog.records) + + @pytest.mark.parametrize("moa_task", ["moa_reference", "moa_aggregator"]) + def test_moa_default_config_has_no_reasoning_effort(self, moa_task): + """Invariant: the shipped MoA auxiliary blocks must not grow a + reasoning_effort key — per-slot preset config is the only surface.""" + from hermes_cli.config import DEFAULT_CONFIG + + assert "reasoning_effort" not in DEFAULT_CONFIG["auxiliary"][moa_task] + def test_anthropic_aux_client_forwards_extra_body_reasoning(self): """_AnthropicCompletionsAdapter passes extra_body.reasoning into build_anthropic_kwargs as reasoning_config.""" @@ -3897,6 +3919,157 @@ def mark_exhausted_and_rotate(self, **kwargs): 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 TestAuxiliaryProviderProfileReasoning: + """Auxiliary calls must reuse provider-profile reasoning wire shapes.""" + + def test_kimi_reasoning_uses_top_level_effort(self): + kwargs = _build_call_kwargs( + "kimi-coding", + "kimi-k2-turbo-preview", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "medium"}, + base_url="https://api.moonshot.ai/v1", + ) + + assert kwargs["reasoning_effort"] == "medium" + assert "reasoning" not in kwargs.get("extra_body", {}) + assert "thinking" not in kwargs.get("extra_body", {}) + + def test_gemini_reasoning_uses_thinking_config(self): + kwargs = _build_call_kwargs( + "gemini", + "gemini-3.5-flash", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://generativelanguage.googleapis.com/v1beta", + ) + + assert kwargs["extra_body"]["thinking_config"] == { + "includeThoughts": True, + "thinkingLevel": "high", + } + assert "reasoning" not in kwargs["extra_body"] + + def test_custom_openai_compatible_reasoning_uses_top_level_effort(self): + kwargs = _build_call_kwargs( + "custom", + "glm-5.2", + [{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "max"}, + base_url="https://example.test/v1", + ) + + assert kwargs["reasoning_effort"] == "max" + assert "reasoning" not in kwargs.get("extra_body", {}) + + @pytest.mark.asyncio + async def test_async_call_llm_preserves_profile_reasoning_kwargs(self): + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))] + ) + create = AsyncMock(return_value=response) + client = SimpleNamespace( + base_url="https://api.moonshot.ai/v1", + chat=SimpleNamespace( + completions=SimpleNamespace(create=create), + ), + ) + + with patch( + "agent.auxiliary_client._resolve_task_provider_model", + return_value=( + "kimi-coding", + "kimi-k2-turbo-preview", + "https://api.moonshot.ai/v1", + "test-key", + None, + ), + ), patch( + "agent.auxiliary_client._get_cached_client", + return_value=(client, "kimi-k2-turbo-preview"), + ): + result = await async_call_llm( + provider="kimi-coding", + model="kimi-k2-turbo-preview", + messages=[{"role": "user", "content": "hi"}], + reasoning_config={"enabled": True, "effort": "high"}, + ) + + assert result is response + final_kwargs = create.call_args.kwargs + assert final_kwargs["reasoning_effort"] == "high" + assert "reasoning" not in final_kwargs.get("extra_body", {}) + + class TestCodexAdapterReasoningTranslation: """Verify _CodexCompletionsAdapter translates extra_body.reasoning into the Responses API's top-level reasoning + include fields, matching -
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} -
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"}) -
website/docs/user-guide/configuration.md@@ -980,7 +980,9 @@ Auxiliary task blocks additionally accept a `reasoning_effort` knob: |-----|-------------|---------| | `reasoning_effort` | Thinking level for that task's LLM calls: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra` | not set (provider default) | -This is the per-task counterpart of the global `agent.reasoning_effort`: run compression at `low` or vision at `none` to cut side-task latency and cost when your main model is an expensive reasoning model, without touching your main chat behavior. It works on every auxiliary task block (`vision`, `web_extract`, `compression`, `title_generation`, `curator`, `background_review`, `moa_reference`, `moa_aggregator`, ...), across all three auxiliary wire formats (chat completions, Codex Responses, Anthropic Messages). An explicit `extra_body.reasoning` on the same task wins over the shorthand. +This is the per-task counterpart of the global `agent.reasoning_effort`: run compression at `low` or vision at `none` to cut side-task latency and cost when your main model is an expensive reasoning model, without touching your main chat behavior. It works on every auxiliary task block (`vision`, `web_extract`, `compression`, `title_generation`, `curator`, `background_review`, ...), across all three auxiliary wire formats (chat completions, Codex Responses, Anthropic Messages). An explicit `extra_body.reasoning` on the same task wins over the shorthand. + +MoA is the one exception: reasoning depth for Mixture-of-Agents is configured **per slot** in the MoA preset (`moa.presets.<name>.reference_models[].reasoning_effort` / `aggregator.reasoning_effort`), not on the `moa_reference`/`moa_aggregator` auxiliary blocks — see [Mixture of Agents](/user-guide/features/mixture-of-agents). ```yaml auxiliary: -
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
-
2 PRs closed without merging
From the close comments: merged via another change 1 · other 1
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #64631
- Closed issues
- 2
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 15 Jul 2026