From 372f8195c7f68488fafedf2a57e61188cea380da Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:22:49 -0700 Subject: [PATCH] =?UTF-8?q?fix(moa):=20default=20temperatures=20to=20unset?= =?UTF-8?q?=20=E2=80=94=20provider=20default,=20like=20single-model=20agen?= =?UTF-8?q?ts=20(#57440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-model Hermes agent never sends temperature; the provider default applies. MoA hardcoded reference_temperature=0.6 / aggregator_temperature=0.4, and the coercion float(preset.get(key, 0.6) or 0.6) made unset IMPOSSIBLE to express: absent, null, empty, and even an explicit 0 all collapsed to the baked-in default. Every MoA advisor and aggregator therefore ran at 0.6/0.4 while the same model running solo used the provider default — silently skewing solo-vs-MoA comparisons and overriding provider-tuned defaults. - moa_config normalization: temperatures coerce to None when absent/blank/ invalid (new _coerce_float_or_none); explicit values incl. 0 honored. - moa_loop: _preset_temperature() resolves preset values; None flows to call_llm, which already omits the parameter when None (same contract as max_tokens). Aggregator still inherits the acting agent's own configured temperature when the preset doesn't pin one. - conversation_loop (context-mode MoA): same resolution, no more hardcoded 0.6/0.4 at the call site. - DEFAULT_CONFIG preset + web_server payload models + docs updated: unset is the default, pinning stays available. --- agent/auxiliary_client.py | 4 +- agent/conversation_loop.py | 6 +-- agent/moa_loop.py | 41 +++++++++++++++++-- hermes_cli/config.py | 2 - hermes_cli/moa_config.py | 23 +++++++---- hermes_cli/web_server.py | 10 +++-- tests/hermes_cli/test_moa_config.py | 7 +++- .../user-guide/features/mixture-of-agents.md | 7 +++- 8 files changed, 74 insertions(+), 26 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d92253a8c72..64768963ec5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -5902,7 +5902,7 @@ def call_llm( api_key: str = None, main_runtime: Optional[Dict[str, Any]] = None, messages: list, - temperature: float = None, + temperature: Optional[float] = None, max_tokens: int = None, tools: list = None, timeout: float = None, @@ -6533,7 +6533,7 @@ async def async_call_llm( api_key: str = None, main_runtime: Optional[Dict[str, Any]] = None, messages: list, - temperature: float = None, + temperature: Optional[float] = None, max_tokens: int = None, tools: list = None, timeout: float = None, diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 011d6cc977d..629667cee93 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -847,15 +847,15 @@ def run_conversation( if moa_config: try: - from agent.moa_loop import aggregate_moa_context + from agent.moa_loop import _preset_temperature, aggregate_moa_context _moa_context = aggregate_moa_context( user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message), api_messages=api_messages, reference_models=moa_config.get("reference_models") or [], aggregator=moa_config.get("aggregator") or {}, - temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6), - aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4), + temperature=_preset_temperature(moa_config, "reference_temperature"), + aggregator_temperature=_preset_temperature(moa_config, "aggregator_temperature"), max_tokens=moa_config.get("reference_max_tokens"), ) if _moa_context: diff --git a/agent/moa_loop.py b/agent/moa_loop.py index d487e13e57e..baca77548be 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -490,14 +490,35 @@ def _extract_text(response: Any) -> str: return "" +def _preset_temperature(preset: dict[str, Any], key: str) -> float | None: + """Read an optional temperature from a preset. + + Returns None when the key is absent, empty, or explicitly null — meaning + "don't send temperature; let the provider default apply", exactly like a + single-model Hermes agent (which never sends temperature unless + configured). The old coercion ``float(preset.get(key, 0.6) or 0.6)`` + made unset impossible: absent, null, and even 0 all collapsed to the + hardcoded default, so MoA advisors/aggregator always ran at 0.6/0.4 + while the same model running solo used the provider default. + """ + value = preset.get(key) + if value is None or (isinstance(value, str) and not value.strip()): + return None + try: + return float(value) + except (TypeError, ValueError): + logger.warning("ignoring non-numeric %s=%r in MoA preset", key, value) + return None + + def aggregate_moa_context( *, user_prompt: str, api_messages: list[dict[str, Any]], reference_models: list[dict[str, str]], aggregator: dict[str, str], - temperature: float = 0.6, - aggregator_temperature: float = 0.4, + temperature: float | None = None, + aggregator_temperature: float | None = None, max_tokens: int | None = None, ) -> str: """Run configured reference models and synthesize their advice. @@ -510,6 +531,11 @@ def aggregate_moa_context( the parameter entirely when it is ``None`` (see its docstring), which also sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap here previously truncated long aggregator syntheses. + + ``temperature`` / ``aggregator_temperature`` are ``None`` by default: + like max_tokens, ``call_llm`` omits temperature when None so the + provider default applies — matching single-model agent behavior. Presets + may still pin explicit values. """ reference_outputs: list[tuple[str, str, Any]] = [] ref_messages = _reference_messages(api_messages) @@ -726,8 +752,15 @@ class MoAChatCompletions: # The acting aggregator is never capped here (its output is the # user-visible answer). reference_max_tokens = preset.get("reference_max_tokens") - temperature = float(preset.get("reference_temperature", 0.6) or 0.6) - aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4) + # None (the default) = don't send temperature; provider default + # applies, matching single-model agent behavior. Presets may pin + # explicit values. See _preset_temperature. + temperature = _preset_temperature(preset, "reference_temperature") + aggregator_temperature = _preset_temperature(preset, "aggregator_temperature") + if aggregator_temperature is None and api_kwargs.get("temperature") is not None: + # The acting agent's own configured temperature (if any) still + # applies to the aggregator, which IS the acting model. + aggregator_temperature = api_kwargs.get("temperature") # When the preset is disabled, skip the reference fan-out and let the # configured aggregator act alone — it is the preset's acting model, so diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 35ad3817220..797f4d0977e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2190,8 +2190,6 @@ DEFAULT_CONFIG = { {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, ], "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, - "reference_temperature": 0.6, - "aggregator_temperature": 0.4, "max_tokens": 4096, "enabled": True, } diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index db644e5f75f..7f88f8ac474 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -21,13 +21,20 @@ DEFAULT_MOA_AGGREGATOR: dict[str, str] = { } -def _coerce_float(value: Any, default: float) -> float: +def _coerce_float_or_none(value: Any) -> float | None: + """Coerce to a float, or None when unset/blank/invalid. + + Used for optional sampling params (reference_temperature / + aggregator_temperature) where None means 'don't send the parameter — + provider default applies', matching how a single-model Hermes agent + never sends temperature unless explicitly configured. + """ if value is None or value == "": - return default + return None try: return float(value) except (TypeError, ValueError): - return default + return None def _coerce_int(value: Any, default: int) -> int: @@ -81,8 +88,10 @@ def _default_preset() -> dict[str, Any]: return { "reference_models": deepcopy(DEFAULT_MOA_REFERENCE_MODELS), "aggregator": deepcopy(DEFAULT_MOA_AGGREGATOR), - "reference_temperature": 0.6, - "aggregator_temperature": 0.4, + # None = temperature omitted from API calls (provider default), + # matching single-model agent behavior. + "reference_temperature": None, + "aggregator_temperature": None, "max_tokens": 4096, "reference_max_tokens": None, "enabled": True, @@ -110,8 +119,8 @@ def _normalize_preset(raw: Any) -> dict[str, Any]: "enabled": bool(raw.get("enabled", True)), "reference_models": refs, "aggregator": aggregator, - "reference_temperature": _coerce_float(raw.get("reference_temperature"), 0.6), - "aggregator_temperature": _coerce_float(raw.get("aggregator_temperature"), 0.4), + "reference_temperature": _coerce_float_or_none(raw.get("reference_temperature")), + "aggregator_temperature": _coerce_float_or_none(raw.get("aggregator_temperature")), "max_tokens": _coerce_int(raw.get("max_tokens"), 4096), # Optional cap on how much each reference ADVISOR may generate per turn. # None (default) = uncapped: advisors write full-length advice, matching diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4c7687f5281..6060e8525d1 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -940,8 +940,10 @@ class MoaModelSlot(BaseModel): class MoaPresetPayload(BaseModel): reference_models: list[MoaModelSlot] = [] aggregator: MoaModelSlot = MoaModelSlot() - reference_temperature: float = 0.6 - aggregator_temperature: float = 0.4 + # None = temperature omitted from API calls (provider default), matching + # single-model agent behavior. + reference_temperature: Optional[float] = None + aggregator_temperature: Optional[float] = None max_tokens: int = 4096 enabled: bool = True @@ -954,8 +956,8 @@ class MoaConfigPayload(BaseModel): # clients during this PR's transition window. reference_models: list[MoaModelSlot] = [] aggregator: MoaModelSlot = MoaModelSlot() - reference_temperature: float = 0.6 - aggregator_temperature: float = 0.4 + reference_temperature: Optional[float] = None + aggregator_temperature: Optional[float] = None max_tokens: int = 4096 enabled: bool = True profile: Optional[str] = None diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index accdfc95b91..eba42dc825a 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -72,8 +72,11 @@ def test_normalize_moa_config_tolerates_non_numeric_values(): preset = cfg["presets"]["broken"] assert preset["max_tokens"] == 4096 - assert preset["reference_temperature"] == 0.6 - assert preset["aggregator_temperature"] == 0.4 + # Unparseable/blank temperatures degrade to None = "don't send the + # parameter; provider default applies" (matching single-model behavior), + # not to a hardcoded sampling value. + assert preset["reference_temperature"] is None + assert preset["aggregator_temperature"] is None def test_normalize_moa_config_tolerates_non_list_reference_models(): diff --git a/website/docs/user-guide/features/mixture-of-agents.md b/website/docs/user-guide/features/mixture-of-agents.md index 88ec36d9e9c..74566b19f9c 100644 --- a/website/docs/user-guide/features/mixture-of-agents.md +++ b/website/docs/user-guide/features/mixture-of-agents.md @@ -85,8 +85,11 @@ moa: aggregator: provider: openrouter model: anthropic/claude-opus-4.8 - reference_temperature: 0.6 - aggregator_temperature: 0.4 + # Optional: pin sampling temperatures. When omitted (the default), + # temperature is NOT sent and each model uses its provider default — + # the same behavior as a single-model Hermes agent. + # reference_temperature: 0.6 + # aggregator_temperature: 0.4 max_tokens: 4096 enabled: true ```