diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 182630fa2c4a..3d9373b823c5 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -281,7 +281,7 @@ def _maybe_apply_moa_cache_control( def _run_reference( - slot: dict[str, str], + slot: dict[str, Any], ref_messages: list[dict[str, Any]], *, temperature: float | None = None, @@ -333,11 +333,16 @@ def _run_reference( # (their caching is automatic; markers are ignored harmlessly, but we # only decorate when the policy says the route honors them). messages = _maybe_apply_moa_cache_control(messages, runtime) + # Per-slot max_tokens takes precedence over the preset-level + # reference_max_tokens passed in by the caller. This lets each + # reference model have its own output cap independently. + _slot_max_tokens: int | None = slot.get("max_tokens") + _effective_max_tokens = _slot_max_tokens if _slot_max_tokens is not None else max_tokens response = call_llm( task="moa_reference", messages=messages, temperature=temperature, - max_tokens=max_tokens, + max_tokens=_effective_max_tokens, reasoning_config=_slot_reasoning_config(slot), **runtime, ) @@ -398,7 +403,7 @@ def _run_reference( def _run_references_parallel( - reference_models: list[dict[str, str]], + reference_models: list[dict[str, Any]], ref_messages: list[dict[str, Any]], *, temperature: float | None = None, @@ -683,8 +688,8 @@ def aggregate_moa_context( *, user_prompt: str, api_messages: list[dict[str, Any]], - reference_models: list[dict[str, str]], - aggregator: dict[str, str], + reference_models: list[dict[str, Any]], + aggregator: dict[str, Any], temperature: float | None = None, aggregator_temperature: float | None = None, reference_max_tokens: int | None = None, diff --git a/contributors/emails/rain@synth.kitchen b/contributors/emails/rain@synth.kitchen new file mode 100644 index 000000000000..de1f34f8281f --- /dev/null +++ b/contributors/emails/rain@synth.kitchen @@ -0,0 +1,2 @@ +matarbot +# PR #60391 salvage diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index a5401c525892..bf40976dc2ac 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -105,6 +105,14 @@ def _clean_slot(slot: Any) -> dict[str, Any] | None: effort = _clean_reasoning_effort(slot.get("reasoning_effort")) if effort: clean["reasoning_effort"] = effort + # Optional per-slot max_tokens: overrides the preset-level + # reference_max_tokens for this specific reference model. None (the + # default) = no cap, so existing slots are unaffected. Allows tuning + # each advisor's output length independently — useful when one model + # is verbose and another is terse. + slot_mt = _coerce_int_or_none(slot.get("max_tokens")) + if slot_mt is not None: + clean["max_tokens"] = slot_mt return clean diff --git a/tests/agent/test_moa_slot_max_tokens.py b/tests/agent/test_moa_slot_max_tokens.py new file mode 100644 index 000000000000..1b3a6813c300 --- /dev/null +++ b/tests/agent/test_moa_slot_max_tokens.py @@ -0,0 +1,82 @@ +"""Tests for per-slot max_tokens in MoA reference calls. + +Verifies that a ``max_tokens`` field on a reference slot dict takes +precedence over the preset-level ``reference_max_tokens``, and that +slot-level max_tokens=None falls back to the preset-level cap. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestRunReferenceSlotMaxTokens: + """_run_reference should prefer slot-level max_tokens over preset-level.""" + + def test_slot_max_tokens_overrides_preset_level(self): + """When slot has max_tokens, it overrides the preset-level cap.""" + from agent.moa_loop import _run_reference + + captured_kwargs: dict = {} + + def fake_call_llm(**kwargs): + captured_kwargs.update(kwargs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content="advice"))] + mock_resp.usage = None + return mock_resp + + slot = {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": 600} + + with patch("agent.moa_loop._slot_runtime", return_value={"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}), \ + patch("agent.moa_loop.call_llm", side_effect=fake_call_llm), \ + patch("agent.moa_loop._maybe_apply_moa_cache_control", side_effect=lambda msgs, rt: msgs): + _run_reference(slot, [{"role": "user", "content": "hi"}], max_tokens=2000) + + assert captured_kwargs.get("max_tokens") == 600 + + def test_slot_max_tokens_absent_falls_back_to_preset(self): + """When slot has no max_tokens, the preset-level cap is used.""" + from agent.moa_loop import _run_reference + + captured_kwargs: dict = {} + + def fake_call_llm(**kwargs): + captured_kwargs.update(kwargs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content="advice"))] + mock_resp.usage = None + return mock_resp + + slot = {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"} + + with patch("agent.moa_loop._slot_runtime", return_value={"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}), \ + patch("agent.moa_loop.call_llm", side_effect=fake_call_llm), \ + patch("agent.moa_loop._maybe_apply_moa_cache_control", side_effect=lambda msgs, rt: msgs): + _run_reference(slot, [{"role": "user", "content": "hi"}], max_tokens=2000) + + assert captured_kwargs.get("max_tokens") == 2000 + + def test_both_none_means_uncapped(self): + """When neither slot nor preset has max_tokens, it's None (uncapped).""" + from agent.moa_loop import _run_reference + + captured_kwargs: dict = {} + + def fake_call_llm(**kwargs): + captured_kwargs.update(kwargs) + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content="advice"))] + mock_resp.usage = None + return mock_resp + + slot = {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"} + + with patch("agent.moa_loop._slot_runtime", return_value={"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}), \ + patch("agent.moa_loop.call_llm", side_effect=fake_call_llm), \ + patch("agent.moa_loop._maybe_apply_moa_cache_control", side_effect=lambda msgs, rt: msgs): + _run_reference(slot, [{"role": "user", "content": "hi"}], max_tokens=None) + + assert captured_kwargs.get("max_tokens") is None diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index e6b4714e2256..88c7896f1556 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -461,3 +461,79 @@ def test_validate_moa_payload_rejects_non_dict(): assert validate_moa_payload(None) assert validate_moa_payload([1, 2]) assert validate_moa_payload({"presets": {"p": "not-a-dict"}}) + + +# ── Per-slot max_tokens ──────────────────────────────────────────────────── + + +def test_slot_max_tokens_preserved(): + """A max_tokens field on a reference slot survives normalization.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": 600}, + {"provider": "openai-codex", "model": "gpt-5.5"}, + ], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + } + } + } + ) + refs = cfg["presets"]["p"]["reference_models"] + assert refs[0] == {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": 600} + assert refs[1] == {"provider": "openai-codex", "model": "gpt-5.5"} + + +def test_slot_max_tokens_coerced_from_string(): + """Hand-edited YAML string '600' coerces to int on a slot.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": "600"}, + ], + } + } + } + ) + refs = cfg["presets"]["p"]["reference_models"] + assert refs[0]["max_tokens"] == 600 + + +def test_slot_max_tokens_invalid_dropped(): + """Non-positive / non-numeric slot max_tokens is dropped (slot kept).""" + for bad in (0, -5, "abc", "", None): + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "max_tokens": bad}, + ], + } + } + } + ) + ref = cfg["presets"]["p"]["reference_models"][0] + assert "max_tokens" not in ref, bad + assert ref["provider"] == "openrouter" + + +def test_slot_max_tokens_absent_by_default(): + """Slots without max_tokens don't get the field — backward compat.""" + cfg = normalize_moa_config( + { + "presets": { + "p": { + "reference_models": [ + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, + ], + } + } + } + ) + ref = cfg["presets"]["p"]["reference_models"][0] + assert "max_tokens" not in ref