mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
feat(moa): support per-slot reasoning effort
This commit is contained in:
parent
6997dc81cd
commit
3dca75b45c
8 changed files with 279 additions and 16 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
tests/agent/test_moa_reasoning_effort.py
Normal file
72
tests/agent/test_moa_reasoning_effort.py
Normal 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}
|
||||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue