feat(moa): support per-slot reasoning effort

This commit is contained in:
Justin Schille 2026-07-09 17:24:42 -06:00 committed by Teknium
parent 6997dc81cd
commit 3dca75b45c
8 changed files with 279 additions and 16 deletions

View file

@ -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.<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,
@ -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)

View file

@ -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),
)

View file

@ -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:

View file

@ -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]:

View file

@ -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

View file

@ -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}

View file

@ -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"})

View file

@ -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