diff --git a/agent/moa_loop.py b/agent/moa_loop.py index f52b88cbb5f5..8ac5dd0d430e 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -428,7 +428,7 @@ def _run_reference( *, temperature: float | None = None, max_tokens: int | None = None, - reference_timeout: float = 30.0, + reference_timeout: float | None = None, ) -> tuple[str, str, Any]: """Call one reference model and return ``(label, text, accounting)``. @@ -573,7 +573,7 @@ def _run_references_parallel( temperature: float | None = None, max_tokens: int | None = None, progress_callback: Any = None, - reference_timeout: float = 30.0, + reference_timeout: float | None = None, ) -> list[tuple[str, str, Any]]: """Fan out all reference models in parallel, returning outputs in order. @@ -867,8 +867,15 @@ def _preset_temperature(preset: dict[str, Any], key: str) -> float | None: def _is_failed_reference(text: str) -> bool: - """Return whether a reference output is the internal failure sentinel.""" - return text.lstrip().lower().startswith("[failed:") + """Return whether a reference output is an internal failure/skip sentinel. + + Covers both the ``[failed: …]`` notes produced when a reference call + raises (which may embed raw provider error text) and the + ``[skipped: …]`` recursion-guard notes — neither is real advice, so + neither belongs in the aggregator prompt. + """ + sentinel = text.lstrip().lower() + return sentinel.startswith("[failed:") or sentinel.startswith("[skipped:") def _successful_references( @@ -899,7 +906,7 @@ def aggregate_moa_context( temperature: float | None = None, aggregator_temperature: float | None = None, reference_max_tokens: int | None = None, - reference_timeout: float = 30.0, + reference_timeout: float | None = None, degraded_reference_policy: str = "loud", ) -> str: """Run configured reference models and synthesize their advice. @@ -1346,7 +1353,13 @@ class MoAChatCompletions: # explicit values. See _preset_temperature. temperature = _preset_temperature(preset, "reference_temperature") aggregator_temperature = _preset_temperature(preset, "aggregator_temperature") - reference_timeout = float(preset.get("reference_timeout") or 30.0) + # None (the default) = no per-preset override; the fan-out inherits + # auxiliary.moa_reference.timeout (900s default) via call_llm's own + # per-task timeout resolution. Explicit per-preset values are honored. + raw_reference_timeout = preset.get("reference_timeout") + reference_timeout = ( + float(raw_reference_timeout) if raw_reference_timeout else None + ) degraded_reference_policy = str( preset.get("degraded_reference_policy") or "loud" ) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index c5f9d1e84de9..cbcff82d1914 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1019,7 +1019,7 @@ export interface MoaConfigResponse { reference_max_tokens?: number | null /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */ fanout?: string - reference_timeout: number + reference_timeout: number | null } > aggregator: MoaModelSlot @@ -1029,7 +1029,7 @@ export interface MoaConfigResponse { max_tokens: number reference_models: MoaModelSlot[] reference_temperature: number - reference_timeout: number + reference_timeout: number | null } export interface ModelAssignmentRequest { diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index 0ebb38570a09..c92f516b1a18 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -21,8 +21,7 @@ DEFAULT_MOA_AGGREGATOR: dict[str, str] = { "model": "anthropic/claude-opus-4.8", } -DEFAULT_MOA_REFERENCE_TIMEOUT = 30.0 -MAX_MOA_REFERENCE_TIMEOUT = 300.0 +DEFAULT_MOA_REFERENCE_TIMEOUT: float | None = None def _default_reference_models() -> list[dict[str, Any]]: @@ -45,9 +44,17 @@ def _coerce_float_or_none(value: Any) -> float | None: return None -def _coerce_reference_timeout(value: Any) -> float: - """Return a finite positive advisor timeout capped at five minutes.""" - if isinstance(value, bool): +def _coerce_reference_timeout(value: Any) -> float | None: + """Return a finite positive advisor timeout, or None to inherit. + + ``None`` (the default) means "no per-preset override": the reference + fan-out inherits the ``auxiliary.moa_reference.timeout`` config value + (900s by default) via ``call_llm``'s own resolution, exactly like every + other auxiliary task. An explicit finite positive per-preset value is + honored as-is — no artificial cap, since long-thinking advisor models + legitimately run far beyond five minutes. + """ + if value is None or value == "" or isinstance(value, bool): return DEFAULT_MOA_REFERENCE_TIMEOUT try: timeout = float(value) @@ -55,7 +62,7 @@ def _coerce_reference_timeout(value: Any) -> float: return DEFAULT_MOA_REFERENCE_TIMEOUT if not math.isfinite(timeout) or timeout <= 0: return DEFAULT_MOA_REFERENCE_TIMEOUT - return min(timeout, MAX_MOA_REFERENCE_TIMEOUT) + return timeout def _coerce_degraded_reference_policy(value: Any) -> str: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b02edc272777..d6c08510f50b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1365,13 +1365,17 @@ class MoaModelSlot(BaseModel): class _MoaReferenceControls(BaseModel): - reference_timeout: float = 30.0 + # None = no per-preset override; the fan-out inherits + # auxiliary.moa_reference.timeout (900s default). + reference_timeout: Optional[float] = None degraded_reference_policy: Literal["loud", "silent"] = "loud" @field_validator("reference_timeout", mode="before") @classmethod - def _validate_reference_timeout(cls, value: Any) -> float: + def _validate_reference_timeout(cls, value: Any) -> Optional[float]: """Reject JSON booleans/non-finite values before float coercion.""" + if value is None or value == "": + return None if isinstance(value, bool): raise ValueError("reference_timeout must be a finite positive number") try: diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index ad46283d99cd..0738e668096b 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -754,13 +754,16 @@ def test_reference_failure_controls_are_normalized_per_preset_and_flattened(): @pytest.mark.parametrize("value", [None, "", 0, -1, "bad"]) def test_reference_timeout_invalid_values_fall_back_to_default(value): - assert resolve_moa_preset(_preset(reference_timeout=value), "p")["reference_timeout"] == 30.0 + # None = inherit auxiliary.moa_reference.timeout (no per-preset override). + assert resolve_moa_preset(_preset(reference_timeout=value), "p")["reference_timeout"] is None -def test_reference_timeout_is_capped_and_unknown_policy_is_loud(): +def test_reference_timeout_is_uncapped_and_unknown_policy_is_loud(): preset = resolve_moa_preset( _preset(reference_timeout=9999, degraded_reference_policy="wat"), "p" ) - assert preset["reference_timeout"] == 300.0 + # Explicit per-preset values are honored as-is — long-thinking advisor + # models legitimately run beyond any fixed cap. + assert preset["reference_timeout"] == 9999.0 assert preset["degraded_reference_policy"] == "loud" diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index a3d76ca11427..dec14524e4fc 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -1889,6 +1889,31 @@ def test_reference_filtering_preserves_accounting_triples(): assert _failed_reference_labels(outputs) == ["bad-model"] +def test_reference_filtering_excludes_recursion_guard_skips(): + """[skipped: …] recursion-guard notes are internal sentinels, not advice — + they must be filtered out of the aggregator prompt like [failed: …].""" + from agent.moa_loop import ( + _RefAccounting, + _failed_reference_labels, + _is_failed_reference, + _successful_references, + ) + from agent.usage_pricing import CanonicalUsage + + outputs = [ + ("good-model", "useful advice", _RefAccounting(CanonicalUsage())), + ( + "moa:nested", + "[skipped: MoA presets cannot recursively reference MoA]", + _RefAccounting(CanonicalUsage()), + ), + ] + + assert _is_failed_reference(outputs[1][1]) + assert _successful_references(outputs) == [outputs[0]] + assert _failed_reference_labels(outputs) == ["moa:nested"] + + def test_aggregate_moa_context_sanitizes_failed_reference_and_forwards_timeout(monkeypatch): from agent import moa_loop from agent.usage_pricing import CanonicalUsage diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1455d369ef4f..6123e2087665 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2344,7 +2344,7 @@ export interface MoaConfigResponse { aggregator: MoaModelSlot; reference_temperature: number; aggregator_temperature: number; - reference_timeout: number; + reference_timeout: number | null; degraded_reference_policy: "loud" | "silent"; max_tokens: number; /** Optional advisor output cap — round-tripped, not edited here. */ @@ -2357,7 +2357,7 @@ export interface MoaConfigResponse { aggregator: MoaModelSlot; reference_temperature: number; aggregator_temperature: number; - reference_timeout: number; + reference_timeout: number | null; degraded_reference_policy: "loud" | "silent"; max_tokens: number; enabled: boolean;