feat(moa): default advisor fanout to user_turn — the cheapest cadence

Flips the default fan-out cadence from per_iteration (advisors re-run on
every tool iteration, multiplying advisor spend by tool-loop depth) to
user_turn (advisors run once on the first message of each user turn; the
acting aggregator works the rest of the tool loop with that turn's
advice). Until per-mode benchmarks justify a costlier default, MoA
defaults to the cheapest, lowest-impact cadence (#67199).

One default for everyone — no split legacy/new-preset semantics; presets
that want per-step advising set fanout: per_iteration explicitly. All
three modes (user_turn / per_iteration / every_n:N) remain selectable;
every_n:1 still collapses to per_iteration (semantic identity), while
unparseable values now fall to user_turn (the default).

Docs updated with a default-change note; the per-iteration rerun test
pins its mode explicitly.

Co-authored-by: skyer-flyyy <188930297+skyer-flyyy@users.noreply.github.com>
This commit is contained in:
Teknium 2026-07-23 20:11:57 -07:00
parent b0ef72a8a0
commit 23476207bc
7 changed files with 84 additions and 56 deletions

View file

@ -672,8 +672,14 @@ def test_slot_max_tokens_absent_by_default():
# --- fanout cadence normalization (every_n) ---
def test_fanout_defaults_to_per_iteration():
def test_fanout_defaults_to_user_turn():
# Default is the cheapest cadence (#67199): advisors once per user turn.
cfg = normalize_moa_config({})
assert cfg["fanout"] == "user_turn"
def test_fanout_per_iteration_still_selectable():
cfg = normalize_moa_config({"fanout": "per_iteration"})
assert cfg["fanout"] == "per_iteration"
@ -689,14 +695,15 @@ def test_fanout_every_n_mapping_form_normalized_to_string():
def test_fanout_every_n_degenerate_n_falls_back():
# n=1 means "every iteration" — that IS per_iteration; n=0 / negative /
# garbage must never produce a broken cadence string.
# n=1 means "every iteration" — that semantically IS per_iteration;
# n=0 / negative / garbage is unparseable and falls to the default
# cadence (user_turn, the cheapest — #67199).
assert normalize_moa_config({"fanout": "every_n:1"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n:0"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n:-2"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n:x"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n"})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": {"mode": "every_n"}})["fanout"] == "per_iteration"
assert normalize_moa_config({"fanout": "every_n:0"})["fanout"] == "user_turn"
assert normalize_moa_config({"fanout": "every_n:-2"})["fanout"] == "user_turn"
assert normalize_moa_config({"fanout": "every_n:x"})["fanout"] == "user_turn"
assert normalize_moa_config({"fanout": "every_n"})["fanout"] == "user_turn"
assert normalize_moa_config({"fanout": {"mode": "every_n"}})["fanout"] == "user_turn"
def test_fanout_every_n_round_trips_through_normalize():

View file

@ -1177,10 +1177,11 @@ def test_references_parallel_interrupt_aborts_wait(monkeypatch):
release_wedged.set() # don't leak a blocked thread past the test
def _ref_config(home):
def _ref_config(home, fanout: str | None = None):
home.mkdir()
fanout_line = f"\n fanout: {fanout}" if fanout else ""
(home / "config.yaml").write_text(
"""
f"""
moa:
default_preset: review
presets:
@ -1192,7 +1193,7 @@ moa:
model: anthropic/claude-opus-4.8
aggregator:
provider: openrouter
model: anthropic/claude-opus-4.8
model: anthropic/claude-opus-4.8{fanout_line}
""".strip(),
encoding="utf-8",
)
@ -1234,13 +1235,15 @@ def test_moa_facade_emits_reference_then_aggregating(monkeypatch, tmp_path):
def test_moa_facade_reruns_references_on_new_tool_result(monkeypatch, tmp_path):
"""References re-run when a new tool result advances the task state.
The agent loop calls create() once per tool-loop iteration. References must
judge the LATEST state, so a new tool result is a cache MISS and re-runs the
references but a redundant create() call with the SAME state is a cache
HIT (no re-run, no re-emit), so we don't fire on a pure no-op re-call.
Pins fanout: per_iteration explicitly (the default became user_turn,
#67199). In this mode the agent loop calls create() once per tool-loop
iteration and references must judge the LATEST state, so a new tool
result is a cache MISS and re-runs the references but a redundant
create() call with the SAME state is a cache HIT (no re-run, no
re-emit), so we don't fire on a pure no-op re-call.
"""
home = tmp_path / ".hermes"
_ref_config(home)
_ref_config(home, fanout="per_iteration")
monkeypatch.setenv("HERMES_HOME", str(home))
ref_runs = []