fix(moa): default temperatures to unset — provider default, like single-model agents (#57440)

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.
This commit is contained in:
Teknium 2026-07-03 00:22:49 -07:00 committed by GitHub
parent e1a1dac848
commit 372f8195c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 74 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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