MoA: mesh answers improve as capable nodes join (#1116)

* feat(moa): give MoA workers tools, synthesize disagreement, survive flaky peers

Agentic MoA turns now reliably produce tool calls, and diverging answers are
synthesized instead of relaying one arbitrarily-chosen worker's text.

Fixes found by replaying recorded traces from 9 open-weight models:

* Tools were withheld from workers. `query_uses_tools` was derived from
  `looks_like_tool_intent`, an English keyword match on the user's text. "The
  test suite is failing. Find out which test fails and why" matches nothing, so
  workers were dispatched without tool schemas and the arbiter ran with
  has_tools=false — a unanimous tool proposal then fell through to the answer
  path and leaked "calling search" as prose. 5 of 10 recorded scenarios hit
  this. Tool availability is now the caller's declaration.

* Argument outliers could win a unanimous tool call. Native tool calls are all
  normalized to a fixed 0.9 confidence, so the confidence-only tiebreak could
  not separate 8 workers proposing {"path":"src"} from one hallucinating
  {"path":"rust_project/src"}. Added argument clustering (key-order
  independent); the largest cluster wins and argument-free calls never outvote
  filled-in ones.

* Early exit committed on tied arguments. Early exit runs on whoever has
  arrived, so two fast models agreeing on a tool name with different arguments
  was a coin flip that also aborted the workers who would have broken the tie.
  It now waits.

* Truncation was invisible. `finish_reason` was never read; 39/140 recorded
  responses came back "length" and 24 carried partial text, which parsed as a
  normal answer and could be returned verbatim. Plumbed through as
  BackendReply.truncated -> WorkerOutput.truncated; such answers are barred
  from consensus and verbatim output but still feed synthesis, labelled
  incomplete.

* A strict endpoint could kill a worker permanently. minimax-m2.5 returns
  HTTP 400 "Reasoning is mandatory for this endpoint" and failed 12/12
  requests; HttpBackend now drops the thinking flags and retries once.

Policy changes:

* Thinking is always off for MoA workers, not merely defaulted off. The
  previous escape hatch only let callers request the broken configuration:
  qwen3-32b spent 408 reasoning tokens against a 384-token cap and returned
  null content. Ignored overrides are logged.

* Diverging answers go to the reducer. Returning the top-confidence answer was
  near-arbitrary because models rarely emit our confidence envelope, so
  everything defaults to 0.5 and max_by returns whichever worker finished
  first. Agreement still short-circuits without paying for synthesis.

* Reducer prompt adopts Together's aggregator framing (synthesize, critically
  evaluate, agreement is not proof), keeping our per-worker attribution,
  structured tool proposals, and 500-char payload bound.

Tests: new sim_real_traces.rs replays 56 recorded cases from 9 models through
handle_turn, plus regression unit tests for each bug. Recorders and corpora
live in evals/moa-openrouter/.

* feat(moa): asymmetric tool turns — references advise, best tool-caller acts

Tool-bearing turns now use the Hermes/Nous-style asymmetric shape instead of a
majority vote across workers:

- references run TOOL-FREE and only advise in prose
- the single best tool-caller (the "actor") acts on that advice with the real
  tools and emits the tool call

Tool authority now tracks capability, not popularity. The old path let several
weak models proposing a popular-but-wrong tool outvote the one strong
tool-caller that picked correctly (observed: qwen3-32b alone chose run_command
for a failing-test triage while the smaller models chose list_dir, and the vote
shipped list_dir). An actor model removes that failure class instead of patching
the vote arithmetic.

Stays a pure stateless /v1/chat/completions turn — references are regenerated
from the caller's transcript each request, and the external client still owns
tool execution. Text-only turns are unchanged (symmetric fan-out +
synthesis-on-divergence).

Actor selection is capability-first: the host ranks candidates by gossiped
`tool_use` level (Supported > Likely > None), then model size tier, then stable
order, and passes the ordering to the engine via the new
`GatewayConfig.actor_candidates`. Empty ordering falls back to the engine's
name-derived size tier, so existing callers and tests are unaffected.

Mesh guardrails for mixed/public meshes: references are gathered with a bounded
wait (proceed at a majority of advisors, never block on the slow tail), and the
actor is called through the existing hedged ladder so a slow/broken best
candidate falls through to the next tool-capable peer.

New:
- crates/mesh-mixture-of-agents/src/tool_turn.rs — asymmetric tool-turn handler
- context::pack_for_actor — "advise, then act" framing (vs synthesis framing)
- fanout::gather_references — bounded, tool-free reference collection
- GatewayConfig.actor_candidates + reducer_candidates honours it
- host compute_actor_candidates from gossiped tool_use + size

The dead majority-vote code in the arbiter (now unreachable — tool turns bypass
it) is removed in the follow-up commit.

Tests: eval_openrouter.rs adds a live OpenRouter harness (ignored by default)
that runs the real handle_turn over 6 open-weight models with mesh-realism
latency/failure injection, for benchmarking asymmetric MoA vs single models.
192 engine tests pass; clippy -D warnings and fmt clean on both crates.

* refactor(moa): remove dead majority tool-vote from the arbiter

Tool turns now take the asymmetric actor path (previous commit), so the
arbiter's tool-proposal voting is unreachable — and it *was* the
majority-of-weakness bug: a popular-but-wrong tool choice from several weak
models could outvote the one strong tool-caller that picked correctly.

Removes it rather than leaving dead code:

- delete best_tool_proposal, best_tool_proposal_by_consensus, and
  decisive_argument_cluster
- drop the tool-arbitration and tool-vs-answer branches from arbitrate, and
  the tool-consensus branch from try_early_decision
- drop the now-unused has_tools parameter from arbitrate, try_early_decision,
  single_output_decision, and gather_workers_incremental
- the arbiter is now purely an answer/critique/uncertainty arbiter; tool-shaped
  text on the text path is already demoted to Uncertainty by
  enforce_tool_call_contract (tools disabled ⇒ empty allow-list)
- drop the arbiter tests that pinned the removed tool-vote behavior; keep and
  re-point every answer-consensus, truncation, synthesis, and tier-gate test

No behavior change for text turns. 178 engine tests pass; host-runtime lib
tests (1851) pass; clippy -D warnings and fmt clean on
mesh-mixture-of-agents, mesh-llm-host-runtime, and mesh-llm.

* test(moa): controlled actor-ablation eval — do references actually help?

Replaces the confounded "MoA vs best single model" comparison with a
within-actor ablation that isolates the one variable that matters: the
references. One pinned actor, identical sampling / token budget / system
prompt across all arms; only the advice changes:

  A. actor alone (no references)
  B. actor + real references (the production tool path)
  C. actor + shuffled references (advice from a different task, length-similar)

Metric: rescue (A✗→B✓) minus harm (A✓→B✗). Arm C is the key control — it
separates "useful information" from "extra tokens + a think-carefully prompt".

Why the change: the earlier best-single comparison ran solo models at different
sampling (0.8/512) than the actor (0.3/2048) and with a different system
prompt, and scored transient 429/502/504 and tool-unsupported endpoints as
capability failures. Those numbers measured sampling+prompt+infra, not the
design, and are not citable. This ablation removes all of that by construction:
the same scorer hits all three arms of the same actor, so an imperfect label
largely cancels in the rescue-minus-harm delta.

Also:
- chat_completion_retrying: predeclared retry on transient infra errors, which
  are then excluded (∅) from the capability analysis rather than scored as
  wrong answers.
- MOA_ABLATION_ACTOR / MOA_ABLATION_DRAWS env overrides.
- the old best-single test is kept but documented as a confounded smoke test,
  not evidence.

Pilot findings (5 draws, 4 tasks, ignored/live):
- Strong actor (qwen3-32b): 20/20 all arms — aces every task alone, so
  references are inert-but-harmless (no headroom to measure rescue).
- Weak actor (qwen3-8b): A 15/20, B 20/20, C 15/20 — net uplift +5, all on the
  one task with headroom (triage). Real advice rescued the weak actor every
  draw; SHUFFLED advice did not (C=A), so the gain is advice CONTENT, not token
  count or the decision prompt. Zero harm.

Reading: reference value tracks actor-alone headroom — a safety net when the
actor is weak, dead weight (not damage) when strong. Suggests gating the
reference phase on actor strength. This is a directional pilot, not the
merge-blocking study (that needs ~40 preregistered stratified tasks, k>=10
draws, a paired hierarchical bootstrap CI, and the production-selected actor).

* test(moa): preregistered scaled actor-ablation study + bootstrap analyzer

The defensible version of the pilot, committed BEFORE running so the labels
are preregistered and can't be seen to chase results.

- tests/fixtures/ablation_tasks.json: 40 preregistered tasks, 4 strata x 10
  (inspect / search / execute / no_tool). Set-valued accept labels
  (accept_tools is a SET; empty = "no tool call"), with optional arg substring
  constraints — addresses the brittle single-tool label from the pilot.
- ablation_scaled_study: loads the fixture, runs A/B/C arms at k draws
  (default 10) with bounded concurrency, writes one JSONL trial per
  (draw,task,arm) to MOA_ABLATION_OUT. Live measurement only; no stats here.
- evals/moa-openrouter/analyze_ablation.py: deterministic paired HIERARCHICAL
  bootstrap — resample tasks within stratum, then draws within task — for a
  95% CI on net uplift = P(rescue) - P(harm), plus the shuffled-arm control
  (content effect = B_uplift - C_uplift). Validated on synthetic data: 3/40
  rescue tasks -> +0.075 point estimate, shuffled +0.000, and the CI honestly
  spans 0 when rescue is sparse (no over-claiming).

Arms (one pinned actor, identical sampling/prompt; only references vary):
  A actor alone · B actor + real references · C actor + shuffled references.

Env: MOA_ABLATION_ACTOR, MOA_ABLATION_DRAWS, MOA_ABLATION_CONCURRENCY,
MOA_ABLATION_OUT. Ignored by default (live network + cost).

* docs(moa): trim over-long rationale comments in the asymmetric tool path

Cut the essay-length doc/inline comments added with the actor design down to the
non-obvious "why". No code change.

- tool_turn.rs: 26-line module preamble -> 6 lines; inline comments tightened
  (20% -> 10% comment density)
- context.rs: pack_for_actor docstring 16 -> 5 lines; reducer synthesis-framing
  block 14 -> 6 lines
- workers.rs: compute_actor_candidates docstring 21 -> 6 lines (kept the terse
  sort-key comments, which are load-bearing)

* test(moa): pre-hoc structured-proposal and post-hoc correction studies

Two more ablation arms to answer "can peers help tool selection at all?",
reusing the A/B/C JSONL schema + analyze_ablation.py.

matched_peer_structured_study — do similar-strength, different-family peers
help a fixed finalizer via STRUCTURED candidate tool calls (not prose)?
  A solo · B diverse (2 different-family peers) · C homogeneous (2 resamples
  of the finalizer's own model). B-C isolates cross-family diversity from
  extra sampling; also records oracle-union.

correction_rescues_weak_tool_caller — the mesh scenario neither Hermes nor
Together handles: a weak tool-caller with no strong peer. Tests correction of
the CONCRETE drafted call instead of pre-hoc advice.
  A draft-alone · B deterministic (schema-validate + re-prompt on structural
  failure) · C semantic (different-family critic reviews the concrete call,
  finalizer revises once).

Findings (live, directional): every tool-selection intervention was
inert-to-harmful vs routing to a capable model. Pre-hoc structured proposals
were flat (37-39/40 all arms). Deterministic correction fired ~never
(qwen3-8b already drafts structurally valid calls ~95%); the residual failures
are semantic (wrong tool choice), which neither validation nor a strong critic
fixed because the revision still runs through the weak actor. Conclusion: tool
selection is a "best capable model acts" task; MoA's value is on the
answer/chat path (untested).

* test(moa): committee study on realistic reasoning/answer turns

Tests where MoA's value should live per Together's validated claim: open-ended
answer QUALITY on realistic agent-session turns (reason-over-tool-output,
planning, explanation) — not tool selection.

Fixed aggregator; only its input varies:
  A alone · B committee (synthesize 3 diverse-peer drafts, 1 round)
  C layered (peers refine seeing each other first, then synthesize — Together's
  `layers`)

Judged pairwise by an out-of-pool different-family judge (gpt-4o-mini),
position-swapped (win only if consistent both orders), output lengths logged.

- tests/fixtures/committee_tasks.json: 15 realistic reasoning/answer turns
- committee_beats_solo_on_reasoning: writes per-trial JSONL

Pilot findings (2 draws, live; DIRECTIONAL, underpowered):
- committee(B) vs solo(A): win 6 / tie 2 / loss 2 — positive but sign-test
  p=0.29, NOT significant at n=10. Every B win was also the longer answer, so
  length is not cleanly ruled out.
- layered(C) vs committee(B): loss 6 / tie 2 / win 2 — Together's extra
  refinement round is negative value on these tasks, at extra cost.
- 20/30 trials skipped because the aggregator (qwen3-32b) returned empty
  content (reasoning-budget exhaustion — the content:null bug Hermes'
  troubleshooting.md documents). A flaky aggregator degrades the committee;
  the instrument needs empty-output handling before a real run.

Net: first directionally-positive result for MoA in this investigation, but
unproven. Contrast with 5 tool-selection experiments that were all
null-to-harmful. Supports the task-split: route tools to one caller, convene a
(single-round) committee on reasoning turns.

* feat(moa): Hermes-style reference packing — strip system prompt + tool transcript

Our references were packed very differently from Hermes', and it was measurably
costly. Adds `pack_for_reference`, which gives advisors only the conversation's
user/assistant prose:

- strips the agent system prompt (an advisor told "you are a coding agent, run
  the tests" role-plays the actor instead of advising it)
- strips the tool transcript (prior tool_calls + results anchored every advisor
  on the trajectory already taken, collapsing the error-independence that makes
  aggregation worth anything)
- drops the "respond with your best answer or tool call" instruction: advisors
  hold no schemas, so asking for a tool call yields tool-shaped prose — exactly
  the advice that pulled the actor off its own better choice
- uniform view across advisors (no per-role trimming), so the packing is a
  stable function of history and caches across iterations
- caps advisor output at 600 tokens (advisor generation dominates turn latency;
  the turn waits for the slowest advisor)

Head-to-head on the same preregistered study (strong actor qwen3-32b, 40 tasks
x 10 draws, identical everything except packing):

  packing      B pass    net uplift      95% CI
  original     359/400   -0.102          [-0.170, -0.045]
  hermes       385/400   -0.037          [-0.090, -0.003]

Harm cut by ~64%. Per-category: search -14 -> -2, execute -25 -> -13,
inspect -2 -> 0. The content-specific component (B-C differential) fell from
-0.075 to -0.015, i.e. with correct packing the residual harm is no longer
mostly "bad advice content".

Honest read: most of the harm I previously attributed to "references" was an
artifact of how WE packed them, not a property of reference-based MoA. The
direction still stands though — even correctly packed, references remain a
small but statistically real regression for a STRONG actor on tool selection
(CI still entirely below zero).

Adds 4 unit tests pinning the packing contract (no tool-call request, no system
prompt leak, no tool-transcript leak, prose preserved), and
MOA_REFERENCE_PACKING=hermes to select the style in the eval harness.

* docs(moa): record the MoA evidence — packing bug, actor headroom, what failed

Writes up every live study in one place (evals/moa-openrouter/RESULTS.md) so
the conclusions and their caveats survive the investigation.

Headline 2x2 (40 preregistered tasks x 10 draws, paired hierarchical bootstrap):

  actor    packing    B pass     net uplift   95% CI
  strong   original   359/400    -0.102       [-0.170, -0.045]  <- the bug
  strong   hermes     385/400    -0.037       [-0.090, -0.003]
  weak     original   365/400    -0.013       [-0.090, +0.070]
  weak     hermes     377/400    +0.017       [-0.053, +0.100]

Two monotonic effects: fixing the packing helps in both actor conditions, and
references are worth more to a weaker actor. The only significant cell is the
original-packing strong-actor harm — i.e. our bug, not a property of MoA.

Per-stratum (weak + hermes) shows references help exactly where the actor has
headroom (search +10, execute +4) and hurt where it was already perfect
(inspect -7). That is a gating signal.

Also records what did NOT work for tool selection (pre-hoc structured
proposals: flat; deterministic correction: never fires, the weak actor already
emits valid calls ~95%; semantic correction: negative, since the revision still
runs through the weak actor), and the committee pilot on reasoning turns
(directionally positive, n=10, not significant; Together's layering loses to
single-round).

* fix(moa): use the validated reference packing in production

The Hermes-style packing was measured and unit-tested but only wired into the
eval harness — the production tool path still used pack_for_worker_selected,
the exact packing measured at -0.102 net uplift.

tool_turn now calls pack_for_reference: conversation prose only, no agent
system prompt, no tool transcript, no request for a tool call. Same head-to-head
(strong actor, 40 tasks x 10 draws) puts this at -0.037 vs -0.102, and it is the
only configuration where references show a positive point estimate for a weak
actor (+0.017). See evals/moa-openrouter/RESULTS.md.

* feat(moa): gate advisory references on actor headroom

References are not universally good or bad — their value tracks how much
headroom the acting model has. Measured over 40 preregistered tool tasks x 10
draws with correct advisor packing (evals/moa-openrouter/RESULTS.md):

  weak actor   (qwen3-8b):  +0.017 net uplift  -> advice helps
  strong actor (qwen3-32b): -0.037 net uplift  -> advice costs

Per-stratum the split is sharper: references gained where the actor had
headroom (search +10, execute +4) and lost where it was already perfect
(inspect -7).

Adds `GatewayConfig.reference_policy`:

  Auto (default) - advise a small-tier actor, let a big-tier actor act alone
  Always         - always fan out advisors
  Never          - actor acts alone (Hermes' `enabled: false`)

A one-model pool never gathers references under any policy (nobody left to
advise once the actor is excluded), and an unknown actor keeps the prior
advise-by-default behaviour rather than silently degrading.

The host sets `Auto`. sim_real_traces pins `Always` because that suite exists
to verify fan-out accounting; the gate itself is covered by six new unit tests
in tool_turn.

189 tests pass; clippy -D warnings and fmt clean on mesh-mixture-of-agents and
mesh-llm-host-runtime.

* test(moa): scale the committee study — 40 preregistered prompts, concurrent, empty-output fix

Prepares the reasoning-turn committee study for a defensible run. Committed
before executing so the prompts are preregistered.

Three fixes to the pilot's weaknesses:

1. Empty-output bug. `response_text` read only `/message/content`, so a
   reasoning model that spends its budget in `reasoning` and returns
   `content: null` looked like an empty answer — that silently dropped 20/30
   pilot trials. Now falls back to `reasoning`, mirroring the in-tree backend's
   chain. Remaining skips are counted and reported rather than hidden.

2. Serial execution. ~18 calls x 30 trials was far too slow to scale. Trials
   are independent, so they now run under a bounded semaphore
   (MOA_COMMITTEE_CONCURRENCY, default 4), with peer drafts and refinements
   gathered concurrently within each trial. Output is sorted for stable
   reporting.

3. Fixture too small. 15 -> 40 prompts, 4 strata x 10: reason_over_output,
   planning, explain, code_review. All realistic agent-session reasoning/answer
   turns (several embed simulated tool output or code, as an agent loop would).

Arms and controls are unchanged: fixed aggregator, A alone / B committee /
C layered, judged pairwise by an out-of-pool different-family judge with
position swap, output lengths logged for the length-vs-preference check.

* docs(moa): committee wins decisively on reasoning turns (120 trials, p<1e-11)

Scaled the reasoning-turn committee study from the 15-prompt pilot to 40
preregistered prompts (4 strata x 10) x 3 draws = 120 trials, 0 skipped.

  comparison                      win/tie/loss   mean    95% CI            sign p
  committee(B) vs solo(A)         86/16/18       +0.567  [+0.392,+0.733]   8.2e-12
  layered(C)   vs solo(A)         90/11/19       +0.592  [+0.408,+0.758]   3.1e-12
  layered(C)   vs committee(B)    57/30/33       +0.200  [+0.008,+0.392]   1.5e-02

Consistent across every stratum. The length confound runs the OPPOSITE way and
strengthens the result: the committee's answers are SHORTER than solo (2548 vs
3136 chars) and still win; restricted to the 61 trials where B was shorter, B
wins 40-14 (p=5.4e-4).

This reverses two pilot conclusions, both of which were artifacts of the
empty-output bug that silently dropped 20/30 pilot trials (biased toward
exactly the cases where the aggregator struggled):

- "committee is directionally positive but not significant" -> it is
  significant by a wide margin;
- "Together's layering is negative value, don't build it" -> RETRACTED;
  layered beats solo about as strongly as single-round, and edges single-round
  itself (though that is the weakest result and costs an extra peer round).

Net across the whole investigation: tool selection shows no win for
multi-model (route to the best tool-caller); reasoning/answer turns show a
clear one (convene the committee). That task split is now evidence-backed
rather than assumed.

* feat(moa): cross-peer refinement round — a small-model mesh beats its best member

The mesh value proposition, measured: a pool of four 8B-class models
(qwen3-8b aggregating llama-3.1-8b + granite-4.1-8b + ministral-8b) produces
better answers than the best single member — but ONLY with a refinement round.

40 preregistered reasoning prompts x 3 draws = 120 trials, judged pairwise by
an out-of-pool different-family judge (gpt-4o-mini), position-swapped:

  small pool (all 8B):
    committee (1 round) vs solo   26/75/19   +0.058  p=0.37     ns
    layered   (2 rounds) vs solo  42/66/12   +0.250  p=5.2e-05  ***
    layered vs committee          39/73/ 8   +0.258  p=5.5e-06  ***

Single-round synthesis does nothing for a small pool; the cross-peer
refinement round is what creates the gain. With a 32B aggregator both work and
the extra round adds much less (p=0.015), so it isn't worth a second fan-out
there.

Adds `refinement::refine_round`: every worker sees all round-1 drafts
(anonymized, length-bounded) and rewrites its own answer, then the reducer
synthesizes the refined set. Runs on the text path only, and only when we were
going to synthesize anyway — an early-exit consensus already has its answer.

`GatewayConfig.refinement_policy`:
  Auto (default) - refine when the pool is all small-tier
  Always / Never - explicit override

Mesh flavour: the round is best-effort. It bounds its own wait at
worker_timeout, refines with whichever drafts arrived, tolerates workers that
fail or time out, and returns the round-1 outputs unchanged on any shortfall —
so a slow or absent peer can never cost the turn.

This also retracts, for the second time, the pilot claim that "Together's
layering is negative value". It is essential for exactly the pool shape a
consumer mesh has.

194 tests pass; clippy -D warnings and fmt clean on both crates.

* test(moa): pin refinement behaviour under mesh conditions; record small-pool evidence

Refinement runs on exactly the hardware where peers are slowest and least
reliable, and a second fan-out is a second chance to hang a turn. Four
deterministic sim tests pin it as strictly best-effort:

- refined drafts (not round-1) are what reach the client
- a refiner that never returns cannot extend the turn past worker_timeout
- every refiner failing degrades to the round-1 drafts, turn still answers
- a big-tier pool skips the extra fan-out entirely under Auto

Two of these failed first and both were my test's fault, not the engine's:
identical round-1 text clustered into consensus so the turn early-exited before
synthesis (peers now return distinct answers), and the "reducer must synthesize"
assertion was too narrow — when refined drafts agree, the arbiter takes
consensus and returns refined text directly, which is equally correct. The
contract pinned is now "refined text reaches the client, round-1 text does not".

RESULTS.md records the small-pool study that motivated the feature: an all-8B
diverse pool (qwen3-8b aggregating llama-3.1-8b + granite-4.1-8b +
ministral-8b) beats its best member only with the refinement round —
single-round 26/75/19 (p=0.37, ns) vs layered 42/66/12 (p=5.2e-05), with
layered over single-round at p=5.5e-06. Includes the honest caveats: ties
dominate for the small pool, and unlike the strong-pool result the length
control is not clean (answers are longer than solo).

* fix(moa): bound the refinement round to half the worker budget

Refinement sits between round 1 and the reducer, so giving it the full
`worker_timeout` let one text turn pay three sequential worker budgets — worst
on exactly the slow, high-latency meshes the feature exists to serve.

The round now gets half the budget: enough for a pool that answered round 1
promptly, and it falls back to the round-1 drafts if the pool can't refine in
time. Worst case drops from ~3x a plain turn to ~2.5x.

* feat(moa): shorter patience on public meshes

A public mesh is a pathological availability case — unknown peers, wider
latency spread, more churn — not a trust case. Both patience knobs are "hold a
usable answer hoping for a better one", which is exactly the wait that hurts
when the tail is long.

  knob                 private   public
  first_answer_grace   3s        1.5s
  strong_patience      20s       8s

Hard bounds (worker_timeout, reducer_timeout) are unchanged; only the optional
waiting shrinks, so quality paths still run when peers are prompt. Sourced from
the node's existing `public_mesh` flag — no new config.

Three tests pin the contract: public waits less on both knobs, public still
gives a prompt strong peer a real chance (grace non-zero, patience >= 5s), and
the private defaults don't drift silently.

1854 host-runtime tests pass; clippy -D warnings and fmt clean.

* test(moa): prove refinement runs under the production grace setting

Every refinement test so far set `first_answer_grace: ZERO`, but production
uses 3s (1.5s public). Grace produces an `early_decision`, and refinement is
skipped whenever one exists — so if grace fired on a fast all-small pool the
whole feature would have been dead code on real traffic. That was untested.

Two tests close the gap:

- `refinement_still_runs_under_production_grace` — at the real 3s default, a
  prompt all-small pool still refines and the refined drafts reach the client.
  Grace only arms once its window has *elapsed*, so a pool that answers
  promptly reaches synthesis first.
- `grace_shipping_a_slow_answer_skips_refinement` — pins the opposite case
  honestly: when round-1 answers straggle past the grace window, grace ships an
  early answer and refinement does not run. That tradeoff is now visible in a
  test rather than assumed.

195 engine tests pass; clippy -D warnings and fmt clean.

* fix(moa): straggling peers must not cost the refinement round

The answer grace was silently disabling the feature that makes a small mesh
worth running.

Grace fires at 3s (1.5s public) and produces an `early_decision`; refinement is
skipped whenever one exists. On an all-small pool a single synthesis round is
worth nothing (26/75/19, p=0.37) — refinement is the only step that beats the
best member (42/66/12, p=5.2e-05). So one fast peer answering while others
straggle would ship a lone small-model answer and skip the round, while still
paying for the fan-out. Variable peer latency is the norm on consumer hardware,
so this hit exactly the case the feature exists for.

Now: when refinement is expected (policy + pool shape, decidable before
dispatch via `refinement_expected`), the answer grace is disabled for that
turn. Round 1 is still bounded by `worker_timeout` and refinement by its own
half-budget deadline, so this costs bounded latency, never an unbounded wait.
Pools that don't refine — anything with a big-tier model — keep the fast chat
path untouched.

Found by re-reading my own test against the goal: the previous
`grace_shipping_a_slow_answer_skips_refinement` pinned this as an acceptable
tradeoff. It wasn't; it was the feature not firing. Replaced with
`straggling_peers_do_not_cost_the_refinement_round`, plus
`grace_still_short_circuits_when_refinement_is_not_expected` to pin that the
fast path survives where refinement is off.

202 engine tests + 1854 host tests pass; clippy -D warnings and fmt clean.

* fix(moa): grace must bound the wait, not forfeit the refinement round

On an all-small pool the answer grace could ship a lone fast answer and skip
refinement — the only step that makes such a pool beat its best member
(26/75/19, p=0.37 without it vs 42/66/12 with). Variable peer latency is the
norm on consumer hardware, so this silently disabled the feature exactly where
it exists to help, while still paying for the fan-out.

Fix: `GatherPolicy.min_grace_answers`. Normally 1 (ship the good answer, stop
waiting for the tail). When refinement is expected it becomes MIN_DRAFTS, so
grace still caps the round-1 wait but will not finalize before refinement is
even possible.

Two earlier attempts were wrong and are worth recording:

- Disabling grace outright for all-small pools removed the only straggler
  bound on round 1 (worst case round1 + refine + reducer), which is backwards
  on a public mesh whose whole profile is "wait less".
- Letting refinement override an existing `early_decision` broke
  `same_tier_pool_keeps_early_exit`, and rightly: that decision is *consensus*
  (workers agreed — a real signal and the cheap path), not a timeout. Grace and
  consensus both surface as `early_decision` but mean opposite things; only the
  timeout should be prevented from pre-empting refinement.

202 engine tests + 1854 host tests pass; clippy -D warnings and fmt clean.

* fix(moa): let the reducer and refiners actually see the answers

Eval-vs-production fidelity gap, same class as the reference-packing bug.

The small-pool result (42/66/12, p=5.2e-05) was measured on untruncated drafts
averaging ~3.8k chars. Production truncated much harder:

  refinement input  1200 chars  -> refiners saw ~30% of each peer draft
  reducer payload    500 chars  -> reducer saw ~13% of each refined answer

So the shipped path discarded most of exactly the content the refinement round
produces. The measured gain could not have survived it.

Both budgets are now 4000 chars, which passes realistic answers intact while
still bounding a pathological worker. Tool turns keep the tight 500-char bound
on purpose: there the signal is the proposal itself, and long prose crowds out
the tool schemas.

Three tests pin the contract (realistic text answers survive, tool payloads
stay tight, refiners see full peer drafts), and the existing
`reducer_truncates_long_worker_payloads` now probes above the new bound so it
still guards the pathological case it was written for.

205 engine tests pass; clippy -D warnings and fmt clean.

* fix(moa): ship the refinement prompt that was actually measured

Final eval-vs-production fidelity check. Sampling already matched exactly
(SamplingParams::worker, thinking off, 1024 tokens), but the prompt did not:

  measured (eval)   "You have been given a user request and several candidate
                     responses... Synthesize them into one high-quality
                     response..." + "Candidate responses:" + [Response N]
  shipped (before)  "Several models independently answered... write a better
                     answer of your own..." + [Answer N]

The eval wording is also what Together's advanced-moa.py uses — it reuses the
aggregator prompt for refinement layers. A refinement-specific wording may well
read better, but the eval wording is the one with evidence behind it, and
"surely equivalent" prompt differences have already caused two wrong
conclusions in this branch (the reference-packing bug and the layering
dismissal). Changing it should be a measured change, not an assumed
improvement.

Production now matches the measured configuration exactly: prompt text, header,
and per-draft labels.

The sim fixture detected refinement calls by matching the old prompt string, so
it needed updating too — it now keys on the "Candidate responses:" header.

205 engine tests pass; clippy -D warnings and fmt clean.

* docs(moa): record the eval-vs-production fidelity gaps and their fixes

A measured gain only counts if the shipped path reproduces the measured
configuration. Three gaps were found after the numbers were collected:
refinement input truncated to 1200 chars (~30% of a real draft), reducer
payload to 500 (~13%), and a different refinement prompt than the one measured.
All closed; tool turns keep the tight 500-char bound on purpose.

Also states the honest limit: production now matches the measured
configuration, but the small-pool result has not been re-measured through the
shipped path since. The gain should carry — that is an expectation, not an
observation.

* fix(guardrails): panic slicing tool-call text with a multi-byte prefix

`parse_parenthesized_tool_call` resumed parsing at `after_open[json_text.len()..]`,
but `first_balanced_object` returns the object starting at its `{` — so any
characters before the brace skew the index. With a multi-byte character in that
prefix the index lands mid-character and slicing panics.

Hit for real during an end-to-end MoA eval against live models:
"start byte index 13 is not a char boundary; it is inside '‑'" (U+2011,
non-breaking hyphen) — a worker task panic that would have taken down the turn.

Adds `first_balanced_object_span` returning the byte span, so callers resume at
the object's end rather than guessing from its length. Three regression tests:
multi-byte prefix must not panic, happy path still parses, multi-byte content
inside the arguments round-trips.

19 guardrails tests pass; clippy -D warnings and fmt clean.

* fix(moa): stop telling the text reducer to "Be concise"

End-to-end through `handle_turn`, MoA lost 7/8/65 to a single small model. Two
causes; this is the second.

The text reducer's instruction framed the turn as reconciling a *disagreement*
and ended with "Be concise". Measured output: ~2.0k chars against a ~4.1k-char
solo baseline — and the judge preferred the fuller answer. Terseness is not the
goal on a reasoning turn; accuracy and completeness are.

Text turns now use the wording the committee study actually measured
(evals/moa-openrouter/RESULTS.md): "produce the most accurate, well-structured
reply. Be direct." Tool turns keep the tight framing — there the output is an
action and the reducer must stay free to emit a tool call.

Also adds the e2e harness (`e2e_handle_turn_beats_best_single_small_model`)
that found this: it drives the real `moa::handle_turn` over a live all-8B pool
and judges it against the pool's strongest member. The committee study measures
the mechanism with its own helpers; this measures the shipped path, which is
where all three fidelity bugs actually hid.

205 engine tests pass; clippy -D warnings and fmt clean.

* fix(moa): give every peer the full budget when refinement is expected

Third and last cause of the end-to-end loss.

Role tiers (Fast 256 tokens, Specialist 512, Strong 1024) encode a capability
spread so the cheap worker can answer the grace path quickly. A homogeneous
all-8B pool has no such spread, and when refinement is expected every draft is
an *input* to the round — a 256-token draft is a ~1000-char stub that drags the
refined answer down.

Measured: production's tiered budgets produced ~3.1k-char answers against a
~4.1k-char solo baseline. The study that showed the +0.250 gain gave every peer
the full 1024.

When refinement is expected, all workers now pack as Generalist (full budget).
Role identity is unchanged, so tier-gating and accounting still see the real
roles; only the packing budget is uniform.

205 engine tests pass; clippy -D warnings and fmt clean.

* fix(moa): text workers get a text preamble, not the tool-turn one

Fourth divergence between the measured study and the shipped path.

Every production worker was prepended MOA_PREAMBLE: "Respond with your best
answer or tool call. Be direct." On a text turn that is wrong twice: there is
no tool to call, and "be direct" pushes workers toward stubs. These drafts are
the *input* to the refinement round, so brevity compounds — measured
end-to-end, MoA answered ~3.3k chars against a ~4.1k-char solo baseline and
lost on judged quality. The study that showed the gain gave workers no such
instruction.

Text turns now get a preamble that says the parts will be combined and asks for
the most accurate and complete answer. Tool turns keep the original wording.

205 engine tests pass; clippy -D warnings and fmt clean.

* fix(moa): don't give the reducer the worker preamble

Fifth divergence between the measured study and the shipped path.

The reducer's system prompt began with the *worker* preamble — "multiple models
are answering this in parallel, give your most accurate answer" — and then
immediately contradicted it with "you have been given several candidate
responses; synthesize them". The combiner was being told it is also one of the
answerers. The measured study gave the synthesizer the synthesis framing alone.

`reducer_system_prompt` now passes the agent's own system prompt (tool guidance
stripped on text turns) without the worker preamble.

205 engine tests pass; clippy -D warnings and fmt clean.

* Revert "fix(moa): don't give the reducer the worker preamble"

This reverts commit fb1281a0.

Measured, the change made things worse, not better:

  with preamble (17/39/24)  ->  without (15/28/37)
  MoA mean output 3697 chars -> 3534 chars

My reasoning was that the worker preamble ("multiple models are answering in
parallel") gave the reducer the wrong role and contradicted the synthesis
instruction. The e2e says otherwise: that preamble also says "the best parts of
each will be combined; give your most accurate and complete answer", and the
reducer was evidently using it to produce fuller answers. Removing it shortened
the output and lost ground.

Fifth hypothesis, first one the measurement rejected. Keeping the behaviour the
evidence supports.

* fix(eval): the e2e judge was scoring length, not quality

Analysis of the 80-trial e2e run, conditioned on relative output length:

  MoA LONGER  than solo:  n=25  win 13  tie 12  loss  0   (100% winrate, p=2e-4)
  MoA SHORTER than solo:  n=55  win  4  tie 27  loss 24   ( 14% winrate, p=2e-4)
  point-biserial r(length delta, verdict) = +0.681
  mean delta when MoA wins: +682 chars; when it loses: -1337 chars

Zero losses whenever MoA was longer. The judge prompt asked for "accurate,
complete, and useful" — "complete" reads as "longer" — so the metric was
verbosity, not quality. This is the exact bias the committee study controls for
(position swap + length reporting) and that I failed to carry into the e2e
harness.

Consequence: "MoA loses 17/39/24 end-to-end" is NOT a valid quality finding,
and four of the five fixes measured against it were partly optimising toward a
biased judge. The correctness fixes stand on their own merits (a real panic,
truncation destroying content, prompts contradicting the evidence); the
*scoreboard* they were measured against did not.

Judge now scores correctness/relevance only and is explicitly told length is
not quality.

* docs(moa): withdraw the length-biased numbers, record the corrected ones

The judge that produced the small-pool and e2e results scored length, not
quality (r = +0.681 between length delta and verdict; the longer answer won
13-0). Re-run with a length-controlled judge, r fell to +0.132.

Corrected small-pool result (n=80, length-controlled):

  committee (1 round) vs solo   6 / 73 / 1   p = 0.125    ns
  layered   (2 rounds) vs solo 11 / 68 / 1   p = 0.0063   significant
  layered vs committee          3 / 77 / 0   p = 0.25     ns

The core claim survives: a small-model mesh beats its best member, but only
with the refinement round. The magnitude does not — 42/66/12 is withdrawn, most
of those "wins" were length artifacts and are now ties. Ties dominate (68/80):
on most prompts a small mesh and a single small model are indistinguishable.

The strong-pool section keeps its numbers and gains a note explaining why: the
bias ran against the winner there (the committee's answers were shorter and won
anyway, 40-14 on the shorter-only subset), so that result is unaffected.

Status now records end-to-end parity (5/65/10, p=0.30) rather than the earlier
loss, and lists closing the harness-vs-production gap as outstanding.

* fix(moa): commit the grace_finalizes field definition

HEAD did not compile. `lib.rs` set `GatherPolicy.grace_finalizes` (committed in
"grace must bound the wait, not forfeit the refinement round") but the field
definition in `fanout.rs` was never staged — it sat unstaged in my working tree
across several commits, so every local build passed while the pushed tree was
broken.

Adds the field, its use in the gather loop (grace expiry stops collecting but
does not finalize when refinement is expected), and `grace_finalizes: true` at
the eight fanout test sites.

Verified against a clean worktree checkout of HEAD, which is how the breakage
was found: `cargo build -p mesh-mixture-of-agents` now succeeds there.

* fix(moa): anonymize reducer inputs on text turns

Sixth divergence between the measured study and the shipped path.

Production labelled reducer inputs "[Worker N — model_name]"; the study that
measured the gain used anonymous "[Response N]". Hermes anonymizes reference
outputs for the same reason — a named model invites the aggregator to defer to
the name rather than the content.

Text turns now use "[Response N]". Tool turns keep attribution: there the
reducer arbitrates between proposals, provenance is genuinely useful, and the
tool-path tests pin it. The "## Worker outputs" header is unchanged — two sim
fixtures use it to identify reducer calls.

205 engine tests pass; clippy -D warnings and fmt clean.

* docs(moa): record that anonymization did not close the harness gap

Sixth divergence fixed (named -> anonymous reducer inputs) and measured:

  before  5 / 65 / 10  p=0.30
  after   9 / 59 / 12  p=0.66

Decided trials went 5W/10L -> 9W/12L. That is not an improvement at this sample
size; both runs are parity. Recorded as such rather than as progress.

Also records the gap that remains unexplained — harness 11/68/1 (p=0.0063) vs
shipped 9/59/12 (p=0.66) on the same models, prompts, judge and packing — and
lists the candidates not yet ruled out.

Adds a caution that r(length,verdict) was +0.465 in the latest e2e run vs
+0.132 in the small-pool study, so length bias is not fully suppressed even
with the corrected judge; single-run e2e deltas are directional only.

* fix(moa): text reducer gets only the synthesis framing

Seventh divergence between the measured study and the shipped path, and the
last known one in the reducer prompt.

Production prepended two things the harness never had:

  * the worker preamble ("several models are answering this in parallel") —
    the reducer is the combiner, not one of the answerers;
  * "Reason for synthesis: {reason}" — an internal arbiter string such as
    "3 workers answered with no agreement", which reads to the model as a
    warning that its inputs are unreliable.

Text turns now get exactly what the study measured: the synthesis instruction
plus the candidate answers. Tool turns keep both extras — there the reducer is
arbitrating a real disagreement, the reason is informative, and the agent's
tool guidance matters.

Note: an earlier commit removed only the preamble and appeared to make things
worse, but that was measured with the length-biased judge, so the result was
invalid and was reverted. This re-tests both extras together under the
corrected judge.

205 engine tests pass; clippy -D warnings and fmt clean.

* Revert "fix(moa): text reducer gets only the synthesis framing"

This reverts commit 031a349a. Measurement rejected it, and this time the
finding replicates.

  v7  preamble + reason kept    5/65/10   decided 33%   MoA len 3606
  v8  + anonymized inputs       9/59/12   decided 43%   MoA len 3679
  v9  - preamble - reason       8/57/15   decided 35%   MoA len 3314

Removing the worker preamble shortened MoA's output on both occasions it was
tried (v6 3534, v9 3314) versus keeping it (v7 3606, v8 3679), and lost ground
on decided trials both times. The first rejection was under the length-biased
judge so it did not count; this one is under the corrected judge and agrees.

Reading: the preamble ("the best parts of each will be combined; give your most
accurate and complete answer") is doing useful work on the reducer even though
it is nominally addressed to a worker. Matching the harness exactly is not
automatically right — the harness had no system prompt at all, while production
has one, and the preamble evidently compensates.

Second hypothesis rejected by measurement rather than argument. Keeping the
behaviour the evidence supports.

* docs(moa): record the two rejected hypotheses for the harness gap

Both prompt-level explanations for the harness-vs-shipped gap were tested and
rejected:

  baseline (v7)                       decided 5/15 = 33%   MoA len 3606
  anonymize reducer inputs (v8)       decided 9/21 = 43%   MoA len 3679
  also drop preamble + reason (v9)    decided 8/23 = 35%   MoA len 3314

Dropping the worker preamble shortened output and lost ground both times it
was tried (v6, v9) and was reverted both times. The preamble does useful work
on the reducer even though it is nominally addressed to a worker — so matching
the harness exactly is not automatically right: the harness sent no system
prompt at all, production sends one, and the preamble compensates.

Anonymization is retained (Hermes does it, the study measured it, it did not
hurt) but 43% vs 33% on ~20 decided trials is not a result.

Notes what remains untested (arbiter short-circuit on converged drafts,
normalize_worker_output reshaping prose) and the honest conclusion: after two
rejected guesses, the gap needs a byte-level diff of the prompts each path
actually sends, not more hypotheses.

* test(moa): diagnostic that dumps every prompt handle_turn actually sends

Two prompt-level hypotheses for the harness-vs-shipped gap were tested live and
rejected, so rather than guess a third this captures what production actually
sends on a text turn — every role/content pair, in order. Deterministic and
offline (canned backends, no network).

What it shows for a 4-model all-small pool:

  calls 1-4  round-1 workers   — text preamble + user prompt, max_tokens=1024
  calls 5-8  refinement round  — byte-identical to the harness's refine()
  call  9    reducer           — harness synthesis prompt, plus the preamble,
                                 a "Reason for synthesis" line and a
                                 "## Worker outputs" header

So the refinement round now matches the harness exactly, and the round-1 and
reducer calls differ only by the preamble and the reducer scaffolding — both of
which were removed in an experiment and measured *worse* (v9), then reverted.

This is the instrument for the next person: run it, diff against the harness
block it prints alongside, and the remaining delta is visible without guessing.

206 engine tests pass; clippy -D warnings and fmt clean.

* fix(moa): refine over all drafts, not the bare minimum

Eighth eval-vs-production divergence, found by reading the gather policy rather
than guessing at prompts.

`min_grace_answers` was set to MIN_DRAFTS (2) — the minimum for the refinement
round to *run*. On a 4-model pool that let grace stop collecting at 2 of 4
drafts, so production refined over half the perspectives while the study that
measured the gain always refined over all of them. Refinement quality scales
with how many drafts it sees, so this directly undercuts the mechanism.

Now waits for all but one draft (floored at MIN_DRAFTS), so a single straggler
still cannot hold the turn and `worker_timeout` still bounds the wait.

206 engine tests pass; clippy -D warnings and fmt clean.

* fix(moa): always synthesize after a refinement round

Ninth divergence, and the first one found from outcome data rather than by
reading code.

Pooling the e2e runs showed the shipped path wins at roughly the harness rate
(22/240 vs 11/80) but loses 32 times where the harness loses once. Splitting
those turns by whether the reducer actually ran located the asymmetry:

  reducer ran     n=66-74   win 8-9   loss 6-10   loss rate  9-14%
  reducer skipped n= 6-14   win   0   loss 2- 4   loss rate 29-33%

MoA won 0 of 20 turns where synthesis was skipped, at triple the loss rate.
That is a pure loss mode.

Cause: `arbitrate` returns `Answer(payload)` when drafts agree, shipping one
worker's text verbatim. That is the right cheap path for raw round-1 answers,
but after refinement it discards the round we just paid for and returns
whichever single small model represented the cluster. Refined drafts agreeing
is not a reason to skip synthesis — it is the best possible input to it.

Text turns that refined now always route to the reducer. Three sim assertions
updated: the client now sees the reducer's synthesis of refined drafts rather
than raw refined text.

206 engine tests pass; clippy -D warnings and fmt clean.

* docs(moa): N=2 is not enough — pool-size comparison

Direct test of "do 2 different models beat 1 of the same strength?", same 40
prompts / same judge / same harness, only pool size differs:

  N=2 (qwen3-8b + llama-3.1-8b)      2W 75T 3L   p=1.00    5 decided
  N=4 (+ granite-4.1-8b, ministral)  11W 68T 1L  p=0.0063  12 decided

Fisher exact on decided win/loss, N=2 vs N=4: p=0.053.

At 8B scale two peers produced no measurable gain — 2 wins against 3 losses.
The same code with four models across four families wins 11-1. Two quantities
move together as peers are added: decided trials rise (5 -> 12; more peers
produce differentiated output instead of near-identical answers the judge ties)
and the win share among them rises (40% -> 92%).

Caveats recorded: 5 decided trials is weak evidence of absence, not evidence of
no effect; and this is a claim about small models — Hermes reports a two-model
frontier preset beating its stronger member by ~6 points, which is untested
here.

* docs(moa): N=2 null was scale, not count — mid-scale N=2 wins cleanly

Mid-scale re-run refutes the "two peers isn't enough" reading. Same prompts,
judge and shipped path, layered arm:

  N=2 8B  (qwen3-8b + llama-3.1-8b)          2W 75T  3L   p=1.00
  N=4 8B  (+ granite, ministral)            11W 68T  1L   p=0.006
  N=2 mid (qwen3-32b + mistral-24b)         49W 24T  6L   p=2e-9   length r=-0.04
  N=4 str (32b agg + 14b/24b/minimax)       90W 11T 19L   p=3e-12  length r=+0.30

The N=2-mid result is the study's cleanest: no length confound, MoA answers
shorter than solo, still won 93% of shorter-MoA trials. So member capability,
not raw count, is the active variable — 8B needs ~4 models, 24-32B needs 2.

Records the deciding caveat: this shows *ensembling* beats the best single
member, not that cross-model *diversity* causes it — the baseline is
strength-matched, not compute-matched. A compute-matched Self-MoA arm (two
samples of the same model) is running to settle it; if it ties Mixed, the claim
becomes "test-time ensembling beats one draw".

* docs(moa): Self-MoA settles it — ensembling, not diversity, is the mechanism

Compute-matched test (both spend 2 drafts + refine + synthesize; only member
identity differs):

  Mixed (qwen3-32b + mistral-24b)   49W 24T 6L   p=1.8e-9   length r=-0.04
  Self  (qwen3-32b x2)              48W 23T 2L   p=2.3e-12  length r=-0.04

Mixed vs Self: Fisher exact p=0.27 — indistinguishable. Both crush the single
best member, both confound-free (MoA answers shorter than solo, ~93% winrate on
shorter-MoA trials).

Different-family membership is not required. The active ingredient is test-time
ensembling — several sampled drafts (temp 0.8), a refinement round, synthesis —
and it works from repeated samples of one model as well as from different ones.
Matches the Self-MoA paper (arXiv:2502.00674).

Practical: the mesh does not need a curated diverse pool; any >=2 sufficiently
capable participants beat picking one, distinct models or repeated instances.

* docs(moa): the 8B ladder — need ~4, and same-vs-different doesn't matter

Same prompts/judge/harness, layered arm:

  2x 8B different (Qwen+Meta)   2W 75T 3L   p=1.00
  2x 8B same (Qwen x2)          2W 78T 0L   p=0.50
  3x 8B different               3W 76T 1L   p=0.63
  4x 8B different              11W 68T 1L   p=0.006

Two 8B instances of the SAME model do exactly what two different ones do
(nothing) — confirming count/strength drive the result, not family identity.
Four 8B models is where it starts winning. Open gap: 4x same-model 8B not run,
so whether the 8B N=4 win needs distinct models or just four drafts is
extrapolated (mid-scale Self-MoA says four drafts alone should suffice).

* fix(moa): refine on correlated drafts (homogeneous pools), not just small ones

The refinement gate keyed on tier: `Auto` refined only when every model was
small, and skipped whenever a big-tier model was present. That is wrong for the
case the mesh goal cares about — a pool of one model repeated across nodes.

Measured (evals/moa-openrouter/RESULTS.md), refinement's value tracks draft
*correlation*, not size:

  same-model 32B x2:   48/2 with refinement vs 35/10 without  (decisive)
  diverse mid 32B+24B: 49/6 layered       vs 47/4 single      (~no gain)
  all-small 8B x4:     needs the round to beat its best member

So `Auto` now refines when the pool is homogeneous (same canonical base — the
repeated-instance / quant case) OR all small-tier, and skips the extra fan-out
only for a diverse pool that already has a big-tier synthesizer.

Adds `worker::canonical_base_name` (mirrors the host's dedup) and
`pool_is_homogeneous`, with unit tests; refinement tests updated to pin the new
homogeneous-big-tier behaviour.

212 engine tests pass; clippy -D warnings and fmt clean.

* feat(moa): admission control — weak workers don't join a strong pool

A modest node must not drag down a committee that already has a stronger
member. Aggregation quality tracks proposal quality (Self-MoA,
arXiv:2502.00674), and an 8B draft added to a 24-32B pool is expected
noise-to-harm.

`build_moa_config` now applies admission control before the >=2 check: when the
pool mixes tiers, small-tier workers are dropped and only the big-tier ones
kept. Consequences:

  * mixed pool (32B + 8B)  -> 8B excluded, 32B committee (or solo if alone)
  * all-small (8B x4)      -> untouched (the case that wins)
  * all-big / homogeneous  -> untouched

A lone strong model after exclusion falls through the >=2 check and simply
serves solo — the safe outcome. `backends`/`models` are parallel vecs linked by
backend_index, so both are rebuilt and reindexed together.

Four unit tests pin the matrix (drops small when big present, keeps all-small,
keeps all-big, keeps homogeneous-big). 68 moa_gateway tests pass; clippy -D
warnings and fmt clean.

* feat(moa): same-model mesh forms a pool (self-fill from extra instances)

A mesh where every node serves the same model got no MoA at all:
build_moa_config resolves one worker per canonical base, so N same-model nodes
collapsed to 1 worker and failed the >=2 check. Self-MoA (arXiv:2502.00674)
shows repeated sampling of one model ensembles as well as different models, so
this is exactly the "add a modest node and it helps" case that was missing.

When distinct-model resolution leaves a single model, `self_fill_from_extra_
instances` now adds extra reachable *nodes* serving that same model as workers
(distinct remote endpoints only, never the same node twice, capped at 2). So:

  1 node,  1 model   -> 1 worker  -> solo (unchanged, no surprise cost)
  2 nodes, same model -> 2 workers -> MoA committee

Combined with the refinement-gate fix (homogeneous pools now refine), a
same-model pool runs the layered path that measured 48/2 for 32B x2.

Extracted pool assembly (resolve loop + admission + self-fill) into
`assemble_worker_pool` to keep build_moa_config under the cognitive-complexity
limit. 68 moa_gateway tests pass; clippy -D warnings and fmt clean.

* chore(moa): drop diag_prompt_diff scratch test

It was an assertion-free diagnostic that dumps prompts to stdout — useful
during the harness-vs-shipped investigation, but it always passes and does not
belong in the merge set. The prompts it printed are now understood and the
findings are recorded in evals/moa-openrouter/RESULTS.md.

* docs(moa): admission control validated — weak node in strong pool is no-upside

Arm C (32B x2 + one 8B, weak node admitted) vs arm B (32B x2):

  B  48W 23T 2L  p=2e-12  decided winrate 96%
  C  50W 25T 5L  p=2e-10  decided winrate 91%

Fisher B-vs-C p=0.44 (not separable at n=80) but one-way: admitting the weak
node never helped and raised losses 2 -> 5. Both still beat solo, so a weak
node doesn't collapse the pool, but it adds cost for no upside and a small tail
risk. This is the measured basis for tier-based apply_admission_control.

* fix(moa): admission control must not collapse a lone-strong pool to solo

You asked the right question: we showed admitting a weak node HURTS when the
pool already has two strong (arm C), but never tested where admitting HELPS.

The missing cell — one strong + one weak — is decisive and overturns the
first-cut rule:

  32B + 8B layered vs solo 32B:  47W 27T 5L  p=1.3e-9  length-clean (r=-0.01)

Admission control rejected the 8B here, collapsing the pool to a solo 32B. But
the mixed committee beats solo decisively, so rejecting it threw away MoA in
exactly the core "modest node joins a strong node and helps" case.

Rule corrected: drop small-tier workers only when >=2 big-tier remain (a real
committee survives). If dropping would collapse to solo, keep the mix.

  32B x2 + 8B  -> drop 8B (committee survives; 8B adds nothing, arm C)
  32B + 8B     -> keep both (dropping = solo, and the mix wins 47/5)

Two admission tests updated/added to pin both cases. 69 moa_gateway tests pass;
clippy -D warnings and fmt clean.

* feat(moa): model=mesh gracefully degrades to single-model instead of 503

Before: a `model=mesh` request on a lone node (or a mesh with <2 workers)
returned 503 "MoA requires ≥2 models". That makes `mesh` unusable as a default
model — it only worked once a committee could form.

Now: when `build_moa_config` can't form a committee but a real model is
available, `try_handle_moa` rewrites `model=mesh` to that model and hands the
stream back to normal single-model routing. `mesh` works everywhere —
passthrough on one node, committee once a second worker joins. Only a node with
no model at all still 503s.

Both call sites handle the new fall-through:
 - passive/transport path already routes a returned `Some(stream)` normally;
 - host/ingress path: `try_handle_moa_intercept` gained a `Degraded { stream,
   model }` result. The pre-computed `decision.effective_model` is stale
   ("mesh") after degradation, so the degraded model name is threaded into
   `route_request`, and the pipeline classifier (computed against "mesh") is
   skipped for degraded turns.

Unchanged from main was the hard 503; this is the missing piece for `mesh` as a
superior always-on drop-in. 1859 host tests pass; clippy -D warnings and fmt
clean.

* feat(moa): cap the committee at 4 workers on large meshes

Fan-out cost is ~2N+1 model calls per turn (N drafts + N refines + 1
synthesis), and measured quality is flat past ~4 workers while latency and
spend keep climbing. On a big shared mesh — say 20 nodes — an uncapped pool
fanned out to all of them: 41 calls for no quality gain.

`cap_committee` now trims the assembled pool to the best MAX_COMMITTEE_WORKERS
(4) by the same capability ranking used for actor selection (gossiped tool_use,
then size, then stable index). Nodes beyond the cap are standbys — they still
serve direct traffic, just not this committee.

Runs after admission control and self-fill, so the cap applies to the final
worker set. Extracted as a helper to keep assemble_worker_pool under the
cognitive-complexity limit. 69 moa_gateway tests pass; clippy -D warnings and
fmt clean.

* test(moa): pin partial-worker-survival robustness

sim_all_workers_fail covers total failure (clean structured error). This covers
the common mesh reality: nodes flicker, so a subset of the committee dies
mid-turn while the rest answer. MoA must degrade to survivors, not the error
path.

Three cases pinned:
 - half the committee dies, rest answer -> turn completes, all 4 accounted
   (2 succeeded, 2 recorded failed, none silently dropped)
 - lone survivor (3 of 4 die) -> turn still answers
 - slow-failing worker -> does not stall the turn past the survivors

Locks the pre-existing best-effort fanout behaviour against regression now that
assembly (admission, self-fill, cap) is more complex. 214 moa tests pass;
clippy -D warnings and fmt clean.

* refactor(moa): split pool assembly out of workers.rs into pool.rs

workers.rs had grown to 1215 lines (over the 1k guideline) mixing two
responsibilities: worker-pool assembly/selection and the model backends +
config orchestration. Per CodeRabbit review, extract the assembly half.

pool.rs (658 lines) now owns: assemble_worker_pool, resolve_one_worker_from_
aliases, add_worker_backend, group_aliases_by_canonical_base, is_locally_served,
apply_admission_control, self_fill_from_extra_instances, cap_committee,
compute_actor_candidates, canonical_base_name, WorkerBackendResolution, the two
size caps, and their tests.

workers.rs (578 lines) keeps: build_moa_config (orchestrator), the
thinking-override helpers, patience_profile, the Local/Remote model backends +
parse_quic_http_response, and their tests.

Pure mechanical move, no behavior change. Backends and canonical_base_name made
pub(super) for cross-module use. 69 moa_gateway tests pass (unchanged); clippy
-D warnings and fmt clean.

* test(moa): loosen partial-survival assertion that raced under early-exit

`turn_completes_when_some_workers_die` asserted exactly succeeded==2/failed==2,
but early-exit consensus can abort a live worker once a usable answer is in
hand, so the succeeded/aborted split is timing-dependent. It passed locally but
raced red in CI (Rust crate tests shard 0).

The robustness contract is unchanged and still pinned: all four dispatched
workers appear in summaries (none silently dropped) and the two dead ones are
recorded as failed (>=2). Dropped the exact success-count assertion. Stable
across 5 consecutive local runs.

* fix(moa): model=mesh degrades to single-model serving, verified live

`model=mesh` now works as a universal virtual model from one node upward:
  1 node / 1 model  -> serves that model (single-model passthrough)
  >=2 workers       -> real MoA committee (fan-out + synthesis)
  0 models          -> 503 (correct: nothing to serve)

Two bugs found and fixed against a live 2-node mesh (MacBook 3B + mini 1B):

1. request.raw not rewritten. degrade patched model_name/body_json but the
   forwarded request rides on request.raw bytes, which still said "mesh", so
   the embedded openai-frontend 404'd. Now uses rewrite_model_field (patches
   raw + body + Content-Length together; already unit-tested).

2. wrong model source. degrade read models_being_served(), empty on a fresh
   serve node. /v1/models draws from three sources; degrade now tries them in
   order: callable_models(targets) -> models_being_served() -> serving_models().

Live-verified: single node model=mesh returns HTTP 200 with a real answer
(was 503/404); 2-node mesh model=mesh returns x-moa-turn=fanout workers=2.

Known cold-start transient: the very first mesh request on a fresh serve node
can 404 for ~1-2s until the routing table populates; solid once warm.

* fix(moa): tier from verified GGUF/gossiped size, not name guessing

Addresses two i386 P1s: destructive admission and committee-cap decisions were
made from a name heuristic that treats every unparseable name as big-tier, so a
small fine-tune/alias (`my-assistant`) could bypass the weak-worker filter or
become the reducer — contradicting the branch's core "weak can't soil strong"
claim.

Producer (each serving node, has its own GGUF): scan_gguf_total_parameters sums
tensor element counts for the exact stored-parameter count and publishes it via
the existing ServedModelMetadata.parameter_count_b. profile.rs prefers this over
name parsing; missing => None (unknown), never a guess.

Consumer (MoA orchestrator, has NO peer GGUFs): reads the gossiped
parameter_count_b off served descriptors. New SizeTier {Small,Big,Unknown}:
  * admission excludes only VERIFIED small, counts only VERIFIED big toward the
    ">=2 big remain" gate; Unknown is never strong and never filtered.
  * cap_committee ranks by verified size, NOT compute_actor_candidates
    (tool_use-first), which could evict a big model for tool-advertising small
    ones on an answer turn.
Name parsing (model_param_size_b) remains a lower-confidence fallback only.

71 moa_gateway tests (2 new: unknown-never-filtered, name-parse fallback);
model-artifact + host clippy -D warnings and fmt clean.

* fix(moa): drop name-based size fallback — no GGUF size ⇒ weakest

Per i386: parsing NNb from a served name is brittle and a destructive
admission decision must not rest on an unverified label. Removed the name
fallback entirely.

Producer (profile.rs): parameter_count_b comes ONLY from the GGUF tensor sum.
No local GGUF to sum ⇒ None (no guessed count). Deleted the now-dead
parameter_count_b_from_text + its test.

Consumer (pool.rs): tier_for uses only the gossiped verified size. Dropped
SizeTier::Unknown — a model with no verified size is Small (weakest), so an
unverifiable label can never masquerade as big and displace a real strong
worker. cap_committee ranking updated (no-size ranks last). Admission excludes
only verified-small; with no verified big there is nothing to protect so an
all-unsized pool is untouched.

Tests updated: unsized-worker-is-weakest (excluded next to verified big),
no-verified-sizes keeps all. 71 moa_gateway + 28 profile tests pass; clippy -D
warnings and fmt clean.

* fix(moa): iron law — single physical endpoint never fakes a committee

Self-fill could re-add the same physical node (or an endpoint already backing
the sole worker), turning ONE box into a fake 2-worker committee that hits it
twice for near-identical drafts. Catastrophic for mesh mode: a lone node must
degrade to single-model serving, never pretend to be a committee.

Rewrote self_fill_from_extra_instances to build the pool from DISTINCT physical
endpoints only: the local skippy port (if this node serves the model and
context fits) plus each distinct remote peer from hosts_for_model. If fewer
than two distinct endpoints serve the model, the pool stays the single worker
and MoA degrades to single-model. A single endpoint can no longer appear twice.

Also fixes the CodeRabbit self-fill bugs (re-adds same peer; skipped context
eligibility) — context fit is now checked on the local endpoint, and endpoints
are distinct by construction.

71 moa_gateway tests pass; clippy -D warnings and fmt clean.

* fix(moa): reject missing/malformed messages with 400 (homelab API-1)

A `model=mesh` request with no `messages`, a non-array `messages`, or an empty
array fell through to the workers and fabricated a 200 answer from nothing
(homelab validation API-1). try_handle_moa now requires a present, non-empty
`messages` array and returns 400 otherwise, before any model call.

Verified live: {"model":"mesh"}, string messages, and [] all now 400; a valid
request still returns 200. 71 moa_gateway tests pass; clippy -D warnings + fmt
clean.

* feat(moa): tier-aware committee width — small pools fan out to 6

Width sprint (evals/moa-openrouter, aggregator qwen3-8b, 8B peers, shipped
committee path) shows the fan-out cap was throttling the pools that benefit
most:

  6× diverse 8B vs best member: 12W/65T/2L, p=0.013  (wins)
  4× diverse 8B:                 5W/73T/0L, p=0.06   (marginal)
  2× diverse 8B:                 2W/77T/1L, p=1.0    (null)

Small, weak drafts need WIDTH — more independent proposals — before
aggregation clears the best member. A verified big model, by contrast, wins at
2 and gains nothing past ~4.

- committee_cap is now tier-aware: COMMITTEE_CAP_SMALL=6 for all-small pools,
  COMMITTEE_CAP_BIG=4 when a verified big model is present (replaces the flat
  MAX_COMMITTEE_WORKERS=4).
- eval harness: the three per-trial judge comparisons now run concurrently
  (tokio::join!) instead of three serial awaits — ~3x faster judging phase, no
  behaviour change.

Also measured (recorded in RESULTS.md): the refine round never beats single
aggregation at 8B (refine-vs-single null in every cell) — Hermes' cheaper
single-synth cadence matches Together's layered shape here; and at 8B diversity
matters (6 diverse >> 6 same), unlike mid-scale.

72 moa_gateway tests (new: committee_cap_is_wide_for_small_pools_tight_for_big);
clippy -D warnings and fmt clean.

* fix(moa): don't refine small pools — width + single-agg wins, refine is dead cost

The width sprint measured refine-vs-single-aggregation as null in every 8B cell
(2/4/6 workers, diverse and same). Small pools win by WIDTH under a single
aggregation, not by the extra serial refine pass. Previously RefinementPolicy::
Auto ran the round for ALL-small pools — paying two synthesis passes for no
measured gain.

Auto now refines only for a homogeneous pool that is NOT all-small (i.e.
same-model at real scale, where correlated drafts + the round measured 48/2 vs
35/10). All-small and diverse pools skip it — matching Hermes' cheaper
single-synth cadence, where refine buys nothing.

Effect: an all-small mesh turn drops from 2 serial synthesis passes to 1,
roughly halving added latency, with no measured quality loss.

Tests: auto_skips_an_all_small_pool (was auto_refines_...); the 5 all-small
mechanics sim tests (straggler/grace/degradation) pinned with Always so they
still exercise the round; big-pool gate tests keep Auto. 178 + sim tests pass;
clippy -D warnings and fmt clean.

* fix(moa): make the shipped path deliver the measured win

Three shipped-path bugs meant `handle_turn` never ran the committee it was
supposed to. Found by measuring the shipped entrypoint at capable scale.

1. Grace pre-empted synthesis. `first_answer_grace` armed on the FIRST answer
   and finalized the turn, so on fast backends it fired ~every turn and shipped
   one worker's text. Measured 80/80 EarlyExit at 32B scale. Now grace
   finalizes on TOOL turns only; on answer turns it is a collection deadline —
   stop waiting for the tail, then synthesize what arrived. Private-mesh window
   widened 3s -> 10s so a normal committee completes before it arms.

2. Role-tiered draft budgets (Fast 256 / Specialist 512) truncated the drafts
   that synthesis consumes. Those tiers existed only to make the grace
   fast-path cheap; grace no longer finalizes answer turns, so every answer-turn
   worker now gets the full budget.

3. Answer turns shipped one worker verbatim when drafts agreed. Agreeing drafts
   are the best input to synthesis, not a reason to skip it. Answer turns with
   >=2 workers always synthesize now. Also drops the worker preamble from the
   reducer prompt — the synthesizer is not one of the parallel answerers, and
   the contradictory framing cost a weak aggregator.

Measured through `moa::handle_turn` at production defaults, 40 prompts x 2
draws, out-of-family judge, length-controlled:

  32B/24B/35B-MoE/14B pool vs best member:  71W / 8T / 1L   p<0.0001
    (was 26W/14T/40L before these fixes; wins 28/29 even when MoA is SHORTER,
     so it is not a length artifact)

Robustness contracts unchanged and still verified: slow worker cannot stall a
turn, lone survivor answers, hanging refiner cannot hold the turn, patience
expiry releases held consensus.

* fix(moa): all-small pools serve the best member instead of a committee

Measured through the shipped path (OpenRouter, 8B-class peers with an 8B
reducer, 40 preregistered prompts x 2 draws, out-of-family position-swapped
judge, vs the pool's best member alone):

  2x 8B:  0W/43T/37L  p<0.0001
  6x 8B:  5W/52T/23L  p=0.0009

The committee never won and lost about a third of decided trials, with
consistently shorter answers (3236-3372 chars vs ~4070 solo). A capable pool is
the opposite (71W/8T/1L, p<0.0001), so this is a statement about *this*
configuration -- a weak reducer synthesizing weak drafts -- not about
small-model MoA in general. The untested cell is small peers with a strong
reducer; if a mesh gains a big-tier model the pool is no longer all-small and
MoA engages again.

Rather than ship a measured regression, an all-small pool now collapses to its
single strongest member, so the caller degrades to serving that model directly.

Also corrects the private-mesh grace assertion to the widened 10s default.

The earlier "6x 8B beats its best member (12W/2L, p=0.013)" harness result did
not replicate: the same rig on the same 40 tasks now gives 3W/76T/1L (p=0.63),
with 11 of 40 tasks flipping verdict. It rested on ~14 decided trials out of 80
(the rest ties) and was a single unreplicated run. Withdrawn.

* docs(moa): withdraw the unreplicated 6x8B win, record all-small shipped results

The width-sprint headline (12W/2L, p=0.013) did not replicate: same rig, same
tasks, same pool now gives 3W/76T/1L (p=0.63), 11 of 40 tasks flipping. It rested
on ~14 decided trials from a single unreplicated run.

Records the shipped-path all-small measurements that motivated the best-member
gate (2x: 0W/37L, 4x: 5W/12L, 6x: 5W/23L), scopes the claim to weak-reducer
configurations, and documents known methodology limitations (tie bucket conflates
failures, per-draw vs per-prompt significance, unverified best member, single judge).

* fix(moa): min_grace_answers back to 1 — N-1 stalled turns to worker_timeout

Public-mesh comparison against released v0.74.0 (same 3B model, same prompts,
`model=mesh`) exposed a liveness regression I introduced earlier today:

  released v0.74.0:  4.0s / 3.2s / 11.3s   (reducer never ran)
  branch (N-1 gate): 61.0s / 6.7s / 61.7s  (reducer ran, SHORTER answers)

`min_grace_answers` gates whether grace can ARM at all, so any value above 1 is
a liveness hazard. With N-1 on a 6-worker public-mesh pool where two peers never
returned, only 4 answers ever arrived, grace could never arm, and the turn rode
`worker_timeout` (60s) instead. The two 61s turns had 6 workers/4 ok and 6/3; the
fast 6.7s turn had 4 workers/3 ok, where N-1=3 was satisfiable. 3/3 consistent.

Width comes from the grace WINDOW (10s), not a count gate: healthy peers land
inside it and are all synthesized, while a dead peer costs 10s rather than 60s.
This also restores the exact configuration the capable-pool win was measured
under (71W/8T/1L, p<0.0001) — that run predates the N-1 gate — and the gate
bought nothing on small pools either (8W/17L with it, 9W/17L without).

* docs(moa): correct the gateway doc and flag the withdrawn small-pool claim

MOA_GATEWAY.md described behaviour that no longer holds:

- "requires >=2 distinct models, returns 503 if fewer" -> a single model (or an
  all-small pool collapsed to its best member) now degrades: the virtual `mesh`
  name is rewritten to a real served model and routed normally. Only a node
  serving nothing returns 503.
- name-derived tiering -> tiering now comes from verified GGUF tensor sums
  gossiped as `parameter_count_b`, split at 10B, with unverified sizes ranking
  small so an unparseable alias cannot pose as big-tier. Name parsing mis-tiered
  real models (gemma-4-E4B stores 7.5B, not 4B).
- documents pool shaping in order (admission, all-small best-member fallback,
  committee cap) with the measured numbers behind each.
- records that answer turns pack every worker at the full budget.
- stale peer-timeout row (15s) -> 60s worker timeout with the 10s grace window.

RESULTS.md: the width-sprint section now carries an explicit WITHDRAWN banner
pointing at the replication failure, so the 6x8B number cannot be cited from the
middle of the file.

* test(moa): pin the tool-turn contract against a live multi-model pool

The tool path is deliberately asymmetric — route to the single best tool-caller
rather than fan out and vote, because voting on tool calls measured
null-to-harmful. That is only safe if structured `tool_calls` still come back
intact when several models are on the wire, so this asserts the contract through
the shipped `handle_turn` with a real pool: a tool prompt yields exactly one
well-formed call whose arguments parse as JSON and whose name is an offered
tool, and a no-tool prompt does not invent one.

Measured live (OpenRouter, both cells pass):

  4x 8B pool  -> dispatched=4 ok=3 reducer=true
    list_dir({"path":"src"})
    search({"path":".","pattern":"MeshError::Timeout\\(.*?\\)"})
    run_command({"cmd":"pytest --verbose"})
    no_tool_concept: no call invented
  4x 24-35B pool -> dispatched=1 ok=1 reducer=true
    same shapes, single actor

The dispatch difference is `ReferencePolicy::Auto` working as measured: a
small-tier actor gets tool-free advisors (+0.017 uplift), a big-tier actor acts
alone (advisors measured -0.037 there). The 4-worker cell also survived a worker
flaking (ok=3 of 4) and still emitted a valid call, which is the ensemble-active
case that was previously only covered by replay fixtures.

Asserts the contract, not a quality delta — the tool chosen may legitimately
differ from a single model's first move, so only a non-offered tool or malformed
arguments fail.

---------

Co-authored-by: Michael Neale <14976+michaelneale@users.noreply.github.com>
This commit is contained in:
Michael Neale 2026-08-06 18:41:34 +10:00 committed by GitHub
parent 32eedbe452
commit 96ef6227f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 16657 additions and 897 deletions

View file

@ -1 +1,3 @@
pub use model_artifact::gguf::{GgufCompactMeta, GgufKvCacheQuant, scan_gguf_compact_meta};
pub use model_artifact::gguf::{
GgufCompactMeta, GgufKvCacheQuant, scan_gguf_compact_meta, scan_gguf_total_parameters,
};

View file

@ -24,11 +24,16 @@ pub(crate) fn served_model_metadata_for_path(
.parameter_size
.clone()
.or_else(|| parameter_size_from_text(model_name));
let parameter_count_b = parameter_count_b_from_text(&format!(
"{} {}",
model_name,
meta.parameter_size.as_deref().unwrap_or("")
));
// Authoritative size: sum the GGUF tensor element counts. This is
// the ONLY source — no name-based fallback. If a served model
// cannot be summed from its GGUF, it advertises no size and MoA
// tiering treats it as the lowest-param (weakest) model rather than
// guessing from a brittle name label (per i386 review).
let parameter_count_b = path
.exists()
.then(|| crate::models::gguf::scan_gguf_total_parameters(path))
.flatten()
.map(|total| total as f64 / 1e9);
let kv_head_count = meta.effective_kv_head_count();
crate::mesh::ServedModelMetadata {
architecture: non_empty(meta.architecture),
@ -51,7 +56,10 @@ pub(crate) fn served_model_metadata_for_path(
}
None => crate::mesh::ServedModelMetadata {
parameter_size: parameter_size_from_text(model_name),
parameter_count_b: parameter_count_b_from_text(model_name),
// No GGUF to sum -> no authoritative size. Advertise none rather
// than a name-guessed count (per i386 review); MoA treats a
// sizeless model as the weakest.
parameter_count_b: None,
quant: quant_from_text(model_name),
..Default::default()
},
@ -99,50 +107,9 @@ fn parameter_size_from_text(text: &str) -> Option<String> {
})
}
fn parameter_count_b_from_text(text: &str) -> Option<f64> {
static MULTIPLIED_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)([bm])").unwrap());
static SIMPLE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)([bm])").unwrap());
let mut best: Option<f64> = None;
for captures in MULTIPLIED_RE.captures_iter(text) {
let Some(left) = captures.get(1).and_then(|m| m.as_str().parse::<f64>().ok()) else {
continue;
};
let Some(right) = captures.get(2).and_then(|m| m.as_str().parse::<f64>().ok()) else {
continue;
};
let Some(unit) = captures.get(3).map(|m| m.as_str().to_ascii_lowercase()) else {
continue;
};
let value = match unit.as_str() {
"b" => left * right,
"m" => (left * right) / 1000.0,
_ => continue,
};
best = Some(best.map_or(value, |current| current.max(value)));
}
for captures in SIMPLE_RE.captures_iter(text) {
let Some(count) = captures.get(1).and_then(|m| m.as_str().parse::<f64>().ok()) else {
continue;
};
let Some(unit) = captures.get(2).map(|m| m.as_str().to_ascii_lowercase()) else {
continue;
};
let value = match unit.as_str() {
"b" => count,
"m" => count / 1000.0,
_ => continue,
};
best = Some(best.map_or(value, |current| current.max(value)));
}
best
}
#[cfg(test)]
mod tests {
use super::{parameter_count_b_from_text, parameter_size_from_text};
use super::parameter_size_from_text;
#[test]
fn extracts_parameter_size_labels() {
@ -155,11 +122,4 @@ mod tests {
Some("8x7B")
);
}
#[test]
fn extracts_total_parameter_count_b() {
assert_eq!(parameter_count_b_from_text("Qwen3-32B-Q4_K_M"), Some(32.0));
assert_eq!(parameter_count_b_from_text("mixtral-8x7b"), Some(56.0));
assert_eq!(parameter_count_b_from_text("235B-A22B"), Some(235.0));
}
}

View file

@ -743,6 +743,14 @@ enum MoaInterceptResult {
/// Not an MoA request — caller should continue with normal routing,
/// reusing the returned stream.
NotMoa(tokio::net::TcpStream),
/// MoA could not form a committee but degraded `model=mesh` to a real
/// single model (already rewritten on the request). Caller routes it
/// normally, but must use this model rather than the stale
/// `decision.effective_model` (still "mesh").
Degraded {
stream: tokio::net::TcpStream,
model: Option<String>,
},
}
/// Dispatch to the MoA gateway when `model == "mesh"`. Self-gates on the
@ -756,14 +764,15 @@ async fn try_handle_moa_intercept(
if decision.effective_model.as_deref() != Some(moa::VIRTUAL_MODEL_NAME) {
return MoaInterceptResult::NotMoa(tcp_stream);
}
// `try_handle_moa` self-gates on the model name and consumes the
// stream when it accepts. The outer gate above guarantees the gate
// matches, so the inner call always returns `None` here — the stream
// is gone, either with the MoA response, a 503, or a 400. Discard
// the return value explicitly. The previous shape kept an
// `if let Some(_) = … { tracing::error!(...) }` branch that could
// never fire and made the control flow confusing to read.
let _ = crate::network::openai::moa_gateway::try_handle_moa(
// `try_handle_moa` self-gates on the model name. It consumes the stream and
// returns `None` when it owns the response (a MoA turn, a 400, or — no
// model at all — a 503). But when it cannot form a committee yet still has
// a model to serve, it degrades: it rewrites `model=mesh` to a real model
// (on `request`) and hands the stream *back* as `Some`, so a lone node
// answers as an ordinary single-model request instead of erroring. In that
// case the pre-computed `decision.effective_model` is stale ("mesh"), so we
// carry the degraded model name out for the caller to route on.
match crate::network::openai::moa_gateway::try_handle_moa(
ctx.route.node,
tcp_stream,
request,
@ -771,9 +780,18 @@ async fn try_handle_moa_intercept(
Some(ctx.route.targets),
decision.required_tokens,
)
.await;
proxy::release_request_objects(ctx.route.node, &request.request_object_request_ids).await;
MoaInterceptResult::Handled
.await
{
Some(stream) => MoaInterceptResult::Degraded {
stream,
model: request.model_name.clone(),
},
None => {
proxy::release_request_objects(ctx.route.node, &request.request_object_request_ids)
.await;
MoaInterceptResult::Handled
}
}
}
async fn handle_buffered_api_request(
@ -817,14 +835,25 @@ async fn handle_buffered_api_request(
}
};
// Effective model for downstream routing. Normally the pre-computed
// decision, but a degraded MoA turn overrides it with the single model it
// fell back to (the decision still says "mesh").
let mut routing_model = decision.effective_model.clone();
let tcp_stream = match try_handle_moa_intercept(tcp_stream, &mut request, &ctx, &decision).await
{
MoaInterceptResult::Handled => return,
MoaInterceptResult::NotMoa(stream) => stream,
MoaInterceptResult::Degraded { stream, model } => {
routing_model = model;
stream
}
};
let mut tcp_stream = tcp_stream;
if try_pipeline_route(&mut tcp_stream, &mut request, &ctx.route, &decision).await {
// A degraded turn is a plain single-model request; skip the pipeline
// classifier (computed against "mesh") and route it directly.
let degraded = routing_model != decision.effective_model;
if !degraded && try_pipeline_route(&mut tcp_stream, &mut request, &ctx.route, &decision).await {
proxy::release_request_objects(ctx.route.node, &request.request_object_request_ids).await;
return;
}
@ -833,7 +862,7 @@ async fn handle_buffered_api_request(
tcp_stream,
&mut request,
&ctx.route,
decision.effective_model.as_deref(),
routing_model.as_deref(),
decision.required_tokens,
)
.await;

View file

@ -20,6 +20,57 @@ use tokio::net::TcpStream;
pub use self::workers::build_moa_config;
/// Fall back to serving a single real model when MoA cannot form a committee.
///
/// Picks any model advertised in the mesh (local or peer), rewrites the
/// request's `model` from the virtual `"mesh"` name to it, and hands the stream
/// back so the caller routes it as an ordinary single-model request. Returns
/// `None` (503) only when the node genuinely has no model at all.
async fn degrade_to_single_model(
node: &mesh::Node,
targets: Option<&election::ModelTargets>,
tcp_stream: TcpStream,
request: &mut proxy::BufferedHttpRequest,
) -> Option<TcpStream> {
// Prefer the same source `/v1/models` and routing use — the local
// targets table (`callable_models`) — since `models_being_served()` can be
// empty at request time on a fresh serve node. Fall back to the gossiped
// served set for a pure client node that has no local targets.
// Try each source /v1/models draws from, cheapest-first: the local targets
// table (`callable_models`), the gossiped served set, then the node's own
// `serving_models` — the last is what a fresh serve node populates first
// (the others can lag at request time).
let mut candidates = targets
.map(super::ingress::callable_models)
.unwrap_or_default();
if candidates.is_empty() {
candidates = node.models_being_served().await;
}
if candidates.is_empty() {
candidates = node.serving_models().await;
}
let Some(target) = candidates
.into_iter()
.find(|m| m != moa::VIRTUAL_MODEL_NAME)
else {
let _ = proxy::send_503(tcp_stream, "no models available in the mesh").await;
return None;
};
tracing::info!("MoA: <2 workers, degrading model=mesh to single model {target}");
// Rewrite every surface the downstream router reads. The forwarded request
// is driven by `request.raw` (the raw HTTP bytes), so `rewrite_model_field`
// patches raw + body + Content-Length together — rewriting only
// `model_name`/`body_json` left `raw` saying "mesh", so the embedded
// frontend still saw the virtual model and 404'd.
proxy::rewrite_model_field(request, &target);
request.model_name = Some(target);
// Hand the stream back: the caller falls through to normal routing.
Some(tcp_stream)
}
/// Detect `model: "mesh"`, build a mesh-wide MoA config, run the turn,
/// and write the HTTP response (JSON or SSE) directly to the stream.
///
@ -52,11 +103,28 @@ pub async fn try_handle_moa(
return None;
};
// Contract check: `messages` must be a present, non-empty array. Without
// this a request like `{"model":"mesh"}` or a string `messages` field fell
// through to the workers, which fabricated a 200 answer from nothing
// (homelab API-1 defect). Reject before any model call.
match body_json.get("messages") {
Some(serde_json::Value::Array(msgs)) if !msgs.is_empty() => {}
_ => {
let _ = proxy::send_400(tcp_stream, "MoA requires a non-empty `messages` array").await;
return None;
}
}
let enable_thinking = effective_enable_thinking_for_moa(&body_json);
let Some(mut config) = build_moa_config(node, targets, required_tokens).await else {
let _ = proxy::send_503(tcp_stream, "MoA requires ≥2 models available in the mesh").await;
return None;
// Graceful degradation: MoA needs ≥2 workers, but a lone node (or a
// mesh with a single model) should still answer a `model=mesh`
// request rather than 503. Rewrite the virtual model to a real served
// model and fall through to normal single-model routing by handing the
// stream back. `mesh` thus works everywhere: passthrough on one node,
// committee once a second worker joins.
return degrade_to_single_model(node, targets, tcp_stream, request).await;
};
config.enable_thinking = enable_thinking;
@ -65,6 +133,7 @@ pub async fn try_handle_moa(
}
pub(in crate::network::openai) mod context_selection;
mod pool;
mod progress;
mod streaming;
mod workers;

View file

@ -0,0 +1,977 @@
//! MoA worker-pool assembly.
//!
//! Owns the discovery-and-assembly side of the MoA gateway: turning the
//! node's mesh-wide model view into a concrete `(backends, models)` worker
//! pool. `build_moa_config` (in [`super::workers`]) is the orchestrator that
//! calls [`assemble_worker_pool`] and [`compute_actor_candidates`] here.
use super::context_selection;
use super::workers::{LocalModelBackend, RemoteModelBackend};
use crate::inference::election;
use crate::mesh;
use mesh_mixture_of_agents as moa;
use std::collections::HashMap;
/// Boundary between small- and big-tier in billions of parameters. Matches the
/// old single-digit-B name heuristic (19B = small).
const SMALL_TIER_MAX_B: f64 = 10.0;
/// Model size class used for the destructive admission/cap decisions.
///
/// Only a verified gossiped GGUF size can make a model `Big`. A model with no
/// verified size is `Small` — the weakest — so an unverifiable label can never
/// masquerade as strong and displace a real big worker (i386 P1).
#[derive(Clone, Copy, PartialEq, Eq)]
enum SizeTier {
Small,
Big,
}
/// Map of canonical base name → verified size (billions of params) as gossiped
/// by peers through `ServedModelMetadata.parameter_count_b`. In MoA the
/// orchestrator has no peer GGUFs — this is the only authoritative size source.
async fn gossiped_sizes(node: &mesh::Node) -> HashMap<String, f64> {
let mut by_base: HashMap<String, f64> = HashMap::new();
for descriptor in node.all_served_model_descriptors().await {
if let Some(b) = descriptor
.metadata
.as_ref()
.and_then(|m| m.parameter_count_b)
{
let base = canonical_base_name(&descriptor.identity.model_name);
by_base
.entry(base)
.and_modify(|e| {
if b > *e {
*e = b;
}
})
.or_insert(b);
}
}
by_base
}
/// Tier a model: verified gossiped size first, model-name parse as a
/// lower-confidence fallback, `Unknown` when neither yields a size.
fn tier_for(name: &str, sizes: &HashMap<String, f64>) -> SizeTier {
// Verified gossiped GGUF size is the ONLY tiering signal (per i386 review).
// No name-based fallback: a model with no gossiped size is treated as the
// weakest (Small), so an unverifiable label can never masquerade as big and
// displace a real strong worker. `SMALL_TIER_MAX_B` splits small/big.
match sizes.get(&canonical_base_name(name)) {
Some(b) if *b >= SMALL_TIER_MAX_B => SizeTier::Big,
_ => SizeTier::Small,
}
}
/// Try each alias in `aliases` until one resolves to a backend, then stop.
///
/// Aliases are pre-sorted by `group_aliases_by_canonical_base` so the most
/// preferred (locally-served first, then shortest) is tried first. Falls
/// back to longer aliases when the preferred one's peer is unreachable.
#[allow(clippy::too_many_arguments)]
async fn resolve_one_worker_from_aliases(
node: &mesh::Node,
targets: Option<&election::ModelTargets>,
http: &reqwest::Client,
aliases: &[String],
required_tokens: Option<u32>,
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
local_count: &mut usize,
) {
let resolution = WorkerBackendResolution {
node,
targets,
http,
required_tokens,
};
for name in aliases {
if add_worker_backend(&resolution, name, backends, models, local_count).await {
return;
}
}
}
/// Group all advertised model names by their canonical base so each
/// canonical model contributes exactly one worker, but the resolver gets
/// to pick the alias that actually has a reachable backend.
///
/// The earlier shape committed to a single alias per base *before* trying
/// to resolve a backend. Two failure modes:
///
/// 1. The chosen alias is advertised only by a peer that drops between
/// gossip refresh and orchestration — `hosts_for_model` returns
/// empty, the worker is dropped, and longer-form aliases for the
/// same canonical model from still-reachable peers are rejected as
/// duplicates.
/// 2. The local node advertises a longer convention
/// (e.g. `unsloth/Qwen3-8B-GGUF:Q4_K_M`) while a peer advertises a
/// shorter variant (e.g. `Qwen3-8B-Q4_K_M`). The shortest-name rule
/// picks the peer alias, `add_worker_backend` looks for a local port
/// under that specific string, finds nothing, and forces a
/// QUIC-tunnel backend even though the model is right here.
///
/// Both failure modes are fixed by grouping first and resolving second.
/// Within each group the aliases are ordered so the most likely
/// optimization wins first try: locally-served name (skippy-port fast
/// path) before remote names, then shortest first as a tiebreaker.
fn group_aliases_by_canonical_base(
names: Vec<String>,
targets: Option<&election::ModelTargets>,
) -> Vec<Vec<String>> {
let mut by_base: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for name in names {
by_base
.entry(canonical_base_name(&name))
.or_default()
.push(name);
}
// Deterministic group order so the worker list is stable across
// builds even though HashMap iteration is not. Sort group entries
// (locally-served first, then shortest), then sort groups by their
// first ("best") alias.
let mut groups: Vec<Vec<String>> = by_base
.into_values()
.map(|mut aliases| {
aliases.sort_by(|a, b| {
let la = is_locally_served(a, targets);
let lb = is_locally_served(b, targets);
lb.cmp(&la) // local (true) before remote (false)
.then_with(|| a.len().cmp(&b.len()))
.then_with(|| a.cmp(b))
});
aliases
})
.collect();
groups.sort_by(|a, b| a[0].cmp(&b[0]));
groups
}
/// Does the local routing table have a backend port for this exact name?
fn is_locally_served(name: &str, targets: Option<&election::ModelTargets>) -> bool {
targets
.and_then(|t| {
t.targets.get(name).map(|tv| {
tv.iter()
.any(|t| matches!(t, election::InferenceTarget::Local(_)))
})
})
.unwrap_or(false)
}
/// Resolve `name` to a backend (local skippy port if available, else first
/// remote host) and append it to `backends`/`models`. Returns true if a
/// backend was added.
struct WorkerBackendResolution<'a> {
node: &'a mesh::Node,
targets: Option<&'a election::ModelTargets>,
http: &'a reqwest::Client,
required_tokens: Option<u32>,
}
async fn add_worker_backend(
resolution: &WorkerBackendResolution<'_>,
name: &str,
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
local_count: &mut usize,
) -> bool {
// Prefer local skippy port when this node serves the model.
let local_port = resolution.targets.and_then(|t| {
t.targets.get(name).and_then(|tv| {
tv.iter().find_map(|t| match t {
election::InferenceTarget::Local(p) => Some(*p),
_ => None,
})
})
});
if let Some(port) = local_port {
let context_length = resolution.node.local_model_context_length(name).await;
if context_selection::context_can_satisfy(resolution.required_tokens, context_length) {
let backend_idx = backends.len();
backends.push(std::sync::Arc::new(LocalModelBackend {
port,
http: resolution.http.clone(),
}));
models.push(moa::ModelEntry {
name: name.to_string(),
backend_index: backend_idx,
});
*local_count += 1;
return true;
} else {
tracing::info!(
"MoA: skipping local worker {name}; context {:?} cannot fit {:?} required tokens",
context_length,
resolution.required_tokens
);
}
}
// Otherwise find a remote host. hosts_for_model returns peers in
// hash-preferred order; prefer hosts with enough advertised context.
let remote_hosts = resolution.node.hosts_for_model(name).await;
if let Some(peer_id) = context_selection::select_remote_host(
resolution.node,
name,
resolution.required_tokens,
remote_hosts,
)
.await
{
let backend_idx = backends.len();
backends.push(std::sync::Arc::new(RemoteModelBackend {
node: resolution.node.clone(),
peer_id,
}));
models.push(moa::ModelEntry {
name: name.to_string(),
backend_index: backend_idx,
});
return true;
}
false
}
/// Discover and assemble the MoA worker pool: resolve one worker per distinct
/// model, apply admission control, then self-fill same-model instances.
///
/// Returns parallel `(backends, models)` vecs linked by `backend_index`.
pub(super) async fn assemble_worker_pool(
node: &mesh::Node,
targets: Option<&election::ModelTargets>,
required_tokens: Option<u32>,
http: &reqwest::Client,
) -> (
Vec<std::sync::Arc<dyn moa::ModelBackend>>,
Vec<moa::ModelEntry>,
) {
let mut backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
let mut models: Vec<moa::ModelEntry> = Vec::new();
let mut local_count = 0usize;
// Full mesh-wide model list (local + every peer's advertised routable
// models).
let all_models: Vec<String> = node
.models_being_served()
.await
.into_iter()
.filter(|n| n != moa::VIRTUAL_MODEL_NAME)
.collect();
// Group aliases by canonical base and resolve one worker per base, trying
// aliases in order so a longer-named reachable alias still resolves when
// the shortest one is offline (PR #566).
for aliases in group_aliases_by_canonical_base(all_models, targets) {
resolve_one_worker_from_aliases(
node,
targets,
http,
&aliases,
required_tokens,
&mut backends,
&mut models,
&mut local_count,
)
.await;
}
// Admission control: a weak worker must not drag down a pool that already
// has a stronger one. Aggregation is sensitive to proposal quality
// (Self-MoA, arXiv:2502.00674), so an 8B draft added to a 24-32B pool is
// expected noise-to-harm. When tiers are mixed, keep only big-tier workers;
// an all-small or all-big pool is untouched. A lone big model then serves
// solo (fails the caller's <2 check), the safe outcome.
// Verified sizes gossiped by peers (metadata.parameter_count_b). The
// orchestrator has no peer GGUFs, so this is the only authoritative size
// source for the destructive admission/cap decisions.
let sizes = gossiped_sizes(node).await;
apply_admission_control(&mut backends, &mut models, &sizes);
// Same-model fill: if only one model resolved but it is served by >=2
// DISTINCT physical endpoints, form a committee from them. Self-MoA shows
// repeated sampling of one model ensembles as well as different models, so
// a same-model mesh should still get MoA. Iron law: a single physical
// endpoint must never become a fake 2-worker committee — one node stays
// single-model.
if models.len() == 1 {
self_fill_from_extra_instances(
node,
targets,
required_tokens,
http,
&mut backends,
&mut models,
)
.await;
}
// All-small pools do not convene a committee.
//
// Measured through the shipped path (evals/moa-openrouter/RESULTS.md),
// 8B-class peers with an 8B reducer, vs the pool's best member alone:
// 2x 8B: 0W/43T/37L 4x 8B: see RESULTS 6x 8B: 5W/52T/23L (p=0.0009)
// The committee never won and lost roughly a third of decided trials, with
// consistently shorter answers. A capable pool is the opposite (71W/8T/1L,
// p<0.0001), so this is a statement about *this* configuration — a weak
// reducer synthesizing weak drafts — not about small-model MoA in general.
// The untested cell is small peers with a strong reducer; if a mesh gains a
// big-tier model the pool stops being all-small and MoA engages again.
//
// So: fall back to best-member routing rather than ship a measured
// regression. Keeping the strongest worker means `build_moa_config` sees a
// single model and the caller degrades to serving it directly.
if !models.is_empty()
&& models
.iter()
.all(|m| tier_for(&m.name, &sizes) == SizeTier::Small)
{
keep_best_member_only(&mut backends, &mut models, &sizes);
return (backends, models);
}
// Committee cap: fan-out cost is ~2N+1 model calls per turn (N drafts + N
// refines + 1 synthesis), and measured quality is flat past ~4 workers
// while latency and spend keep climbing. On a big shared mesh (say 20
// nodes) an uncapped pool would fan out to all of them — 41 calls for no
// quality gain. Keep the best MAX_COMMITTEE_WORKERS by capability ranking;
// the rest are standbys (they still serve direct traffic, just not this
// committee).
cap_committee(node, &mut backends, &mut models).await;
(backends, models)
}
/// Committee width caps, by pool tier. Measured (evals/moa-openrouter,
/// aggregator = qwen3-8b, 8B-class peers):
///
/// - all-small pool: 6x diverse 8B beats solo (12W/2L, p=0.013); 4 is only
/// marginal (5W/0L, p=0.06); 2 is null. Small, weak drafts need WIDTH — more
/// independent proposals — before aggregation clears the best member.
/// - big-tier present: a 24-32B pair already wins (49W/6L, p=2e-9); extra
/// workers past ~4 are latency/cost with no measured gain.
///
/// So the cap scales with size: wide for small pools, tight when a big model is
/// present.
const COMMITTEE_CAP_SMALL: usize = 6;
const COMMITTEE_CAP_BIG: usize = 4;
/// Reduce an all-small pool to its single strongest member, so the caller
/// degrades to serving that model directly instead of convening a committee
/// that measured worse than the member alone.
fn keep_best_member_only(
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
sizes: &HashMap<String, f64>,
) {
// Largest verified size wins; unsized models rank last, stable index
// breaks ties (same ordering rule as `cap_committee`).
let best = (0..models.len())
.max_by(|&a, &b| {
let key = |i: usize| {
sizes
.get(&canonical_base_name(&models[i].name))
.copied()
.unwrap_or(0.0)
};
key(a)
.partial_cmp(&key(b))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.cmp(&a))
})
.unwrap_or(0);
tracing::info!(
"MoA: all-small pool ({} workers) — serving best member {} directly \
(committee measured worse than the member alone)",
models.len(),
models[best].name,
);
let backend = backends[models[best].backend_index].clone();
let name = models[best].name.clone();
*backends = vec![backend];
*models = vec![moa::ModelEntry {
name,
backend_index: 0,
}];
}
fn committee_cap(models: &[moa::ModelEntry], sizes: &HashMap<String, f64>) -> usize {
let has_big = models
.iter()
.any(|m| tier_for(&m.name, sizes) == SizeTier::Big);
if has_big {
COMMITTEE_CAP_BIG
} else {
COMMITTEE_CAP_SMALL
}
}
/// Trim the pool to the best workers for its tier (see [`committee_cap`]).
async fn cap_committee(
node: &mesh::Node,
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
) {
let sizes = gossiped_sizes(node).await;
let cap = committee_cap(models, &sizes);
if models.len() <= cap {
return;
}
// Rank by verified size, NOT the tool-actor ranking. The committee serves
// ordinary answer turns where `tool_use` is irrelevant; ranking by it
// (i386 P1) could evict a 32B/70B model with `tool_use=None` in favour of
// four small models whose metadata advertises tool use — the opposite of
// the admission goal. Keep the largest verified models; a model with no
// verified size ranks as weakest, and stable index breaks ties.
let mut ranked: Vec<usize> = (0..models.len()).collect();
ranked.sort_by(|&a, &b| {
let key = |i: usize| match tier_for(&models[i].name, &sizes) {
SizeTier::Big => 0,
SizeTier::Small => 1,
};
key(a).cmp(&key(b)).then_with(|| a.cmp(&b))
});
let keep: std::collections::HashSet<usize> = ranked.into_iter().take(cap).collect();
let mut kept_backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
let mut kept_models: Vec<moa::ModelEntry> = Vec::new();
for (i, m) in models.iter().enumerate() {
if !keep.contains(&i) {
tracing::info!("MoA: capping committee, dropping worker {}", m.name);
continue;
}
let new_idx = kept_backends.len();
kept_backends.push(backends[m.backend_index].clone());
kept_models.push(moa::ModelEntry {
name: m.name.clone(),
backend_index: new_idx,
});
}
*backends = kept_backends;
*models = kept_models;
}
/// Cap on same-model instances added by self-fill. Two is enough to switch a
/// single-model mesh from solo to a working committee; beyond that the extra
/// draft's marginal value falls and it is just latency/cost.
const SELF_FILL_TARGET_WORKERS: usize = 2;
/// When only one model resolved, add extra reachable *nodes* serving that same
/// model as additional workers, up to [`SELF_FILL_TARGET_WORKERS`].
///
/// Only genuinely distinct remote endpoints are added — never the local backend
/// again and never the same peer twice — so each added worker is real capacity
/// from a node that joined the mesh. This is what makes a same-model mesh get
/// MoA at all; without it `build_moa_config` returns None for such a mesh.
async fn self_fill_from_extra_instances(
node: &mesh::Node,
targets: Option<&election::ModelTargets>,
required_tokens: Option<u32>,
http: &reqwest::Client,
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
) {
let Some(existing) = models.first().cloned() else {
return;
};
let name = existing.name.clone();
// Rebuild the pool from DISTINCT physical endpoints serving this model:
// the local skippy port (if this node serves it and context fits) plus
// each distinct remote peer. `hosts_for_model` returns distinct peers, and
// the local endpoint is a different physical box from any of them, so no
// endpoint can appear twice.
//
// Iron law: a single physical endpoint must NEVER become a fake 2-worker
// committee. If fewer than two distinct endpoints serve the model we leave
// the pool as the single worker and MoA degrades to single-model serving.
let mut endpoints: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
if let Some(port) = targets.and_then(|t| {
t.targets.get(&name).and_then(|tv| {
tv.iter().find_map(|t| match t {
election::InferenceTarget::Local(p) => Some(*p),
_ => None,
})
})
}) {
let context_length = node.local_model_context_length(&name).await;
if context_selection::context_can_satisfy(required_tokens, context_length) {
endpoints.push(std::sync::Arc::new(LocalModelBackend {
port,
http: http.clone(),
}));
}
}
for peer_id in node.hosts_for_model(&name).await {
if endpoints.len() >= SELF_FILL_TARGET_WORKERS {
break;
}
endpoints.push(std::sync::Arc::new(RemoteModelBackend {
node: node.clone(),
peer_id,
}));
}
if endpoints.len() < 2 {
return; // single physical endpoint -> stay single-model (iron law)
}
endpoints.truncate(SELF_FILL_TARGET_WORKERS);
tracing::info!(
"MoA: self-fill formed a {}-worker committee for {name} from distinct endpoints",
endpoints.len()
);
*backends = endpoints;
*models = (0..backends.len())
.map(|i| moa::ModelEntry {
name: name.clone(),
backend_index: i,
})
.collect();
}
/// Drop small-tier workers when any big-tier worker is present.
///
/// A weak draft can contaminate synthesis, and aggregation quality tracks
/// proposal quality (Self-MoA, arXiv:2502.00674), so a modest node must not be
/// admitted into a committee that already has a stronger member. When the pool
/// is mixed we keep only the big-tier workers; an all-small or all-big pool is
/// left untouched. `backends` and `models` are parallel vecs linked by
/// `backend_index`, so we rebuild both and reindex.
fn apply_admission_control(
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
sizes: &HashMap<String, f64>,
) {
// Only *verified* big-tier models count as strong, and only *verified*
// small-tier models are eligible for exclusion. An `Unknown` size is never
// treated as strong (so it can't anchor the ">=2 big" gate) and is never
// filtered out (so an unverified label can't get a worker dropped). This
// is the i386 P1 fix: a destructive admission decision must rest on
// verified size, not a guessed tier.
let big_count = models
.iter()
.filter(|m| tier_for(&m.name, sizes) == SizeTier::Big)
.count();
let has_small = models
.iter()
.any(|m| tier_for(&m.name, sizes) == SizeTier::Small);
// Only exclude small-tier workers when doing so still leaves a committee
// (>=2 big-tier). Measured:
// * 32B x2 + 8B -> dropping the 8B leaves 32B x2, and the 8B added
// nothing (arm C: no upside, losses 2->5) — so drop it.
// * 32B + 8B -> dropping the 8B collapses to a solo 32B, but the
// mixed committee beats solo decisively (47W/27T/5L, p=1e-9) — so
// KEEP the 8B. Admission must not throw away MoA to protect a pool
// that no longer exists.
// See `evals/moa-openrouter/RESULTS.md`.
if !(has_small && big_count >= 2) {
return;
}
let mut kept_backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
let mut kept_models: Vec<moa::ModelEntry> = Vec::new();
for m in models.iter() {
if tier_for(&m.name, sizes) == SizeTier::Small {
tracing::info!(
"MoA: excluding verified small-tier worker {} (>=2 big-tier present)",
m.name
);
continue;
}
let new_idx = kept_backends.len();
kept_backends.push(backends[m.backend_index].clone());
kept_models.push(moa::ModelEntry {
name: m.name.clone(),
backend_index: new_idx,
});
}
*backends = kept_backends;
*models = kept_models;
}
/// Rank the pool best-tool-caller-first (indices into `models`) for the actor.
///
/// Ordering: gossiped `tool_use` (`Supported` > `Likely` > `None`), then size
/// tier, then stable index. Capabilities match pool entries by canonical base
/// name (so `unsloth/Qwen3-8B-GGUF:Q4_K_M` supplies `Qwen3-8B-Q4_K_M`). Always
/// returns a full ranking; the engine reads an empty vec as "no host guidance".
pub(super) async fn compute_actor_candidates(
node: &mesh::Node,
models: &[moa::ModelEntry],
) -> Vec<usize> {
// canonical base name -> best tool_use level seen across the mesh.
let mut tool_use_by_base: std::collections::HashMap<String, crate::models::CapabilityLevel> =
std::collections::HashMap::new();
for descriptor in node.all_served_model_descriptors().await {
let base = canonical_base_name(&descriptor.identity.model_name);
let level = descriptor.capabilities.tool_use;
tool_use_by_base
.entry(base)
.and_modify(|existing| {
if level > *existing {
*existing = level;
}
})
.or_insert(level);
}
let mut ranked: Vec<usize> = (0..models.len()).collect();
ranked.sort_by(|&a, &b| {
let ma = &models[a];
let mb = &models[b];
let tool_a = tool_use_by_base
.get(&canonical_base_name(&ma.name))
.copied()
.unwrap_or(crate::models::CapabilityLevel::None);
let tool_b = tool_use_by_base
.get(&canonical_base_name(&mb.name))
.copied()
.unwrap_or(crate::models::CapabilityLevel::None);
// 1) higher tool_use first
tool_b
.cmp(&tool_a)
// 2) big-tier before small-tier
.then_with(|| {
let small_a = moa::model_name_is_small_tier(&ma.name);
let small_b = moa::model_name_is_small_tier(&mb.name);
small_a.cmp(&small_b) // false (big) sorts before true (small)
})
// 3) stable index order
.then_with(|| a.cmp(&b))
});
ranked
}
/// Canonical name used for cross-peer dedup. Different peers advertise the
/// same model under different conventions (`unsloth/Qwen3-8B-GGUF:Q4_K_M`
/// vs `Qwen3-8B-Q4_K_M`); normalize before comparing.
///
/// Strategy: strip the publisher prefix, the `-gguf` suffix, any `@branch`
/// suffix, then keep only `[a-z0-9]` characters so `:` vs `-` separators
/// don't matter.
pub(super) fn canonical_base_name(name: &str) -> String {
let lower = name.to_lowercase();
// Drop an `@branch` segment if present, keeping anything after the
// next `:` so quant tags survive (e.g. `repo@main:q4_k_m` → `repo:q4_k_m`).
let no_branch = match lower.find('@') {
Some(at) => {
let after = &lower[at + 1..];
let rest = after.find(':').map(|c| &after[c..]).unwrap_or("");
format!("{}{}", &lower[..at], rest)
}
None => lower,
};
let stripped = no_branch
.replace("-gguf", "")
.replace("unsloth/", "")
.replace("meshllm/", "");
stripped
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal backend stub for admission-control tests.
struct FakeBackend;
#[async_trait::async_trait]
impl moa::ModelBackend for FakeBackend {
async fn chat_completion(
&self,
_model: &str,
_messages: &[serde_json::Value],
_tools: Option<&serde_json::Value>,
_max_tokens: u32,
_timeout: std::time::Duration,
_sampling: moa::SamplingParams,
) -> Result<serde_json::Value, String> {
Ok(serde_json::json!({"choices":[{"message":{"content":"x"}}]}))
}
}
fn pool(
names: &[&str],
) -> (
Vec<std::sync::Arc<dyn moa::ModelBackend>>,
Vec<moa::ModelEntry>,
) {
let mut b: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
let mut m = Vec::new();
for name in names {
m.push(moa::ModelEntry {
name: (*name).to_string(),
backend_index: b.len(),
});
b.push(std::sync::Arc::new(FakeBackend));
}
(b, m)
}
/// Build a verified-size map keyed by canonical base name, as if gossiped.
fn sizes_of(entries: &[(&str, f64)]) -> HashMap<String, f64> {
entries
.iter()
.map(|(n, b)| (canonical_base_name(n), *b))
.collect()
}
#[test]
fn all_small_pool_keeps_only_the_best_member() {
// Measured: an all-small committee never beat its best member and lost
// ~a third of decided trials (2x8B 0W/37L; 6x8B 5W/23L p=0.0009). So an
// all-small pool collapses to the single strongest member and the
// caller degrades to serving it directly.
let (mut b, mut m) = pool(&["Qwen3-8B", "Llama-3.1-8B", "Qwen3.5-9B"]);
let sizes = sizes_of(&[
("Qwen3-8B", 8.0),
("Llama-3.1-8B", 8.0),
("Qwen3.5-9B", 9.0),
]);
keep_best_member_only(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 1, "all-small pool must collapse to one member");
assert_eq!(m[0].name, "Qwen3.5-9B", "largest verified size wins");
assert_eq!(b.len(), 1);
assert_eq!(m[0].backend_index, 0, "backend index reindexed");
}
#[test]
fn best_member_falls_back_to_stable_order_when_unsized() {
// No verified sizes: every member ranks equal, so the stable first
// entry is kept rather than an arbitrary one.
let (mut b, mut m) = pool(&["alpha-model", "beta-model"]);
let sizes = sizes_of(&[]);
keep_best_member_only(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 1);
assert_eq!(m[0].name, "alpha-model");
}
#[test]
fn admission_drops_small_when_two_big_remain() {
// Dropping the small workers still leaves a committee (2x 32B), and the
// small drafts add nothing there — so exclude them.
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B", "Qwen3-8B", "Ministral-8B"]);
let sizes = sizes_of(&[
("Qwen3-32B", 32.0),
("Qwen3-8B", 8.0),
("Ministral-8B", 8.0),
]);
apply_admission_control(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 2);
assert!(m.iter().all(|e| e.name == "Qwen3-32B"));
assert_eq!(b.len(), 2);
// backends stay aligned and reindexed
assert_eq!(m[0].backend_index, 0);
assert_eq!(m[1].backend_index, 1);
}
#[test]
fn admission_keeps_mix_when_dropping_would_collapse_to_solo() {
// One strong + one weak: dropping the 8B leaves a solo 32B, but the
// mixed committee beats solo (47W/27T/5L) — so keep the mix.
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-8B"]);
let sizes = sizes_of(&[("Qwen3-32B", 32.0), ("Qwen3-8B", 8.0)]);
apply_admission_control(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 2, "must not collapse a lone-strong pool to solo");
}
#[test]
fn admission_keeps_all_small_pool() {
let (mut b, mut m) = pool(&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B"]);
let sizes = sizes_of(&[
("Qwen3-8B", 8.0),
("Llama-3.1-8B", 8.0),
("Ministral-8B", 8.0),
]);
apply_admission_control(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 3);
}
#[test]
fn admission_keeps_all_big_pool() {
let (mut b, mut m) = pool(&["Qwen3-32B", "Mistral-Small-24B"]);
let sizes = sizes_of(&[("Qwen3-32B", 32.0), ("Mistral-Small-24B", 24.0)]);
apply_admission_control(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 2);
}
#[test]
fn admission_keeps_homogeneous_big_pool() {
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B"]);
let sizes = sizes_of(&[("Qwen3-32B", 32.0)]);
apply_admission_control(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 2);
}
/// i386: a model with NO verified GGUF size is the weakest (Small), so
/// next to two verified big models it is excluded like any other small
/// worker. An unverifiable label can never masquerade as big.
#[test]
fn admission_treats_unsized_worker_as_weakest() {
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B", "my-assistant"]);
// Only the two big models have gossiped sizes; "my-assistant" has none.
let sizes = sizes_of(&[("Qwen3-32B", 32.0)]);
apply_admission_control(&mut b, &mut m, &sizes);
assert_eq!(m.len(), 2, "unsized worker ranks weakest and is excluded");
assert!(m.iter().all(|e| e.name == "Qwen3-32B"));
}
/// No gossiped sizes at all: every worker is weakest (Small), so it is an
/// all-small pool and admission excludes nothing (there is no verified big
/// to protect). Name is never consulted.
#[test]
fn admission_keeps_all_when_no_verified_sizes() {
let (mut b, mut m) = pool(&["Qwen3-32B", "Qwen3-32B", "Qwen3-8B"]);
let sizes: HashMap<String, f64> = HashMap::new();
apply_admission_control(&mut b, &mut m, &sizes);
assert_eq!(
m.len(),
3,
"no verified big => all-small pool => nothing excluded"
);
}
#[test]
fn committee_cap_is_wide_for_small_pools_tight_for_big() {
// All-small pool: cap is wide (6) so a 6× 8B mesh keeps its width —
// measured 12W/2L p=0.013 at width 6, only marginal at 4.
let small: Vec<moa::ModelEntry> = (0..8)
.map(|i| moa::ModelEntry {
name: "Qwen3-8B".to_string(),
backend_index: i,
})
.collect();
let sizes = sizes_of(&[("Qwen3-8B", 8.0)]);
assert_eq!(committee_cap(&small, &sizes), COMMITTEE_CAP_SMALL);
// A verified big model present -> tight cap (4); extra workers past a
// 2432B pair buy nothing.
let mixed = vec![
moa::ModelEntry {
name: "Qwen3-32B".to_string(),
backend_index: 0,
},
moa::ModelEntry {
name: "Qwen3-8B".to_string(),
backend_index: 1,
},
];
let sizes = sizes_of(&[("Qwen3-32B", 32.0), ("Qwen3-8B", 8.0)]);
assert_eq!(committee_cap(&mixed, &sizes), COMMITTEE_CAP_BIG);
}
#[test]
fn canonical_base_dedupes_unsloth_and_gguf_variants() {
assert_eq!(
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
canonical_base_name("Qwen3-8B-Q4_K_M")
);
assert_eq!(
canonical_base_name("unsloth/Qwen3-8B-GGUF@main:Q4_K_M"),
canonical_base_name("Qwen3-8B-Q4_K_M")
);
}
#[test]
fn canonical_base_keeps_distinct_models_distinct() {
assert_ne!(
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M")
);
assert_ne!(
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M"),
canonical_base_name("unsloth/MiniMax-M2.5-GGUF:Q4_K_M")
);
}
fn make_targets(local_names: &[&str]) -> election::ModelTargets {
let mut t = election::ModelTargets::default();
for (i, name) in local_names.iter().enumerate() {
t.targets.insert(
(*name).to_string(),
vec![election::InferenceTarget::Local(50000 + i as u16)],
);
}
t
}
#[test]
fn group_aliases_keeps_all_aliases_per_canonical_base() {
// Regression for PR #566 review (item #10): the dedup-then-resolve
// shape committed to a single alias per base before checking
// backend reachability. Now every alias is retained so the
// resolver can fall back if the preferred alias is unreachable.
let groups = group_aliases_by_canonical_base(
vec![
"Qwen3-8B-Q4_K_M".to_string(),
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
],
None,
);
assert_eq!(groups.len(), 1, "both names share a canonical base");
assert_eq!(groups[0].len(), 2, "both aliases retained");
}
#[test]
fn group_aliases_prefers_locally_served_alias_even_when_longer() {
// Without a targets table, length-order wins and the shorter peer
// alias would be tried first — forcing an unnecessary QUIC hop
// when the model is right here under a different alias.
// With targets, the local-served alias must come first.
let local = "unsloth/Qwen3-8B-GGUF:Q4_K_M";
let peer = "Qwen3-8B-Q4_K_M";
let targets = make_targets(&[local]);
let groups = group_aliases_by_canonical_base(
vec![peer.to_string(), local.to_string()],
Some(&targets),
);
assert_eq!(groups.len(), 1);
assert_eq!(
groups[0].first().map(String::as_str),
Some(local),
"locally-served alias must win even though it's longer"
);
}
#[test]
fn group_aliases_falls_back_to_shortest_when_no_local() {
// No targets table at all (pure --client --auto node) — shortest
// alias should win, but the longer alias is still in the group so
// it can be tried if the shortest one is unreachable.
let groups = group_aliases_by_canonical_base(
vec![
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
"Qwen3-8B-Q4_K_M".to_string(),
],
None,
);
assert_eq!(groups.len(), 1);
assert_eq!(
groups[0].first().map(String::as_str),
Some("Qwen3-8B-Q4_K_M")
);
assert_eq!(groups[0].len(), 2, "longer alias kept as fallback");
}
#[test]
fn group_aliases_distinct_models_stay_in_separate_groups() {
let groups = group_aliases_by_canonical_base(
vec![
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
"unsloth/Qwen3-32B-GGUF:Q4_K_M".to_string(),
"unsloth/MiniMax-M2.5-GGUF:Q4_K_M".to_string(),
],
None,
);
assert_eq!(groups.len(), 3);
}
}

View file

@ -1,4 +1,4 @@
use super::context_selection;
use super::pool::{assemble_worker_pool, compute_actor_candidates};
use crate::inference::election;
use crate::mesh;
use mesh_mixture_of_agents as moa;
@ -14,7 +14,36 @@ use mesh_mixture_of_agents as moa;
/// [`extract_enable_thinking_override`]. When no preference is
/// expressed, MoA picks for them: off (always `Some(false)`).
pub(super) fn effective_enable_thinking_for_moa(body: &serde_json::Value) -> Option<bool> {
extract_enable_thinking_override(body).or(Some(false))
// Always off. Not a default — a policy.
//
// Reasoning is actively harmful inside MoA fan-out, and recorded traces
// from 9 open-weight models make the failure mode concrete
// (`evals/moa-openrouter/`):
//
// * Workers run on a short budget (the fast worker gets 256 tokens).
// With thinking on, qwen3-32b spent 408 reasoning tokens against a
// 384-token cap and returned `finish_reason=length` with
// `content: null` — 1620 characters of reasoning and no answer. The
// worker contributed nothing but still cost a full inference.
// * Across a recorded corpus, 15/140 responses came back truncated at
// the limit, concentrated in exactly the reasoning-capable models.
// * The reducer synthesizes from worker answers; reasoning prose is bad
// candidate input regardless of budget.
//
// The previous shape honoured a caller's `reasoning_effort` /
// `enable_thinking` override. That escape hatch only let callers ask for
// the broken configuration, so it is gone: MoA decides this, not the
// caller. Callers who want a reasoning model's thinking output should
// address that model directly instead of going through `model=mesh`.
//
// We still parse the caller's preference so an ignored override is
// visible in logs rather than silently dropped.
if extract_enable_thinking_override(body) == Some(true) {
tracing::info!(
"moa: caller asked for reasoning, ignoring — MoA workers always run with thinking off"
);
}
Some(false)
}
/// Pull the caller's "disable / enable thinking" preference out of an
@ -103,56 +132,31 @@ pub async fn build_moa_config(
required_tokens: Option<u32>,
) -> Option<moa::GatewayConfig> {
let http = reqwest::Client::new();
let mut backends: Vec<std::sync::Arc<dyn moa::ModelBackend>> = Vec::new();
let mut models: Vec<moa::ModelEntry> = Vec::new();
let mut local_count = 0usize;
// Full mesh-wide model list (local + every peer's advertised
// routable models).
let all_models: Vec<String> = node
.models_being_served()
.await
.into_iter()
.filter(|n| n != moa::VIRTUAL_MODEL_NAME)
.collect();
// Group aliases by canonical base. The old shape sorted by name
// length, took the *first* alias per base, and dropped the rest —
// which silently dropped the model from the worker pool whenever the
// shortest-named peer was unreachable (regression flagged by PR #566
// review). Now we keep every alias per base and try them in order so
// a longer-named reachable alias can still resolve when the shortest
// one is offline.
let groups = group_aliases_by_canonical_base(all_models, targets);
for aliases in groups {
resolve_one_worker_from_aliases(
node,
targets,
&http,
&aliases,
required_tokens,
&mut backends,
&mut models,
&mut local_count,
)
.await;
}
let (backends, models) = assemble_worker_pool(node, targets, required_tokens, &http).await;
if models.len() < 2 {
tracing::warn!(
"MoA: only {} model(s) reachable, need ≥2 (models={:?})",
"MoA: only {} qualified model(s) after admission, need ≥2 (models={:?})",
models.len(),
models.iter().map(|m| &m.name).collect::<Vec<_>>()
);
return None;
}
// Actor priority for the asymmetric tool path: best tool-caller first.
// The actor is the one model that actually emits the tool call, so it must
// be the best available tool-caller — a judgement the engine crate cannot
// make because it can't see gossiped capabilities.
let actor_candidates = compute_actor_candidates(node, &models).await;
// Public meshes are a pathological availability case, not a trust case:
// unknown peers, wider latency spread, more churn. Wait less for perfect.
let patience = patience_profile(node.public_mesh);
tracing::info!(
required_tokens = ?required_tokens,
"MoA config: {} workers ({} local, {} remote): {:?}",
"MoA config: {} workers (admitted): {:?}",
models.len(),
local_count,
models.len() - local_count,
models.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
);
@ -194,7 +198,9 @@ pub async fn build_moa_config(
// With the relaxed eligibility added in this change, the timer
// is the dominant chat path, so a tighter default is the right
// default.
first_answer_grace: std::time::Duration::from_secs(3),
//
// Tightened further on a public mesh — see `patience_profile`.
first_answer_grace: patience.first_answer_grace,
// Tier-gate patience: how long small-tier-only answers/consensus
// are held when a big-tier strong worker (e.g. MiniMax) is still
// running. 20s covers the strong worker's typical first-token
@ -203,219 +209,65 @@ pub async fn build_moa_config(
// decision rules revert to ungated behavior. Same-tier pools are
// unaffected, so "many small models lift each other" keeps its
// current latency profile.
strong_patience: std::time::Duration::from_secs(20),
strong_patience: patience.strong_patience,
// Defaults to leaving each model's thinking behavior alone.
// `try_handle_moa` overrides this from the inbound request body
// when the caller has expressed a preference
// (`reasoning_effort: "none"`, `enable_thinking: false`, etc.).
enable_thinking: None,
// Actor priority for tool turns / synthesis: best tool-caller first.
// Computed below from gossiped `tool_use`, model size, and peer health.
actor_candidates,
// Gate advisory references on actor strength: they help a weak actor
// and cost a strong one (evals/moa-openrouter/RESULTS.md).
reference_policy: moa::ReferencePolicy::Auto,
refinement_policy: Default::default(),
})
}
/// Try each alias in `aliases` until one resolves to a backend, then stop.
/// How long a turn waits for better answers before shipping what it has.
struct PatienceProfile {
first_answer_grace: std::time::Duration,
strong_patience: std::time::Duration,
}
/// Timing profile for the turn, tightened on a public mesh.
///
/// Aliases are pre-sorted by `group_aliases_by_canonical_base` so the most
/// preferred (locally-served first, then shortest) is tried first. Falls
/// back to longer aliases when the preferred one's peer is unreachable.
#[allow(clippy::too_many_arguments)]
async fn resolve_one_worker_from_aliases(
node: &mesh::Node,
targets: Option<&election::ModelTargets>,
http: &reqwest::Client,
aliases: &[String],
required_tokens: Option<u32>,
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
local_count: &mut usize,
) {
let resolution = WorkerBackendResolution {
node,
targets,
http,
required_tokens,
};
for name in aliases {
if add_worker_backend(&resolution, name, backends, models, local_count).await {
return;
/// A public mesh is a pathological *availability* case (unknown peers, wider
/// latency spread, more churn), not a trust case. Both knobs here are
/// "how long do we hold a usable answer hoping for a better one" — exactly the
/// wait that hurts most when the tail is long. The hard bounds are unchanged;
/// only the optional waiting shrinks, so quality paths still run when peers are
/// prompt.
fn patience_profile(public_mesh: bool) -> PatienceProfile {
if public_mesh {
PatienceProfile {
// Ship a good answer sooner rather than wait out a long tail.
first_answer_grace: std::time::Duration::from_millis(1500),
// Still give a strong peer a real chance, but don't hold a usable
// small-tier answer for 20s against an unknown remote worker.
strong_patience: std::time::Duration::from_secs(8),
}
} else {
PatienceProfile {
// Widened 3s -> 10s: at 3s a fast small worker landed inside the
// window while a larger peer was still generating, so grace armed
// and (before the finalize fix) shipped the small answer, skipping
// synthesis on ~every turn. 10s lets a normal committee complete
// and synthesize; grace still bounds a genuinely stuck tail. Paired
// with grace-finalizes-on-tool-turns-only, so even if it does fire
// on an answer turn it synthesizes what arrived rather than shipping
// one worker. See `evals/moa-openrouter/RESULTS.md`.
first_answer_grace: std::time::Duration::from_secs(10),
strong_patience: std::time::Duration::from_secs(20),
}
}
}
/// Group all advertised model names by their canonical base so each
/// canonical model contributes exactly one worker, but the resolver gets
/// to pick the alias that actually has a reachable backend.
///
/// The earlier shape committed to a single alias per base *before* trying
/// to resolve a backend. Two failure modes:
///
/// 1. The chosen alias is advertised only by a peer that drops between
/// gossip refresh and orchestration — `hosts_for_model` returns
/// empty, the worker is dropped, and longer-form aliases for the
/// same canonical model from still-reachable peers are rejected as
/// duplicates.
/// 2. The local node advertises a longer convention
/// (e.g. `unsloth/Qwen3-8B-GGUF:Q4_K_M`) while a peer advertises a
/// shorter variant (e.g. `Qwen3-8B-Q4_K_M`). The shortest-name rule
/// picks the peer alias, `add_worker_backend` looks for a local port
/// under that specific string, finds nothing, and forces a
/// QUIC-tunnel backend even though the model is right here.
///
/// Both failure modes are fixed by grouping first and resolving second.
/// Within each group the aliases are ordered so the most likely
/// optimization wins first try: locally-served name (skippy-port fast
/// path) before remote names, then shortest first as a tiebreaker.
fn group_aliases_by_canonical_base(
names: Vec<String>,
targets: Option<&election::ModelTargets>,
) -> Vec<Vec<String>> {
let mut by_base: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for name in names {
by_base
.entry(canonical_base_name(&name))
.or_default()
.push(name);
}
// Deterministic group order so the worker list is stable across
// builds even though HashMap iteration is not. Sort group entries
// (locally-served first, then shortest), then sort groups by their
// first ("best") alias.
let mut groups: Vec<Vec<String>> = by_base
.into_values()
.map(|mut aliases| {
aliases.sort_by(|a, b| {
let la = is_locally_served(a, targets);
let lb = is_locally_served(b, targets);
lb.cmp(&la) // local (true) before remote (false)
.then_with(|| a.len().cmp(&b.len()))
.then_with(|| a.cmp(b))
});
aliases
})
.collect();
groups.sort_by(|a, b| a[0].cmp(&b[0]));
groups
}
/// Does the local routing table have a backend port for this exact name?
fn is_locally_served(name: &str, targets: Option<&election::ModelTargets>) -> bool {
targets
.and_then(|t| {
t.targets.get(name).map(|tv| {
tv.iter()
.any(|t| matches!(t, election::InferenceTarget::Local(_)))
})
})
.unwrap_or(false)
}
/// Resolve `name` to a backend (local skippy port if available, else first
/// remote host) and append it to `backends`/`models`. Returns true if a
/// backend was added.
struct WorkerBackendResolution<'a> {
node: &'a mesh::Node,
targets: Option<&'a election::ModelTargets>,
http: &'a reqwest::Client,
required_tokens: Option<u32>,
}
async fn add_worker_backend(
resolution: &WorkerBackendResolution<'_>,
name: &str,
backends: &mut Vec<std::sync::Arc<dyn moa::ModelBackend>>,
models: &mut Vec<moa::ModelEntry>,
local_count: &mut usize,
) -> bool {
// Prefer local skippy port when this node serves the model.
let local_port = resolution.targets.and_then(|t| {
t.targets.get(name).and_then(|tv| {
tv.iter().find_map(|t| match t {
election::InferenceTarget::Local(p) => Some(*p),
_ => None,
})
})
});
if let Some(port) = local_port {
let context_length = resolution.node.local_model_context_length(name).await;
if context_selection::context_can_satisfy(resolution.required_tokens, context_length) {
let backend_idx = backends.len();
backends.push(std::sync::Arc::new(LocalModelBackend {
port,
http: resolution.http.clone(),
}));
models.push(moa::ModelEntry {
name: name.to_string(),
backend_index: backend_idx,
});
*local_count += 1;
return true;
} else {
tracing::info!(
"MoA: skipping local worker {name}; context {:?} cannot fit {:?} required tokens",
context_length,
resolution.required_tokens
);
}
}
// Otherwise find a remote host. hosts_for_model returns peers in
// hash-preferred order; prefer hosts with enough advertised context.
let remote_hosts = resolution.node.hosts_for_model(name).await;
if let Some(peer_id) = context_selection::select_remote_host(
resolution.node,
name,
resolution.required_tokens,
remote_hosts,
)
.await
{
let backend_idx = backends.len();
backends.push(std::sync::Arc::new(RemoteModelBackend {
node: resolution.node.clone(),
peer_id,
}));
models.push(moa::ModelEntry {
name: name.to_string(),
backend_index: backend_idx,
});
return true;
}
false
}
/// Canonical name used for cross-peer dedup. Different peers advertise the
/// same model under different conventions (`unsloth/Qwen3-8B-GGUF:Q4_K_M`
/// vs `Qwen3-8B-Q4_K_M`); normalize before comparing.
///
/// Strategy: strip the publisher prefix, the `-gguf` suffix, any `@branch`
/// suffix, then keep only `[a-z0-9]` characters so `:` vs `-` separators
/// don't matter.
fn canonical_base_name(name: &str) -> String {
let lower = name.to_lowercase();
// Drop an `@branch` segment if present, keeping anything after the
// next `:` so quant tags survive (e.g. `repo@main:q4_k_m` → `repo:q4_k_m`).
let no_branch = match lower.find('@') {
Some(at) => {
let after = &lower[at + 1..];
let rest = after.find(':').map(|c| &after[c..]).unwrap_or("");
format!("{}{}", &lower[..at], rest)
}
None => lower,
};
let stripped = no_branch
.replace("-gguf", "")
.replace("unsloth/", "")
.replace("meshllm/", "");
stripped
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
/// Backend that calls a local model directly on its skippy HTTP port.
struct LocalModelBackend {
port: u16,
http: reqwest::Client,
pub(super) struct LocalModelBackend {
pub(super) port: u16,
pub(super) http: reqwest::Client,
}
#[async_trait::async_trait]
@ -468,9 +320,9 @@ impl moa::ModelBackend for LocalModelBackend {
}
/// Backend that calls a remote model over the QUIC tunnel.
struct RemoteModelBackend {
node: mesh::Node,
peer_id: iroh::EndpointId,
pub(super) struct RemoteModelBackend {
pub(super) node: mesh::Node,
pub(super) peer_id: iroh::EndpointId,
}
#[async_trait::async_trait]
@ -554,110 +406,6 @@ fn parse_quic_http_response(response: &[u8]) -> Result<serde_json::Value, String
mod tests {
use super::*;
#[test]
fn canonical_base_dedupes_unsloth_and_gguf_variants() {
assert_eq!(
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
canonical_base_name("Qwen3-8B-Q4_K_M")
);
assert_eq!(
canonical_base_name("unsloth/Qwen3-8B-GGUF@main:Q4_K_M"),
canonical_base_name("Qwen3-8B-Q4_K_M")
);
}
#[test]
fn canonical_base_keeps_distinct_models_distinct() {
assert_ne!(
canonical_base_name("unsloth/Qwen3-8B-GGUF:Q4_K_M"),
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M")
);
assert_ne!(
canonical_base_name("unsloth/Qwen3-32B-GGUF:Q4_K_M"),
canonical_base_name("unsloth/MiniMax-M2.5-GGUF:Q4_K_M")
);
}
fn make_targets(local_names: &[&str]) -> election::ModelTargets {
let mut t = election::ModelTargets::default();
for (i, name) in local_names.iter().enumerate() {
t.targets.insert(
(*name).to_string(),
vec![election::InferenceTarget::Local(50000 + i as u16)],
);
}
t
}
#[test]
fn group_aliases_keeps_all_aliases_per_canonical_base() {
// Regression for PR #566 review (item #10): the dedup-then-resolve
// shape committed to a single alias per base before checking
// backend reachability. Now every alias is retained so the
// resolver can fall back if the preferred alias is unreachable.
let groups = group_aliases_by_canonical_base(
vec![
"Qwen3-8B-Q4_K_M".to_string(),
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
],
None,
);
assert_eq!(groups.len(), 1, "both names share a canonical base");
assert_eq!(groups[0].len(), 2, "both aliases retained");
}
#[test]
fn group_aliases_prefers_locally_served_alias_even_when_longer() {
// Without a targets table, length-order wins and the shorter peer
// alias would be tried first — forcing an unnecessary QUIC hop
// when the model is right here under a different alias.
// With targets, the local-served alias must come first.
let local = "unsloth/Qwen3-8B-GGUF:Q4_K_M";
let peer = "Qwen3-8B-Q4_K_M";
let targets = make_targets(&[local]);
let groups = group_aliases_by_canonical_base(
vec![peer.to_string(), local.to_string()],
Some(&targets),
);
assert_eq!(groups.len(), 1);
assert_eq!(
groups[0].first().map(String::as_str),
Some(local),
"locally-served alias must win even though it's longer"
);
}
#[test]
fn group_aliases_falls_back_to_shortest_when_no_local() {
// No targets table at all (pure --client --auto node) — shortest
// alias should win, but the longer alias is still in the group so
// it can be tried if the shortest one is unreachable.
let groups = group_aliases_by_canonical_base(
vec![
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
"Qwen3-8B-Q4_K_M".to_string(),
],
None,
);
assert_eq!(groups.len(), 1);
assert_eq!(
groups[0].first().map(String::as_str),
Some("Qwen3-8B-Q4_K_M")
);
assert_eq!(groups[0].len(), 2, "longer alias kept as fallback");
}
#[test]
fn group_aliases_distinct_models_stay_in_separate_groups() {
let groups = group_aliases_by_canonical_base(
vec![
"unsloth/Qwen3-8B-GGUF:Q4_K_M".to_string(),
"unsloth/Qwen3-32B-GGUF:Q4_K_M".to_string(),
"unsloth/MiniMax-M2.5-GGUF:Q4_K_M".to_string(),
],
None,
);
assert_eq!(groups.len(), 3);
}
// ── extract_enable_thinking_override ────────────────────────────────
//
// Mirrors the shapes that `openai_frontend::common::normalize_reasoning_template_options`
@ -758,14 +506,30 @@ mod tests {
}
#[test]
fn effective_lets_caller_explicitly_enable_thinking() {
// Escape hatch: a caller who really wants reasoning on MoA can
// ask for it via any of the recognised knobs.
let body = serde_json::json!({
"reasoning_effort": "low",
"model": "mesh",
});
assert_eq!(effective_enable_thinking_for_moa(&body), Some(true));
fn effective_ignores_caller_request_to_enable_thinking() {
// There is deliberately no escape hatch. Thinking-on is a broken
// configuration for MoA fan-out: recorded traces show reasoning
// models spending their entire worker budget inside `<think>` and
// returning `finish_reason=length` with null content, contributing
// nothing while still costing a full inference.
//
// The override is parsed (and logged) but never honoured, so a
// caller asking for reasoning gets a working turn instead of a pool
// of empty workers. Reasoning output should be requested from a
// model directly, not through `model=mesh`.
for body in [
serde_json::json!({"reasoning_effort": "low", "model": "mesh"}),
serde_json::json!({"reasoning_effort": "high", "model": "mesh"}),
serde_json::json!({"enable_thinking": true, "model": "mesh"}),
serde_json::json!({"reasoning": {"enabled": true}, "model": "mesh"}),
serde_json::json!({"chat_template_kwargs": {"enable_thinking": true}}),
] {
assert_eq!(
effective_enable_thinking_for_moa(&body),
Some(false),
"MoA must force thinking off regardless of caller knobs: {body}"
);
}
}
#[test]
@ -781,4 +545,46 @@ mod tests {
});
assert_eq!(effective_enable_thinking_for_moa(&body), Some(false));
}
/// Public meshes are a pathological availability case: unknown peers,
/// wider latency spread, more churn. Both patience knobs are "hold a
/// usable answer hoping for a better one", which is exactly the wait that
/// hurts when the tail is long — so they shrink, and only they.
#[test]
fn public_mesh_waits_less_for_a_better_answer() {
let public = patience_profile(true);
let private = patience_profile(false);
assert!(
public.first_answer_grace < private.first_answer_grace,
"public mesh must ship a good answer sooner"
);
assert!(
public.strong_patience < private.strong_patience,
"public mesh must not hold a usable answer as long for a slow strong peer"
);
}
/// Shrinking patience must not disable the quality paths entirely — a
/// prompt strong peer should still get a chance to land.
#[test]
fn public_mesh_still_gives_strong_peers_a_chance() {
let public = patience_profile(true);
assert!(!public.first_answer_grace.is_zero());
assert!(public.strong_patience >= std::time::Duration::from_secs(5));
}
/// Private-mesh timings are the tuned defaults and must not drift silently.
/// Grace is 10s (widened from 3s): at 3s a fast worker landed inside the
/// window while a larger peer was still generating, so grace armed and the
/// committee never synthesized — measured 80/80 early-exit at capable
/// scale. See `evals/moa-openrouter/RESULTS.md`.
#[test]
fn private_mesh_keeps_the_tuned_defaults() {
let private = patience_profile(false);
assert_eq!(
private.first_answer_grace,
std::time::Duration::from_secs(10)
);
assert_eq!(private.strong_patience, std::time::Duration::from_secs(20));
}
}

View file

@ -1,39 +1,23 @@
//! Deterministic arbitration of worker outputs.
//! Deterministic arbitration of worker **answers**.
//!
//! The arbiter uses code, not models, to decide the outcome.
//! Models are only called (via the reducer) when there's genuine ambiguity.
//!
//! This arbiter handles the text path only. Tool-bearing turns take the
//! asymmetric actor path in [`crate::tool_turn`], where the best tool-caller
//! acts and references only advise — so no tool proposal ever reaches here.
//! Any tool-shaped text on the text path was demoted to `Uncertainty` by
//! `enforce_tool_call_contract` (tools disabled ⇒ empty allow-list).
//!
//! Decision priority:
//! 1. Unanimous tool proposal → emit tool call
//! 2. High-confidence tool proposal with no dissent → emit tool call
//! 3. Unanimous answers → pick highest confidence
//! 4. Conflicting outputs → escalate to reducer
//! 5. All uncertainty → escalate to reducer
//! 1. Agreeing answers (content cluster, majority) → pick highest confidence
//! 2. Diverging answers → escalate to reducer (synthesis)
//! 3. All uncertainty → escalate to reducer
use crate::normalize::{OutputKind, WorkerOutput};
use crate::worker::WorkerRole;
use serde_json::Value;
/// Pick the best tool proposal: prefer proposals that have arguments,
/// then by confidence. A proposal without arguments (e.g. from a fast
/// worker that only got tool names in the system prompt) should lose to
/// one that has actual arguments.
fn best_tool_proposal<'a>(proposals: &[&'a WorkerOutput]) -> &'a WorkerOutput {
proposals
.iter()
.copied()
.max_by(|a, b| {
let a_has_args = a.tool_arguments.is_some()
&& a.tool_arguments.as_ref() != Some(&Value::Object(Default::default()));
let b_has_args = b.tool_arguments.is_some()
&& b.tool_arguments.as_ref() != Some(&Value::Object(Default::default()));
a_has_args
.cmp(&b_has_args)
.then(a.confidence.total_cmp(&b.confidence))
})
.unwrap()
}
/// What the arbiter decided.
#[derive(Debug)]
pub enum Decision {
@ -46,7 +30,7 @@ pub enum Decision {
}
/// Arbitrate worker outputs into a single decision.
pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
pub fn arbitrate(outputs: &[WorkerOutput]) -> Decision {
if outputs.is_empty() {
return Decision::NeedsReducer {
reason: "no worker outputs".into(),
@ -54,13 +38,9 @@ pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
}
if outputs.len() == 1 {
return single_output_decision(&outputs[0], has_tools);
return single_output_decision(&outputs[0]);
}
let tool_proposals: Vec<&WorkerOutput> = outputs
.iter()
.filter(|o| o.kind == OutputKind::ToolProposal)
.collect();
let answers: Vec<&WorkerOutput> = outputs.iter().filter(|o| is_usable_answer(o)).collect();
let critiques: Vec<&WorkerOutput> = outputs
.iter()
@ -78,79 +58,12 @@ pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
};
}
// ── Tool call arbitration ────────────────────────────────────
if has_tools && !tool_proposals.is_empty() {
// Check if any critique opposes the tool call
let has_tool_dissent = critiques.iter().any(|c| {
c.payload.to_lowercase().contains("don't")
|| c.payload.to_lowercase().contains("should not")
|| c.payload.to_lowercase().contains("no tool")
});
if has_tool_dissent {
return Decision::NeedsReducer {
reason: "tool proposal with dissenting critique".into(),
};
}
// All tool proposals agree on the same tool?
let tool_names: Vec<&str> = tool_proposals
.iter()
.filter_map(|o| o.tool_name.as_deref())
.collect();
if !tool_names.is_empty() {
// If some workers propose tools and others answer directly, conflict
if !answers.is_empty() {
return Decision::NeedsReducer {
reason: "some workers propose tools, others answer directly".into(),
};
}
let first = tool_names[0];
let unanimous = tool_names.iter().all(|n| *n == first);
if unanimous {
let best = best_tool_proposal(&tool_proposals);
return Decision::ToolCall {
name: first.to_string(),
arguments: best
.tool_arguments
.clone()
.unwrap_or(Value::Object(Default::default())),
};
}
// Different tools proposed — check if one is clearly dominant
let max_conf = best_tool_proposal(&tool_proposals);
let others_low = tool_proposals
.iter()
.filter(|o| o.tool_name != max_conf.tool_name)
.all(|o| o.confidence < 0.5);
if max_conf.confidence > 0.7 && others_low {
return Decision::ToolCall {
name: max_conf.tool_name.clone().unwrap_or_default(),
arguments: max_conf
.tool_arguments
.clone()
.unwrap_or(Value::Object(Default::default())),
};
}
return Decision::NeedsReducer {
reason: format!("conflicting tool proposals: {}", tool_names.join(" vs ")),
};
}
// Tool proposals without extractable names — single high-confidence?
if tool_proposals.len() == 1 && tool_proposals[0].confidence > 0.6 {
return Decision::NeedsReducer {
reason: "tool proposal without parseable tool name".into(),
};
}
}
// Tool turns never reach here — a fresh tool request takes the asymmetric
// actor path in `tool_turn`, where the best tool-caller acts and references
// only advise. This arbiter is purely an answer/critique/uncertainty
// arbiter: any tool-shaped output on the text path was demoted by
// `enforce_tool_call_contract` (tools disabled ⇒ empty allow-list), so it
// is treated as ordinary text below.
// ── Answer arbitration ───────────────────────────────────────
@ -168,6 +81,26 @@ pub fn arbitrate(outputs: &[WorkerOutput], has_tools: bool) -> Decision {
};
}
// Diverging answers are synthesized, not picked.
//
// Returning `best.payload` verbatim was close to arbitrary: models
// rarely emit our `kind:/confidence:` envelope, so `normalize`
// defaults them all to `confidence: 0.5` and `max_by` just returns
// the first maximum — i.e. whichever worker happened to finish
// first. That discarded every other worker's contribution while
// still paying to run them.
//
// This is the pattern Together's MoA is built on and benchmarks
// well with: fan out, then have an aggregator read all responses
// and write one. We only take that path when the answers actually
// disagree — when they agree, the existing consensus/early-exit
// paths are cheaper and already correct.
if answers.len() >= 2 && largest_agreeing_cluster(&answers).is_none() {
return Decision::NeedsReducer {
reason: format!("{} workers answered with no agreement", answers.len()),
};
}
return Decision::Answer(best.payload.clone());
}
@ -208,7 +141,6 @@ pub fn try_early_decision(
outputs: &[WorkerOutput],
total_workers: usize,
total_finished: usize,
has_tools: bool,
strong_gate: StrongGate,
) -> Option<Decision> {
if outputs.is_empty() {
@ -231,7 +163,7 @@ pub fn try_early_decision(
// If we have 1 successful output and no more workers are coming,
// return it immediately — no point waiting.
if outputs.len() == 1 && remaining == 0 {
return Some(single_output_decision(&outputs[0], has_tools));
return Some(single_output_decision(&outputs[0]));
}
// ── Single output with others still pending ─────────────────────
@ -246,9 +178,7 @@ pub fn try_early_decision(
}
// Tier gate: a small-tier sole-survivor *answer* must not
// finalize while the strong worker is still running. The
// fan-out loop bounds this wait via `strong_patience`. Tool
// proposals are exempt — they are schema-verified and holding
// them would slow agent loops.
// fan-out loop bounds this wait via `strong_patience`.
if strong_pending
&& outputs[0].role != WorkerRole::Strong
&& outputs[0].kind == OutputKind::Answer
@ -259,23 +189,22 @@ pub fn try_early_decision(
tracing::info!(
"moa: early exit — sole survivor, {failed_count}/{total_workers} workers failed",
);
return Some(single_output_decision(&outputs[0], has_tools));
return Some(single_output_decision(&outputs[0]));
}
// ── 2+ outputs: check for consensus ─────────────────────────────
// ── 2+ outputs: check for answer consensus ──────────────────────
//
// Tool turns never reach here (they take the asymmetric actor path), so
// this is purely answer-consensus detection.
let answers: Vec<&WorkerOutput> = outputs.iter().filter(|o| is_usable_answer(o)).collect();
let tool_proposals: Vec<&WorkerOutput> = outputs
.iter()
.filter(|o| o.kind == OutputKind::ToolProposal)
.collect();
// Workers agree on an answer — but agreement means the *content*
// overlaps, not just that they all produced an Answer-kind output.
// Two workers saying "Paris" and "Berlin" must not be treated as
// consensus. Find the largest cluster of content-similar answers
// and only early-exit if it's ≥2 workers AND a majority of answers.
let agreeing_cluster = if answers.len() >= 2 && tool_proposals.is_empty() {
let agreeing_cluster = if answers.len() >= 2 {
largest_agreeing_cluster(&answers)
} else {
None
@ -293,48 +222,6 @@ pub fn try_early_decision(
}
}
// All agree on the same tool call
if has_tools && tool_proposals.len() >= 2 && answers.is_empty() {
let tool_names: Vec<&str> = tool_proposals
.iter()
.filter_map(|o| o.tool_name.as_deref())
.collect();
if !tool_names.is_empty() {
let first = tool_names[0];
let unanimous = tool_names.iter().all(|n| *n == first);
if unanimous {
let best = best_tool_proposal(&tool_proposals);
tracing::info!(
"moa: early exit — {} workers agree on tool '{}', {} still pending",
tool_proposals.len(),
first,
remaining,
);
return Some(Decision::ToolCall {
name: first.to_string(),
arguments: best
.tool_arguments
.clone()
.unwrap_or(serde_json::Value::Object(Default::default())),
});
}
}
}
// Conflict detected early — some say tool, some say answer.
// Escalate to reducer now, don't wait for more conflicting opinions.
if !tool_proposals.is_empty() && !answers.is_empty() {
tracing::info!(
"moa: early escalation — {} tool proposals vs {} answers, {} still pending",
tool_proposals.len(),
answers.len(),
remaining,
);
return Some(Decision::NeedsReducer {
reason: "some workers propose tools, others answer directly".into(),
});
}
// Not enough signal yet — keep waiting
None
}
@ -552,27 +439,23 @@ fn largest_agreeing_cluster<'a>(answers: &[&'a WorkerOutput]) -> Option<(usize,
best
}
fn single_output_decision(output: &WorkerOutput, has_tools: bool) -> Decision {
fn single_output_decision(output: &WorkerOutput) -> Decision {
if output.kind == OutputKind::Answer && !is_usable_answer(output) {
// Two distinct unusable shapes reach here, and the reason string is
// surfaced to the reducer as context, so keep them apart.
return Decision::NeedsReducer {
reason: "single worker returned silent reply sentinel".into(),
reason: if output.truncated {
"single worker answer truncated at token limit".into()
} else {
"single worker returned silent reply sentinel".to_string()
},
};
}
// Tool turns take the asymmetric actor path, so this text-path arbiter
// never sees an executable ToolProposal (tool-shaped text is demoted to
// Uncertainty by `enforce_tool_call_contract` when tools are disabled).
match output.kind {
OutputKind::ToolProposal if has_tools => {
if let Some(ref name) = output.tool_name {
Decision::ToolCall {
name: name.clone(),
arguments: output
.tool_arguments
.clone()
.unwrap_or(Value::Object(Default::default())),
}
} else {
Decision::Answer(output.payload.clone())
}
}
OutputKind::Uncertainty => Decision::NeedsReducer {
reason: "single worker uncertain".into(),
},
@ -580,8 +463,19 @@ fn single_output_decision(output: &WorkerOutput, has_tools: bool) -> Decision {
}
}
/// Is this output an answer we can return to the caller verbatim?
///
/// Excludes truncated answers. A response the backend cut off at the token
/// limit is a half-finished sentence: it must not win the confidence pick,
/// must not anchor consensus, and must not be shipped as-is.
///
/// Truncated answers are *not* dropped from the turn, though — they stay in
/// `outputs` and so are still packed into the reducer's context by
/// `pack_for_reducer_selected`, which labels them as incomplete. Partial text
/// is usable material for synthesis; it just can't be the final answer.
fn is_usable_answer(output: &WorkerOutput) -> bool {
output.kind == OutputKind::Answer
&& !output.truncated
&& !crate::normalize::is_silent_reply_sentinel(&output.payload)
}
@ -599,21 +493,13 @@ mod tests {
model: "test".to_string(),
role: WorkerRole::Generalist,
elapsed_ms: 0,
truncated: false,
}
}
fn make_tool_output(confidence: f32, tool: &str, args: Value) -> WorkerOutput {
WorkerOutput {
kind: OutputKind::ToolProposal,
confidence,
tool_name: Some(tool.to_string()),
tool_arguments: Some(args),
payload: "propose tool".to_string(),
model: "test".to_string(),
role: WorkerRole::Generalist,
elapsed_ms: 0,
}
}
// Tool arbitration is intentionally absent here: tool turns take the
// asymmetric actor path in `tool_turn`, so this arbiter only ever sees
// answers / critiques / uncertainty. See the module docs.
#[test]
fn unanimous_answer_picks_highest_confidence() {
@ -621,55 +507,18 @@ mod tests {
make_output(OutputKind::Answer, 0.7, "Paris"),
make_output(OutputKind::Answer, 0.9, "Paris is the capital"),
];
match arbitrate(&outputs, false) {
match arbitrate(&outputs) {
Decision::Answer(text) => assert!(text.contains("Paris")),
other => panic!("expected Answer, got {other:?}"),
}
}
#[test]
fn unanimous_tool_proposal() {
let outputs = vec![
make_tool_output(0.8, "read_file", serde_json::json!({"path": "a.rs"})),
make_tool_output(0.7, "read_file", serde_json::json!({"path": "a.rs"})),
];
match arbitrate(&outputs, true) {
Decision::ToolCall { name, .. } => assert_eq!(name, "read_file"),
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn conflicting_tools_needs_reducer() {
let outputs = vec![
make_tool_output(0.6, "read_file", serde_json::json!({})),
make_tool_output(0.6, "web_search", serde_json::json!({})),
];
match arbitrate(&outputs, true) {
Decision::NeedsReducer { reason } => assert!(reason.contains("conflicting")),
other => panic!("expected NeedsReducer, got {other:?}"),
}
}
#[test]
fn tool_vs_answer_needs_reducer() {
let outputs = vec![
make_tool_output(0.7, "read_file", serde_json::json!({})),
make_output(OutputKind::Answer, 0.8, "I can answer that directly"),
];
match arbitrate(&outputs, true) {
Decision::NeedsReducer { reason } => assert!(reason.contains("some workers")),
other => panic!("expected NeedsReducer, got {other:?}"),
}
}
// ── Early decision tests ────────────────────────────────────
#[test]
fn early_decision_none_with_one_of_three() {
let outputs = vec![make_output(OutputKind::Answer, 0.9, "Paris")];
// 1 of 3 — too early to decide
assert!(try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off).is_none());
assert!(try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off).is_none());
}
#[test]
@ -678,48 +527,19 @@ mod tests {
make_output(OutputKind::Answer, 0.8, "Paris"),
make_output(OutputKind::Answer, 0.9, "Paris is the capital"),
];
// 2 of 3 agree — early exit
match try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off) {
match try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off) {
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
other => panic!("expected early Answer, got {other:?}"),
}
}
#[test]
fn early_decision_tool_consensus() {
let outputs = vec![
make_tool_output(0.8, "read_file", serde_json::json!({"path": "a.rs"})),
make_tool_output(0.7, "read_file", serde_json::json!({"path": "a.rs"})),
];
match try_early_decision(&outputs, 3, outputs.len(), true, StrongGate::Off) {
Some(Decision::ToolCall { name, .. }) => assert_eq!(name, "read_file"),
other => panic!("expected early ToolCall, got {other:?}"),
}
}
#[test]
fn early_decision_conflict_escalates() {
let outputs = vec![
make_tool_output(0.7, "read_file", serde_json::json!({})),
make_output(OutputKind::Answer, 0.8, "I know the answer"),
];
match try_early_decision(&outputs, 3, outputs.len(), true, StrongGate::Off) {
Some(Decision::NeedsReducer { .. }) => {}
other => panic!("expected early NeedsReducer, got {other:?}"),
}
}
#[test]
fn early_decision_requires_content_agreement() {
// Two high-confidence answers that disagree on the answer noun.
// The old code wrongly early-exited on the highest-confidence
// one. The new subset rule sees `{paris}` and `{berlin}` as
// distinct leftovers, so neither side is a subset of the other.
let outputs = vec![
make_output(OutputKind::Answer, 0.9, "The capital of France is Paris"),
make_output(OutputKind::Answer, 0.8, "The capital of France is Berlin"),
];
let res = try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off);
let res = try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off);
assert!(
res.is_none(),
"disagreeing answers should not trigger early-exit, got {res:?}"
@ -728,15 +548,11 @@ mod tests {
#[test]
fn early_decision_agrees_on_terse_vs_verbose() {
// The most common real-world agreement pattern: one worker is
// terse, another is verbose, both correct. Terse content tokens
// ⊆ verbose content tokens → cluster.
let outputs = vec![
make_output(OutputKind::Answer, 0.7, "Paris"),
make_output(OutputKind::Answer, 0.9, "Paris is the capital of France"),
];
match try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off) {
// Representative picks the most complete (most tokens) member.
match try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off) {
Some(Decision::Answer(text)) => {
let lower = text.to_lowercase();
assert!(lower.contains("paris"), "expected Paris, got {text:?}");
@ -751,14 +567,12 @@ mod tests {
#[test]
fn early_decision_majority_cluster_wins() {
// Two agreeing answers + one outlier. Cluster of 2 is a majority
// of 3 finished answers → early-exit fires.
let outputs = vec![
make_output(OutputKind::Answer, 0.9, "Paris"),
make_output(OutputKind::Answer, 0.8, "Paris is the capital"),
make_output(OutputKind::Answer, 0.6, "I think it's Lyon"),
];
match try_early_decision(&outputs, 4, outputs.len(), false, StrongGate::Off) {
match try_early_decision(&outputs, 4, outputs.len(), StrongGate::Off) {
Some(Decision::Answer(text)) => assert!(
text.to_lowercase().contains("paris"),
"should pick from the agreeing cluster, got {text:?}"
@ -769,16 +583,12 @@ mod tests {
#[test]
fn early_decision_disagreement_with_shared_scaffolding_still_blocks() {
// High token overlap from shared scaffolding ("the capital of
// France is X") used to false-positive a similarity check.
// With subset containment, each answer has a distinct leftover
// (paris, berlin, madrid) so no cluster forms.
let outputs = vec![
make_output(OutputKind::Answer, 0.9, "The capital of France is Paris"),
make_output(OutputKind::Answer, 0.9, "The capital of France is Berlin"),
make_output(OutputKind::Answer, 0.5, "The capital of France is Madrid"),
];
let res = try_early_decision(&outputs, 4, outputs.len(), false, StrongGate::Off);
let res = try_early_decision(&outputs, 4, outputs.len(), StrongGate::Off);
assert!(
res.is_none(),
"three disagreeing answers should not early-exit, got {res:?}"
@ -787,14 +597,11 @@ mod tests {
#[test]
fn early_decision_negation_blocks_agreement() {
// The negation guard keeps "not" / "dont" etc. as content tokens,
// so an answer with negation is not a subset of the affirmative
// version even when all other tokens match.
let outputs = vec![
make_output(OutputKind::Answer, 0.9, "You should use grep"),
make_output(OutputKind::Answer, 0.8, "You should not use grep"),
];
let res = try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off);
let res = try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off);
assert!(
res.is_none(),
"affirmative vs negated answer should not cluster, got {res:?}"
@ -803,13 +610,11 @@ mod tests {
#[test]
fn early_decision_dont_blocks_agreement() {
// Same idea with a contraction. After punctuation stripping
// "don't" → "dont", which is in NEGATION_TOKENS.
let outputs = vec![
make_output(OutputKind::Answer, 0.9, "Do that"),
make_output(OutputKind::Answer, 0.8, "Don't do that"),
];
let res = try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off);
let res = try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off);
assert!(
res.is_none(),
"affirmative vs negated should not cluster, got {res:?}"
@ -818,12 +623,11 @@ mod tests {
#[test]
fn early_decision_numeric_answers_cluster() {
// Numeric answers survive the length filter and cluster cleanly.
let outputs = vec![
make_output(OutputKind::Answer, 0.9, "42"),
make_output(OutputKind::Answer, 0.8, "The answer is 42"),
];
match try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off) {
match try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off) {
Some(Decision::Answer(text)) => assert!(text.contains("42")),
other => panic!("expected numeric agreement, got {other:?}"),
}
@ -831,10 +635,8 @@ mod tests {
#[test]
fn early_decision_single_survivor() {
// 1 success out of 3, other 2 failed — should return the single answer
let outputs = vec![make_output(OutputKind::Answer, 0.8, "Paris")];
// total_workers=3, total_finished=3 (1 success + 2 failures), remaining=0
match try_early_decision(&outputs, 3, 3, false, StrongGate::Off) {
match try_early_decision(&outputs, 3, 3, StrongGate::Off) {
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
other => panic!("expected early Answer for sole survivor, got {other:?}"),
}
@ -846,8 +648,7 @@ mod tests {
make_output(OutputKind::Answer, 0.3, "maybe Paris"),
make_output(OutputKind::Answer, 0.4, "could be Paris"),
];
// Both answers but low confidence — should wait for more
assert!(try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off).is_none());
assert!(try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off).is_none());
}
#[test]
@ -856,7 +657,7 @@ mod tests {
make_output(OutputKind::Answer, 0.99, "NO_REPLY"),
make_output(OutputKind::Answer, 0.6, "I can help with that."),
];
match arbitrate(&outputs, false) {
match arbitrate(&outputs) {
Decision::Answer(text) => assert_eq!(text, "I can help with that."),
other => panic!("expected usable answer, got {other:?}"),
}
@ -865,7 +666,7 @@ mod tests {
#[test]
fn no_reply_sentinel_for_single_output_needs_reducer() {
let outputs = vec![make_output(OutputKind::Answer, 0.99, "NO_REPLY")];
match arbitrate(&outputs, false) {
match arbitrate(&outputs) {
Decision::NeedsReducer { reason } => {
assert!(reason.contains("silent reply sentinel"));
}
@ -879,7 +680,7 @@ mod tests {
make_output(OutputKind::Uncertainty, 0.2, "not sure"),
make_output(OutputKind::Uncertainty, 0.3, "hard to say"),
];
match arbitrate(&outputs, false) {
match arbitrate(&outputs) {
Decision::NeedsReducer { reason } => assert!(reason.contains("uncertain")),
other => panic!("expected NeedsReducer, got {other:?}"),
}
@ -887,10 +688,8 @@ mod tests {
#[test]
fn early_decision_sole_survivor_majority_failed() {
// 1 success, 3 failures, 1 still pending — majority failed, return sole survivor
let outputs = vec![make_output(OutputKind::Answer, 0.8, "Paris")];
// total_workers=5, total_finished=4 (1 success + 3 failures), remaining=1
match try_early_decision(&outputs, 5, 4, false, StrongGate::Off) {
match try_early_decision(&outputs, 5, 4, StrongGate::Off) {
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
other => {
panic!("expected early Answer for sole survivor (majority failed), got {other:?}")
@ -900,66 +699,96 @@ mod tests {
#[test]
fn early_decision_sole_survivor_minority_failed_waits() {
// 1 success, 1 failure, 3 still pending — minority failed, wait for more
let outputs = vec![make_output(OutputKind::Answer, 0.8, "Paris")];
// total_workers=5, total_finished=2 (1 success + 1 failure), remaining=3
assert!(try_early_decision(&outputs, 5, 2, false, StrongGate::Off).is_none());
assert!(try_early_decision(&outputs, 5, 2, StrongGate::Off).is_none());
}
// ── Truncation ───────────────────────────────────────────────────
//
// 39/140 recorded responses came back `finish_reason == "length"`, and 24
// of those carry partial text. Such an answer parses as normal prose at
// the default 0.5 confidence, so before truncation was tracked it could
// win the pick and be returned verbatim as a half-finished sentence.
#[test]
fn truncated_answer_is_not_returned_verbatim() {
let mut cut_off = make_output(
OutputKind::Answer,
0.9,
"**Island Magic: My Unforgettable Journey Through the Heart of",
);
cut_off.truncated = true;
let complete = make_output(OutputKind::Answer, 0.5, "Hawaii is worth visiting.");
match arbitrate(&[cut_off, complete]) {
Decision::Answer(text) => assert_eq!(
text, "Hawaii is worth visiting.",
"a truncated answer must not win even with higher confidence"
),
other => panic!("expected the complete answer, got {other:?}"),
}
}
#[test]
fn best_tool_proposal_prefers_arguments() {
let without_args = WorkerOutput {
kind: OutputKind::ToolProposal,
confidence: 0.9,
tool_name: Some("read_file".into()),
tool_arguments: None,
payload: "calling read_file".into(),
model: "fast-model".into(),
role: crate::worker::WorkerRole::Fast,
elapsed_ms: 100,
};
let with_args = WorkerOutput {
kind: OutputKind::ToolProposal,
confidence: 0.6,
tool_name: Some("read_file".into()),
tool_arguments: Some(serde_json::json!({"path": "/tmp/test.txt"})),
payload: "calling read_file".into(),
model: "strong-model".into(),
role: crate::worker::WorkerRole::Strong,
elapsed_ms: 3000,
};
let proposals = vec![&without_args, &with_args];
let best = best_tool_proposal(&proposals);
assert_eq!(best.model, "strong-model");
assert!(best.tool_arguments.is_some());
fn sole_truncated_answer_goes_to_synthesis() {
let mut cut_off = make_output(OutputKind::Answer, 0.9, "The first step is to");
cut_off.truncated = true;
match arbitrate(&[cut_off]) {
Decision::NeedsReducer { reason } => assert!(
reason.contains("truncated"),
"reason should name truncation so the reducer gets useful context; got {reason:?}"
),
other => panic!("expected NeedsReducer, got {other:?}"),
}
}
#[test]
fn best_tool_proposal_falls_back_to_confidence() {
let a = WorkerOutput {
kind: OutputKind::ToolProposal,
confidence: 0.6,
tool_name: Some("read_file".into()),
tool_arguments: Some(serde_json::json!({"path": "/a.txt"})),
payload: "calling read_file".into(),
model: "model-a".into(),
role: crate::worker::WorkerRole::Specialist,
elapsed_ms: 2000,
};
let b = WorkerOutput {
kind: OutputKind::ToolProposal,
confidence: 0.9,
tool_name: Some("read_file".into()),
tool_arguments: Some(serde_json::json!({"path": "/b.txt"})),
payload: "calling read_file".into(),
model: "model-b".into(),
role: crate::worker::WorkerRole::Strong,
elapsed_ms: 3000,
};
let proposals = vec![&a, &b];
let best = best_tool_proposal(&proposals);
// Both have args, so confidence wins
assert_eq!(best.model, "model-b");
fn truncated_answers_do_not_anchor_consensus() {
let mut a = make_output(OutputKind::Answer, 0.9, "The capital of Japan is");
a.truncated = true;
let mut b = make_output(OutputKind::Answer, 0.9, "The capital of Japan is");
b.truncated = true;
assert!(
matches!(
try_early_decision(&[a, b], 4, 2, StrongGate::Off),
None | Some(Decision::NeedsReducer { .. })
),
"truncated agreement must not short-circuit the turn"
);
}
// ── Synthesis on disagreement ────────────────────────────────────
#[test]
fn diverging_answers_go_to_synthesis_rather_than_an_arbitrary_pick() {
let a = make_output(OutputKind::Answer, 0.5, "Use ripgrep for this search task");
let b = make_output(OutputKind::Answer, 0.5, "Postgres indexes are B-trees");
let c = make_output(
OutputKind::Answer,
0.5,
"Kubernetes schedules pods on nodes",
);
match arbitrate(&[a, b, c]) {
Decision::NeedsReducer { reason } => assert!(
reason.contains("no agreement"),
"reason should explain the escalation; got {reason:?}"
),
other => panic!("three unrelated answers should be synthesized, got {other:?}"),
}
}
#[test]
fn agreeing_answers_still_short_circuit_without_the_reducer() {
let a = make_output(OutputKind::Answer, 0.5, "The capital of Japan is Tokyo");
let b = make_output(OutputKind::Answer, 0.5, "The capital of Japan is Tokyo");
assert!(
matches!(arbitrate(&[a, b]), Decision::Answer(_)),
"agreeing answers must not be sent to the reducer"
);
}
// ── Tier gate (StrongGate) ───────────────────────────────────────
@ -982,8 +811,6 @@ mod tests {
#[test]
fn gate_holds_small_tier_consensus_while_strong_pending() {
// Two small-tier workers agree — without the gate this would
// early-exit. With the strong worker still running, hold.
let outputs = vec![
make_role_output(OutputKind::Answer, 0.8, "Paris", WorkerRole::Fast),
make_role_output(
@ -994,20 +821,17 @@ mod tests {
),
];
assert!(
try_early_decision(&outputs, 3, outputs.len(), false, GATE_PENDING).is_none(),
try_early_decision(&outputs, 3, outputs.len(), GATE_PENDING).is_none(),
"small-tier consensus must be held while the strong worker is pending"
);
// Same outputs, gate off → previous behavior (early exit).
assert!(
try_early_decision(&outputs, 3, outputs.len(), false, StrongGate::Off).is_some(),
try_early_decision(&outputs, 3, outputs.len(), StrongGate::Off).is_some(),
"gate off must preserve pre-gate early-exit behavior"
);
}
#[test]
fn gate_passes_consensus_that_includes_strong_worker() {
// Strong worker has answered and agrees — that's agreement WITH
// the strong model. Ship it even though another worker is pending.
let outputs = vec![
make_role_output(OutputKind::Answer, 0.8, "Paris", WorkerRole::Fast),
make_role_output(
@ -1020,7 +844,7 @@ mod tests {
let gate = StrongGate::Active {
strong_pending: false,
};
match try_early_decision(&outputs, 3, outputs.len(), false, gate) {
match try_early_decision(&outputs, 3, outputs.len(), gate) {
Some(Decision::Answer(text)) => assert!(text.contains("Paris")),
other => panic!("expected early Answer with strong agreement, got {other:?}"),
}
@ -1028,43 +852,21 @@ mod tests {
#[test]
fn gate_holds_small_tier_sole_survivor_answer() {
// Majority failed, sole survivor is a small-tier Answer, strong
// still pending → hold (the fan-out patience timer bounds this).
let outputs = vec![make_role_output(
OutputKind::Answer,
0.9,
"Tokyo",
WorkerRole::Fast,
)];
// 3 dispatched, 2 finished (1 ok + 1 failed) → majority_failed
assert!(
try_early_decision(&outputs, 3, 2, false, GATE_PENDING).is_none(),
try_early_decision(&outputs, 3, 2, GATE_PENDING).is_none(),
"small-tier sole-survivor answer must be held while strong is pending"
);
// Gate off → pre-gate behavior: sole survivor ships.
assert!(try_early_decision(&outputs, 3, 2, false, StrongGate::Off).is_some());
}
#[test]
fn gate_does_not_hold_tool_proposals() {
// Tool proposals are schema-verified and exempt from the gate —
// agent loops must stay snappy.
let outputs = vec![WorkerOutput {
role: WorkerRole::Fast,
..make_tool_output(0.9, "read_file", serde_json::json!({"path": "x"}))
}];
// Majority failed → sole-survivor path, but it's a ToolProposal.
match try_early_decision(&outputs, 3, 2, true, GATE_PENDING) {
Some(Decision::ToolCall { name, .. }) => assert_eq!(name, "read_file"),
other => panic!("tool proposals must not be gated, got {other:?}"),
}
assert!(try_early_decision(&outputs, 3, 2, StrongGate::Off).is_some());
}
#[test]
fn strong_dissent_wins_over_small_consensus() {
// Two small workers agree on "Sydney"; the strong worker landed
// with a different answer ("Canberra"). Gate no longer pending.
// The strong worker's answer must win, not the small consensus.
let outputs = vec![
make_role_output(OutputKind::Answer, 0.9, "Sydney", WorkerRole::Fast),
make_role_output(OutputKind::Answer, 0.9, "Sydney", WorkerRole::Specialist),
@ -1073,7 +875,7 @@ mod tests {
let gate = StrongGate::Active {
strong_pending: false,
};
match try_early_decision(&outputs, 3, outputs.len(), false, gate) {
match try_early_decision(&outputs, 3, outputs.len(), gate) {
Some(Decision::Answer(text)) => assert!(
text.contains("Canberra"),
"strong dissent must win over small consensus, got {text:?}"
@ -1084,8 +886,6 @@ mod tests {
#[test]
fn gate_releases_when_strong_finished() {
// Strong worker finished (failed or succeeded without usable
// answer) — gate no longer pending, consensus ships.
let outputs = vec![
make_role_output(OutputKind::Answer, 0.8, "Paris", WorkerRole::Fast),
make_role_output(
@ -1099,7 +899,7 @@ mod tests {
strong_pending: false,
};
assert!(
try_early_decision(&outputs, 4, outputs.len(), false, gate).is_some(),
try_early_decision(&outputs, 4, outputs.len(), gate).is_some(),
"consensus must ship once the strong worker has finished"
);
}

View file

@ -138,6 +138,47 @@ impl ModelBackend for HttpBackend {
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
// Some OpenAI-compatible endpoints *require* reasoning and reject
// our thinking-disable flags outright. Observed against
// minimax-m2.5: `HTTP 400: Reasoning is mandatory for this
// endpoint`, which killed that worker on 12/12 recorded requests
// until the flags were dropped. A strict endpoint must cost us a
// slightly slower worker, not the whole worker.
if status.as_u16() == 400
&& text.to_ascii_lowercase().contains("reasoning")
&& sampling.enable_thinking == Some(false)
{
tracing::info!(
"moa: {model} rejected thinking-disable flags, retrying without them"
);
let mut retry_body = body.clone();
if let Some(obj) = retry_body.as_object_mut() {
obj.remove("reasoning_effort");
obj.remove("chat_template_kwargs");
}
let retry = self
.http
.post(&url)
.json(&retry_body)
.timeout(timeout)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let retry_status = retry.status();
if !retry_status.is_success() {
let retry_text = retry.text().await.unwrap_or_default();
return Err(format!(
"HTTP {retry_status}: {}",
crate::worker::truncate_chars(&retry_text, 200)
));
}
return retry
.json::<Value>()
.await
.map_err(|e| format!("response parse: {e}"));
}
return Err(format!(
"HTTP {status}: {}",
crate::worker::truncate_chars(&text, 200)
@ -164,6 +205,27 @@ pub struct ModelEntry {
// ─── Backend call + text extraction ──────────────────────────────────
/// A successful backend call: the extracted assistant text plus the
/// transport-level facts the arbiter needs that the text alone can't carry.
#[derive(Debug, Clone)]
pub struct BackendReply {
pub text: String,
/// `choices[0].finish_reason == "length"` — the backend cut this
/// response off at the token limit. See [`crate::normalize::WorkerOutput::truncated`].
pub truncated: bool,
}
impl BackendReply {
/// A complete (non-truncated) reply. Convenience for tests and for
/// call sites that synthesize replies rather than receiving them.
pub fn complete(text: impl Into<String>) -> Self {
Self {
text: text.into(),
truncated: false,
}
}
}
/// Call a backend and extract the assistant text from the response.
/// Retries once on HTTP 429 (rate limit) after the server's `retry-after`
/// delay (default 1s).
@ -175,7 +237,7 @@ pub(crate) async fn call_backend(
max_tokens: u32,
timeout: Duration,
sampling: SamplingParams,
) -> Result<String, String> {
) -> Result<BackendReply, String> {
match backend
.chat_completion(model, messages, tools, max_tokens, timeout, sampling)
.await
@ -262,11 +324,21 @@ fn parse_retry_after(err: &str) -> Option<u64> {
/// an empty string — or worse, panics in `.unwrap()` chains. Use
/// `.pointer()` so a malformed response surfaces as a structured `Err`
/// rather than a hidden empty answer.
fn extract_text_from_response(resp: &Value) -> Result<String, String> {
fn extract_text_from_response(resp: &Value) -> Result<BackendReply, String> {
let message = resp
.pointer("/choices/0/message")
.ok_or_else(|| "malformed response: missing choices[0].message".to_string())?;
// The backend stopped at the token limit. Recorded open-model traces
// show this on 15/140 responses, so it is a normal operating condition
// rather than an edge case. Carry it out so the arbiter can refuse to
// return a half-finished sentence verbatim.
let truncated = resp
.pointer("/choices/0/finish_reason")
.and_then(Value::as_str)
== Some("length");
let reply = |text: String| BackendReply { text, truncated };
// Native tool_calls → KV format for normalizer
let first_tool_call = message
.get("tool_calls")
@ -281,9 +353,12 @@ fn extract_text_from_response(resp: &Value) -> Result<String, String> {
.pointer("/function/arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
return Ok(format!(
// A tool call that parsed into name + arguments is structurally
// complete even if the backend hit the token limit emitting it, so
// this path is never marked truncated.
return Ok(BackendReply::complete(format!(
"kind: tool_proposal\ntool: {name}\narguments: {args}\nconfidence: 0.9\npayload: calling {name}",
));
)));
}
let content = message
@ -294,12 +369,12 @@ fn extract_text_from_response(resp: &Value) -> Result<String, String> {
let stripped = worker::strip_thinking(&content);
if !stripped.is_empty() {
return Ok(stripped);
return Ok(reply(stripped));
}
let thinking = worker::extract_thinking(&content);
if !thinking.is_empty() {
return Ok(thinking);
return Ok(reply(thinking));
}
let reasoning = message
@ -307,7 +382,7 @@ fn extract_text_from_response(resp: &Value) -> Result<String, String> {
.and_then(|r| r.as_str())
.unwrap_or("");
if !reasoning.is_empty() {
return Ok(reasoning.to_string());
return Ok(reply(reasoning.to_string()));
}
Err("empty response".into())

View file

@ -67,7 +67,24 @@ const MOA_PREAMBLE: &str = "\
[Multiple models are analyzing this request in parallel. \
Respond with your best answer or tool call. Be direct.]";
/// Text-turn preamble.
///
/// The tool-turn wording ("your best answer **or tool call**. Be direct.") is
/// wrong on a text turn twice over: there is no tool to call, and "be direct"
/// pushes workers toward stubs. Since these drafts are the *input* to the
/// refinement round, brevity here compounds — measured end-to-end, MoA answers
/// ran ~3.3k chars against a ~4.1k-char solo baseline and lost on judged
/// quality. The study that showed the gain gave workers no such instruction.
const MOA_PREAMBLE_TEXT: &str = "\
[Multiple models are answering this request in parallel; the best parts of each \
will be combined. Give your most accurate and complete answer.]";
fn augmented_system_prompt_for_mode(session: &Session, include_tool_guidance: bool) -> String {
let preamble = if include_tool_guidance {
MOA_PREAMBLE
} else {
MOA_PREAMBLE_TEXT
};
match session.system_prompt() {
Some(sp) => {
let prompt = if include_tool_guidance {
@ -75,9 +92,9 @@ fn augmented_system_prompt_for_mode(session: &Session, include_tool_guidance: bo
} else {
strip_tool_guidance_sections(&sp)
};
format!("{MOA_PREAMBLE}\n\n{prompt}")
format!("{preamble}\n\n{prompt}")
}
None => MOA_PREAMBLE.to_string(),
None => preamble.to_string(),
}
}
@ -263,26 +280,64 @@ pub fn pack_for_reducer_selected(
) -> (Vec<Value>, Option<Value>) {
let user_text = session.last_user_text();
let mut system_parts = vec![
augmented_system_prompt_for_mode(session, has_tools),
String::new(),
format!("Multiple models analyzed this request and disagreed. Reason: {reason}"),
"Review their outputs below and produce ONE final response — either a direct answer \
or a tool call. Be concise."
.to_string(),
];
// Synthesis framing adapted from Together's MoA aggregator prompt: tell the
// model to synthesize (not relay) and warn that inputs may be wrong — the
// second clause stops it averaging in confidently-wrong inputs. We add
// per-worker attribution and per-payload length bounds (below), which
// Together omits.
// The reducer is the synthesizer, not one of the parallel answerers. Giving
// it the *worker* preamble ("multiple models are answering in parallel;
// give your most complete answer") on top of the synthesis instruction is
// contradictory framing: it tells the model to both draft and aggregate. A
// 32B reconciles it; an 8B reducer does not. So the reducer gets the
// agent's own system prompt (tool guidance only when this is a tool turn)
// plus the synthesis instruction — matching the harness configuration that
// measured 12W/2L on a 6x8B pool.
let mut system_parts: Vec<String> = Vec::new();
if let Some(sp) = session.system_prompt() {
system_parts.push(sp);
system_parts.push(String::new());
}
if has_tools {
system_parts.push(format!(
"Multiple models analyzed this request. Reason for synthesis: {reason}"
));
}
system_parts.push(synthesis_instruction(has_tools));
// Worker outputs
system_parts.push(String::new());
system_parts.push("## Worker outputs".to_string());
let payload_budget = reducer_payload_budget(has_tools);
for (i, output) in outputs.iter().enumerate() {
system_parts.push(format!("\n[Worker {}{}]:", i + 1, output.model,));
let payload = if output.payload.len() > 500 {
format!("{}...", crate::worker::truncate_chars(&output.payload, 497))
// Anonymous on text turns, matching the measured configuration and
// Hermes, which anonymizes reference outputs "to prevent aggregator
// bias" — a named model invites deference to the name rather than the
// content. Tool turns keep attribution: the reducer is arbitrating
// between proposals and provenance is genuinely useful there, and it
// is what the tool-path tests pin.
if has_tools {
system_parts.push(format!("\n[Worker {}{}]:", i + 1, output.model));
} else {
system_parts.push(format!("\n[Response {}]:", i + 1));
}
let payload = if output.payload.len() > payload_budget {
format!(
"{}...",
crate::worker::truncate_chars(&output.payload, payload_budget - 3)
)
} else {
output.payload.clone()
};
system_parts.push(payload);
// Truncated inputs are labelled so the reducer treats them as partial
// material rather than copying a dangling sentence as a finished
// answer. `is_usable_answer` already bars them from winning verbatim;
// this is what lets them still contribute here.
if output.truncated {
system_parts
.push(" → NOTE: cut off at the token limit — incomplete, do not copy".to_string());
}
if let Some(ref tool) = output.tool_name {
system_parts.push(format!(" → Proposed tool: {tool}"));
if let Some(ref args) = output.tool_arguments {
@ -302,6 +357,218 @@ pub fn pack_for_reducer_selected(
)
}
/// Advisor framing for [`pack_for_reference`]. Deliberately does NOT ask for a
/// tool call: references hold no schemas, so requesting one yields tool-shaped
/// prose that can pull the actor off its own (better) choice.
const REFERENCE_PREAMBLE: &str = "\
You are advising another model that will decide and act on this request. \
You do not have tools and must not emit a tool call. Give a short, direct \
analysis: what the request is really asking, and what you would do. Be concise.";
/// Pack context for a **reference** (advisor), Hermes-style: only the
/// conversation's user/assistant text.
///
/// Three things are withheld on purpose:
/// * the agent's system prompt — an advisor told "you are a coding agent, run
/// the tests" role-plays the actor instead of advising it;
/// * the tool-call transcript — it anchors every advisor on the trajectory
/// already taken, collapsing the error-independence aggregation depends on;
/// * any instruction to emit a tool call (see [`REFERENCE_PREAMBLE`]).
///
/// The view is uniform across advisors (no per-role trimming) and is a stable
/// function of the history, so it caches across iterations.
pub fn pack_for_reference(session: &Session, max_messages: usize) -> PackedContext {
let mut messages = vec![json!({"role": "system", "content": REFERENCE_PREAMBLE})];
// User/assistant prose only: no system turn, no tool_calls, no tool results.
let history: Vec<Value> = session
.messages()
.iter()
.filter(|m| {
let role = m.get("role").and_then(Value::as_str).unwrap_or("");
let is_prose = matches!(role, "user" | "assistant");
let carries_tool_call = m.get("tool_calls").is_some();
let has_text = m
.get("content")
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty());
is_prose && !carries_tool_call && has_text
})
.cloned()
.collect();
let start = history.len().saturating_sub(max_messages);
messages.extend_from_slice(&history[start..]);
// Guarantee the current request is present even if it was filtered above.
let user_text = session.last_user_text();
let last_is_current = messages
.last()
.and_then(|m| m.get("content").and_then(Value::as_str))
== Some(user_text.as_str());
if !last_is_current && !user_text.is_empty() {
messages.push(json!({"role": "user", "content": user_text}));
}
PackedContext {
messages,
max_tokens: 600, // Hermes caps advisors; the slowest advisor sets turn latency.
tools: None,
}
}
/// How much of each peer draft a refiner may see.
const REFINEMENT_DRAFT_BUDGET: usize = 4000;
/// What the reducer is asked to do with the worker outputs.
///
/// Text turns use the wording the committee study measured
/// (`evals/moa-openrouter/RESULTS.md`), which asks for a *well-structured*
/// synthesis. The previous text wording framed the turn as reconciling a
/// disagreement and ended with "Be concise" — measured end-to-end through
/// `handle_turn`, that produced ~2.0k-char answers against a ~4.1k-char solo
/// baseline and lost to it on judged quality. Terseness is not the goal on a
/// reasoning turn; accuracy and completeness are.
///
/// Tool turns keep the tight framing: the output there is an action, and the
/// reducer must be free to emit a tool call rather than prose.
fn synthesis_instruction(has_tools: bool) -> String {
if has_tools {
"You have been provided with their responses below. Synthesize them into ONE \
final response either a direct answer or a tool call. Critically evaluate \
what they say: some of it may be biased or incorrect, and agreement between \
workers is not proof of correctness. Do not simply copy the longest or most \
confident response; produce the most accurate reply to the request. Be concise."
.to_string()
} else {
"You have been given a user request and several candidate responses from other \
models. Synthesize them into one high-quality response. Critically evaluate them \
some may be biased or incorrect, and agreement is not proof of correctness. Do not \
merely copy the longest or most confident; produce the most accurate, \
well-structured reply. Be direct."
.to_string()
}
}
/// How much of each worker payload the reducer may see.
///
/// Tool turns keep the tight bound: the reducer is choosing an action, the
/// signal is the proposal itself, and long prose crowds out the tool schemas.
///
/// Text turns need far more. Measured refined answers average ~3.8k chars
/// (`evals/moa-openrouter/RESULTS.md`), so a 500-char bound would hand the
/// reducer ~13% of each answer and discard exactly the content refinement just
/// produced — the measured gain could not survive it. Together's aggregator
/// passes references unbounded; we keep a bound so a pathological worker can't
/// blow the context, just a realistic one.
fn reducer_payload_budget(has_tools: bool) -> usize {
if has_tools { 500 } else { 4000 }
}
/// Pack context for a worker in the cross-peer refinement round.
///
/// The worker sees every round-1 draft (its own included, anonymized) and
/// rewrites its answer. Anonymizing keeps the worker from deferring to a name
/// it recognizes, and the framing asks for an improved answer rather than a
/// critique — the reducer still does the final synthesis.
pub fn pack_for_refinement(session: &Session, drafts: &[String]) -> PackedContext {
// Wording deliberately matches the eval that measured the +0.250 gain
// (`evals/moa-openrouter/RESULTS.md`), which in turn matches Together's
// `advanced-moa.py` — it reuses the aggregator prompt for refinement
// layers. A refinement-specific wording may well read better, but this is
// the configuration with evidence behind it; changing it should be a
// measured change, not an assumed improvement.
let mut system = String::from(
"You have been given a user request and several candidate responses from \
other models. Synthesize them into one high-quality response. Critically \
evaluate them some may be biased or incorrect, and agreement is not proof \
of correctness. Do not merely copy the longest or most confident; produce \
the most accurate, well-structured reply. Be direct.\n\nCandidate responses:",
);
for (i, d) in drafts.iter().enumerate() {
// Same reasoning as `reducer_payload_budget`: measured drafts average
// ~3.8k chars, so a tight bound would hand each refiner a fraction of
// what its peers actually said — the input the round exists to use.
let bounded = if d.len() > REFINEMENT_DRAFT_BUDGET {
format!(
"{}...",
crate::worker::truncate_chars(d, REFINEMENT_DRAFT_BUDGET - 3)
)
} else {
d.clone()
};
system.push_str(&format!("\n[Response {}]:\n{bounded}\n", i + 1));
}
PackedContext {
messages: vec![
json!({"role": "system", "content": system}),
json!({"role": "user", "content": session.last_user_text()}),
],
max_tokens: 1024,
tools: None,
}
}
/// Pack context for the actor in the asymmetric tool path: "here is advice, now
/// you act" (not the reducer's "you disagreed, reconcile"). Advice is prose,
/// per-model length-bounded and truncation-labelled; `has_tools` /
/// `selected_tool_names` attach the real tools the advisors never saw.
pub fn pack_for_actor(
session: &Session,
references: &[WorkerOutput],
has_tools: bool,
selected_tool_names: &[String],
) -> (Vec<Value>, Option<Value>) {
let user_text = session.last_user_text();
let mut system_parts = vec![
augmented_system_prompt_for_mode(session, has_tools),
String::new(),
"Other models were asked to advise on this request. They did not have \
access to tools; you do. Use their advice as input, but you decide the \
action. Critically evaluate what they say some of it may be biased or \
incorrect, and agreement between them is not proof of correctness. \
Respond with the single best action: a direct answer, or the appropriate \
tool call. Be concise."
.to_string(),
];
if references.is_empty() {
// No advice in time (slow/absent peers): actor proceeds alone.
system_parts.push(String::new());
system_parts
.push("(No advice from other models arrived in time — proceed on your own.)".into());
} else {
system_parts.push(String::new());
system_parts.push("## Advice from other models".to_string());
for (i, r) in references.iter().enumerate() {
system_parts.push(format!("\n[Advisor {}{}]:", i + 1, r.model));
let payload = if r.payload.len() > 500 {
format!("{}...", crate::worker::truncate_chars(&r.payload, 497))
} else {
r.payload.clone()
};
system_parts.push(payload);
if r.truncated {
system_parts.push(
" → NOTE: cut off at the token limit — incomplete, treat as partial".into(),
);
}
}
}
let tools = selected_tools(session, has_tools, selected_tool_names);
(
vec![
json!({"role": "system", "content": system_parts.join("\n")}),
json!({"role": "user", "content": user_text}),
],
tools,
)
}
fn selected_tools(
session: &Session,
has_tools: bool,
@ -793,6 +1060,165 @@ mod tests {
s
}
/// The reducer must actually see the answers it is synthesizing.
///
/// Measured refined answers average ~3.8k chars. The old flat 500-char
/// bound handed the reducer ~13% of each one, discarding exactly the
/// content the refinement round produces — the measured gain could not
/// have survived it. Tool turns keep the tight bound (the signal is the
/// proposal, and prose crowds out schemas).
#[test]
fn text_reducer_sees_realistic_answer_lengths() {
let long_answer = "x".repeat(3800);
let outputs = vec![
WorkerOutput {
kind: OutputKind::Answer,
confidence: 0.5,
tool_name: None,
tool_arguments: None,
payload: long_answer.clone(),
model: "a".into(),
role: WorkerRole::Generalist,
elapsed_ms: 0,
truncated: false,
},
WorkerOutput {
kind: OutputKind::Answer,
confidence: 0.5,
tool_name: None,
tool_arguments: None,
payload: long_answer,
model: "b".into(),
role: WorkerRole::Generalist,
elapsed_ms: 0,
truncated: false,
},
];
let session = session_with(&[json!({"role": "user", "content": "explain"})], None);
let (messages, _) =
pack_for_reducer_selected(&session, &outputs, "no agreement", false, &[]);
let sys = system_text(&messages);
// Each 3800-char answer must survive largely intact (2 answers).
assert!(
sys.len() > 7000,
"text reducer truncated the answers it must synthesize: {} chars",
sys.len()
);
}
/// Tool turns keep the tight payload bound.
#[test]
fn tool_reducer_keeps_the_tight_payload_bound() {
let outputs = vec![WorkerOutput {
kind: OutputKind::Answer,
confidence: 0.5,
tool_name: None,
tool_arguments: None,
payload: "x".repeat(3800),
model: "a".into(),
role: WorkerRole::Generalist,
elapsed_ms: 0,
truncated: false,
}];
let session = session_with(&[json!({"role": "user", "content": "read a file"})], None);
let (messages, _) = pack_for_reducer_selected(&session, &outputs, "conflict", true, &[]);
let sys = system_text(&messages);
assert!(
!sys.contains(&"x".repeat(1000)),
"tool reducer must keep payloads tight so schemas aren't crowded out"
);
}
/// Refiners must see what their peers actually said, for the same reason.
#[test]
fn refiners_see_realistic_peer_draft_lengths() {
let session = session_with(&[json!({"role": "user", "content": "explain"})], None);
let drafts = vec!["y".repeat(3800), "z".repeat(3800)];
let packed = pack_for_refinement(&session, &drafts);
let sys = system_text(&packed.messages);
assert!(
sys.len() > 7000,
"refiners were handed a fraction of their peers' drafts: {} chars",
sys.len()
);
}
/// Advisors must not be told to emit a tool call. Asking a schema-less
/// model for one yields tool-shaped prose, which measurably pulled the
/// actor off its own better choice.
#[test]
fn reference_packing_never_requests_a_tool_call() {
let s = session_with(&[json!({"role": "user", "content": "list src"})], None);
let packed = pack_for_reference(&s, 6);
let sys = system_text(&packed.messages).to_lowercase();
assert!(sys.contains("must not emit a tool call"));
assert!(packed.tools.is_none(), "advisors never receive schemas");
}
/// The agent's system prompt is withheld: an advisor handed "you are a
/// coding agent, run the tests" role-plays the actor instead of advising.
#[test]
fn reference_packing_strips_the_agent_system_prompt() {
let s = session_with(
&[
json!({"role": "system", "content": "You are a coding agent. SECRET_MARKER."}),
json!({"role": "user", "content": "what is failing?"}),
],
None,
);
let packed = pack_for_reference(&s, 6);
let all = serde_json::to_string(&packed.messages).unwrap();
assert!(
!all.contains("SECRET_MARKER"),
"agent system prompt must not reach advisors: {all}"
);
}
/// The tool transcript is withheld so advisors stay independent of the
/// trajectory already taken — error independence is what makes
/// aggregation worth anything.
#[test]
fn reference_packing_strips_the_tool_transcript() {
let s = session_with(
&[
json!({"role": "user", "content": "find the bug"}),
json!({"role": "assistant", "content": Value::Null,
"tool_calls": [{"id": "1", "type": "function",
"function": {"name": "list_dir", "arguments": "{\"path\":\"TRAJECTORY\"}"}}]}),
json!({"role": "tool", "tool_call_id": "1", "content": "TOOL_RESULT_MARKER"}),
json!({"role": "user", "content": "and now?"}),
],
None,
);
let packed = pack_for_reference(&s, 6);
let all = serde_json::to_string(&packed.messages).unwrap();
assert!(!all.contains("TOOL_RESULT_MARKER"), "tool results leaked");
assert!(!all.contains("TRAJECTORY"), "prior tool_calls leaked");
assert!(all.contains("and now?"), "current request must survive");
}
/// Uniform view regardless of role: every advisor sees the same prose,
/// so the packing is a stable function of history (and caches).
#[test]
fn reference_packing_keeps_user_assistant_prose() {
let s = session_with(
&[
json!({"role": "user", "content": "first question"}),
json!({"role": "assistant", "content": "first answer"}),
json!({"role": "user", "content": "second question"}),
],
None,
);
let packed = pack_for_reference(&s, 6);
let all = serde_json::to_string(&packed.messages).unwrap();
assert!(all.contains("first question"));
assert!(all.contains("first answer"));
assert!(all.contains("second question"));
}
/// Helper: extract the system message content from a packed message vec.
fn system_text(messages: &[Value]) -> String {
messages
@ -1266,6 +1692,7 @@ keep this";
model: model.to_string(),
role: WorkerRole::Strong,
elapsed_ms: 0,
truncated: false,
}
}
@ -1324,17 +1751,18 @@ keep this";
#[test]
fn reducer_truncates_long_worker_payloads() {
let s = session_with(&[user_msg("go")], None);
let big = "x".repeat(2000);
// Above the text-turn budget: realistic answers (~3.8k chars) must pass
// through intact — see `text_reducer_sees_realistic_answer_lengths` —
// but a pathological payload still gets bounded.
let big = "x".repeat(9000);
let outputs = vec![worker_out("alpha", &big)];
let (messages, _tools) = pack_for_reducer(&s, &outputs, "conflict", false);
let sys = system_text(&messages);
// Long payloads must be truncated (cap is ~500 chars + ellipsis).
// The full 2000-char string must NOT appear verbatim.
assert!(
!sys.contains(&big),
"reducer must truncate long worker payloads to keep context bounded",
"reducer must bound pathological worker payloads to keep context sane",
);
assert!(
sys.contains("..."),

View file

@ -9,6 +9,7 @@
use std::time::{Duration, Instant};
use crate::backend::BackendReply;
use crate::enforce_tool_call_contract;
use crate::worker::WorkerRole;
use crate::{WorkerSummary, arbiter, normalize};
@ -36,6 +37,25 @@ pub(crate) struct GatherPolicy {
pub grace_mode: GraceMode,
/// See [`crate::GatewayConfig::strong_patience`].
pub strong_patience: Duration,
/// How many qualifying answers must be in hand before the answer grace may
/// fire. Normally 1 — ship the good answer, stop waiting for the tail.
///
/// Raised when a refinement round is expected, so grace cannot trip before
/// there is enough material to refine with.
pub min_grace_answers: usize,
/// Whether grace expiry *finalizes* the turn or merely *stops collecting*.
///
/// Normally true: grace ships the best answer in hand and the turn is done.
///
/// False when a refinement round is expected. Grace is a deadline on
/// waiting, not a quality signal — but finalizing there skips refinement,
/// which on a small pool is the only step that beats the best member.
/// Measured end-to-end through `handle_turn`, finalizing grace made MoA
/// *lose* 7/8/65 to a single small model: 79 of 80 turns exited at grace
/// with a lone 8B answer and never refined. With this false, grace still
/// bounds the round-1 wait, then the turn proceeds to refine and
/// synthesize what arrived.
pub grace_finalizes: bool,
}
/// Identifier for a worker we dispatched. Used to reconcile the
@ -47,10 +67,17 @@ pub(crate) struct DispatchedWorker {
pub role: WorkerRole,
}
/// What a spawned worker task yields: `(model, role, reply, elapsed_ms)`.
///
/// The reply carries [`BackendReply::truncated`] alongside the text so the
/// arbiter can tell a complete answer from one the backend cut off at the
/// token limit. Flattening this to a bare `String` is what previously let
/// a half-finished sentence compete as a normal answer.
pub(crate) type WorkerTaskResult = (String, WorkerRole, Result<BackendReply, String>, u64);
pub(crate) async fn gather_workers_incremental(
join_set: &mut tokio::task::JoinSet<(String, WorkerRole, Result<String, String>, u64)>,
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
dispatched: &[DispatchedWorker],
has_tools: bool,
allowed_tools: &[String],
tools: Option<&Value>,
policy: GatherPolicy,
@ -63,6 +90,8 @@ pub(crate) async fn gather_workers_incremental(
first_answer_grace,
grace_mode,
strong_patience,
min_grace_answers,
grace_finalizes,
} = policy;
let total_workers = dispatched.len();
let mut outputs = Vec::new();
@ -101,9 +130,15 @@ pub(crate) async fn gather_workers_incremental(
}
match grace_mode {
GraceMode::Disabled => false,
GraceMode::Answer => outs.iter().any(|o| {
o.kind == normalize::OutputKind::Answer && o.confidence >= GRACE_MIN_CONFIDENCE
}),
GraceMode::Answer => {
outs.iter()
.filter(|o| {
o.kind == normalize::OutputKind::Answer
&& o.confidence >= GRACE_MIN_CONFIDENCE
})
.count()
>= min_grace_answers.max(1)
}
GraceMode::Tool => outs.iter().any(|o| {
o.kind == normalize::OutputKind::ToolProposal
&& o.tool_name.is_some()
@ -141,18 +176,26 @@ pub(crate) async fn gather_workers_incremental(
join = join_set.join_next() => join,
_ = tokio::time::sleep(grace_remaining), if armed => {
tracing::info!(
"moa: grace early-exit after {}ms (grace={}ms), {} pending",
"moa: grace expiry after {}ms (grace={}ms), {} pending, finalize={}",
dispatched_at.elapsed().as_millis(),
first_answer_grace.as_millis(),
total_workers.saturating_sub(total_finished),
grace_finalizes,
);
drain_after_early_exit(join_set, &mut summaries).await;
reconcile_dispatched(dispatched, &mut summaries);
// When a refinement round is expected, grace is only a deadline
// on *collecting*: stop waiting for the tail, but let the turn
// refine and synthesize what arrived instead of shipping one
// worker's answer.
if !grace_finalizes {
return (outputs, summaries, None);
}
let decision = match grace_mode {
GraceMode::Answer => grace_answer_decision(&outputs),
GraceMode::Tool => grace_tool_decision(&outputs),
GraceMode::Disabled => unreachable!("disabled grace cannot be armed"),
};
drain_after_early_exit(join_set, &mut summaries).await;
reconcile_dispatched(dispatched, &mut summaries);
return (outputs, summaries, Some(decision));
}
_ = tokio::time::sleep(patience_remaining), if gate_holding => {
@ -166,7 +209,6 @@ pub(crate) async fn gather_workers_incremental(
&outputs,
total_workers,
total_finished,
has_tools,
arbiter::StrongGate::Off,
) {
drain_after_early_exit(join_set, &mut summaries).await;
@ -182,22 +224,32 @@ pub(crate) async fn gather_workers_incremental(
};
match join_result {
Ok((model, role, Ok(text), elapsed)) => {
Ok((model, role, Ok(reply), elapsed)) => {
total_finished += 1;
if role == WorkerRole::Strong {
strong_finished = true;
}
let mut normalized =
normalize::normalize_worker_output(&text, &model, role, elapsed);
normalize::normalize_worker_output(&reply.text, &model, role, elapsed);
// Truncation is a transport fact the text can't carry: a
// response cut off at the token limit looks like a normal
// answer to the parser. Stamp it so the arbiter can keep it
// out of consensus and out of verbatim responses.
normalized.truncated = reply.truncated;
enforce_tool_call_contract(&mut normalized, allowed_tools, tools, &model);
tracing::info!(
"moa: worker {} ({}) → {:?} conf={:.2} ({}ms, {} chars)",
"moa: worker {} ({}) → {:?} conf={:.2} ({}ms, {} chars{})",
model,
role.label(),
normalized.kind,
normalized.confidence,
elapsed,
text.len(),
reply.text.len(),
if normalized.truncated {
", TRUNCATED"
} else {
""
},
);
summaries.push(WorkerSummary {
model: model.clone(),
@ -213,7 +265,6 @@ pub(crate) async fn gather_workers_incremental(
&outputs,
total_workers,
total_finished,
has_tools,
strong_gate(strong_finished, dispatched_at.elapsed()),
) {
drain_after_early_exit(join_set, &mut summaries).await;
@ -246,7 +297,6 @@ pub(crate) async fn gather_workers_incremental(
&outputs,
total_workers,
total_finished,
has_tools,
strong_gate(strong_finished, dispatched_at.elapsed()),
) {
drain_after_early_exit(join_set, &mut summaries).await;
@ -275,6 +325,99 @@ pub(crate) async fn gather_workers_incremental(
(outputs, summaries, None)
}
/// Gather **references** for the asymmetric (Hermes-style) tool path.
///
/// References run tool-free and only advise; the actor acts afterwards. So
/// unlike [`gather_workers_incremental`] this does no arbitration, no
/// consensus, no early-exit — it just collects whatever advice arrives within
/// a bounded window and returns it.
///
/// The bound is the whole point on a mixed/public mesh: we must not wait for
/// perfect when good-enough advice is already in hand. We stop as soon as
/// *either* `min_references` usable outputs have arrived *or* `deadline`
/// elapses (or every reference finishes), then abort the stragglers. An empty
/// result is legal — the actor proceeds on the user request alone.
pub(crate) async fn gather_references(
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
dispatched: &[DispatchedWorker],
deadline: Duration,
min_references: usize,
) -> (Vec<WorkerOutput>, Vec<WorkerSummary>) {
let mut outputs = Vec::new();
let mut summaries = Vec::new();
let started = Instant::now();
loop {
// Enough advice already, or we've waited long enough: stop.
if !outputs.is_empty() && outputs.len() >= min_references {
break;
}
let remaining = deadline.saturating_sub(started.elapsed());
if remaining.is_zero() {
tracing::info!(
"moa: reference deadline reached after {}ms with {} advisor(s)",
started.elapsed().as_millis(),
outputs.len(),
);
break;
}
let join_result = tokio::select! {
biased;
join = join_set.join_next() => join,
_ = tokio::time::sleep(remaining) => {
tracing::info!(
"moa: reference deadline ({}ms) elapsed with {} advisor(s)",
deadline.as_millis(),
outputs.len(),
);
break;
}
};
let Some(join_result) = join_result else {
break; // all references finished
};
match join_result {
Ok((model, role, Ok(reply), elapsed)) => {
let mut normalized =
normalize::normalize_worker_output(&reply.text, &model, role, elapsed);
normalized.truncated = reply.truncated;
// No `enforce_tool_call_contract`: references ran tool-free, so
// any tool-shaped text is advice, not an executable proposal.
summaries.push(WorkerSummary {
model,
role,
succeeded: true,
elapsed_ms: elapsed,
output_kind: Some(normalized.kind),
confidence: Some(normalized.confidence),
});
outputs.push(normalized);
}
Ok((model, role, Err(e), elapsed)) => {
tracing::warn!("moa: reference {model} ({}) failed: {e}", role.label());
summaries.push(WorkerSummary {
model,
role,
succeeded: false,
elapsed_ms: elapsed,
output_kind: None,
confidence: None,
});
}
Err(e) => {
tracing::warn!("moa: reference task panicked or was cancelled: {e}");
}
}
}
drain_after_early_exit(join_set, &mut summaries).await;
reconcile_dispatched(dispatched, &mut summaries);
(outputs, summaries)
}
fn grace_answer_decision(outputs: &[WorkerOutput]) -> arbiter::Decision {
// Prefer the Strong worker's qualifying answer when it has landed:
// if the biggest model already answered, shipping a smaller model's
@ -347,7 +490,7 @@ fn grace_tool_decision(outputs: &[WorkerOutput]) -> arbiter::Decision {
/// those are reconciled by [`reconcile_dispatched`] using the dispatch
/// list.
async fn drain_after_early_exit(
join_set: &mut tokio::task::JoinSet<(String, WorkerRole, Result<String, String>, u64)>,
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
summaries: &mut Vec<WorkerSummary>,
) {
join_set.abort_all();
@ -417,12 +560,33 @@ mod tests {
.to_string()
}
/// Spawn a worker that yields complete (non-truncated) text. Truncation
/// behavior has its own helper below so existing tests keep asserting
/// the same thing they always did.
fn spawn_worker(
join_set: &mut tokio::task::JoinSet<(String, WorkerRole, Result<String, String>, u64)>,
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
model: &str,
role: WorkerRole,
delay_ms: u64,
result: Result<String, String>,
) -> DispatchedWorker {
spawn_worker_reply(
join_set,
model,
role,
delay_ms,
result.map(BackendReply::complete),
)
}
/// Spawn a worker yielding a full [`BackendReply`], so a test can set
/// `truncated`.
fn spawn_worker_reply(
join_set: &mut tokio::task::JoinSet<WorkerTaskResult>,
model: &str,
role: WorkerRole,
delay_ms: u64,
result: Result<BackendReply, String>,
) -> DispatchedWorker {
let model_owned = model.to_string();
let result_clone = result.clone();
@ -470,13 +634,14 @@ mod tests {
let (outputs, summaries, decision) = gather_workers_incremental(
&mut js,
&dispatched,
false, // has_tools
&[],
None,
GatherPolicy {
first_answer_grace: Duration::from_millis(50),
grace_mode: GraceMode::Answer,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;
@ -526,13 +691,14 @@ mod tests {
let (outputs, _summaries, decision) = gather_workers_incremental(
&mut js,
&dispatched,
true, // has_tools
&[],
None,
GatherPolicy {
first_answer_grace: Duration::from_millis(50),
grace_mode: GraceMode::Answer,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;
@ -577,13 +743,14 @@ mod tests {
let (outputs, _summaries, _decision) = gather_workers_incremental(
&mut js,
&dispatched,
true,
&[],
None,
GatherPolicy {
first_answer_grace: Duration::from_millis(50),
grace_mode: GraceMode::Disabled,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;
@ -620,13 +787,14 @@ mod tests {
let (_outputs, _summaries, decision) = gather_workers_incremental(
&mut js,
&dispatched,
true,
&["read".to_string()],
None,
GatherPolicy {
first_answer_grace: Duration::from_millis(50),
grace_mode: GraceMode::Tool,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;
@ -678,13 +846,14 @@ mod tests {
let (outputs, _summaries, _decision) = gather_workers_incremental(
&mut js,
&dispatched,
false, // has_tools
&[],
None,
GatherPolicy {
first_answer_grace: Duration::ZERO,
grace_mode: GraceMode::Answer,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;
@ -729,13 +898,14 @@ mod tests {
let (outputs, _summaries, _decision) = gather_workers_incremental(
&mut js,
&dispatched,
false,
&[],
None,
GatherPolicy {
first_answer_grace: Duration::from_millis(50),
grace_mode: GraceMode::Answer,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;
@ -785,13 +955,14 @@ mod tests {
let (outputs, _summaries, decision) = gather_workers_incremental(
&mut js,
&dispatched,
false,
&[],
None,
GatherPolicy {
first_answer_grace: Duration::from_millis(50),
grace_mode: GraceMode::Answer,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;
@ -849,13 +1020,14 @@ mod tests {
let (_outputs, _summaries, decision) = gather_workers_incremental(
&mut js,
&dispatched,
false,
&[],
None,
GatherPolicy {
first_answer_grace: Duration::from_millis(100),
grace_mode: GraceMode::Answer,
strong_patience: Duration::ZERO,
min_grace_answers: 1,
grace_finalizes: true,
},
)
.await;

View file

@ -41,8 +41,10 @@ pub mod context;
mod fanout;
pub mod normalize;
mod reducer;
mod refinement;
pub mod session;
mod tool_guard;
mod tool_turn;
pub mod worker;
pub use backend::{HttpBackend, ModelBackend, ModelEntry, SamplingParams, apply_enable_thinking};
@ -57,7 +59,7 @@ use serde_json::{Value, json};
use session::Session;
use std::time::{Duration, Instant};
use worker::WorkerRole;
pub use worker::{strip_thinking, truncate_chars};
pub use worker::{model_name_is_small_tier, strip_thinking, truncate_chars};
const SAME_TOOL_FORCE_ANSWER_THRESHOLD: usize = 3;
@ -104,6 +106,66 @@ pub struct GatewayConfig {
/// populates this from the caller's `reasoning_effort` / `enable_thinking`
/// / `reasoning.enabled` knobs so MoA users get a single switch.
pub enable_thinking: Option<bool>,
/// Actor priority order for tool turns and synthesis, as indices into
/// [`Self::models`], best actor first.
///
/// In the asymmetric (Hermes-style) tool path the *actor* is the model
/// that actually emits the tool call; references only advise. The actor
/// should be the best available tool-caller, which is a host-side judgement
/// combining gossiped `tool_use` capability, model size, and recent peer
/// health — signals the engine crate cannot see. The host computes the
/// ordering and passes it here.
///
/// Empty (the default) means "no host guidance": the engine falls back to
/// its name-derived size tier (big-tier first), preserving prior behaviour
/// for tests and any caller that doesn't populate it.
pub actor_candidates: Vec<usize>,
/// Whether tool turns gather advisory references before the actor acts.
pub reference_policy: ReferencePolicy,
/// Whether text turns run a cross-peer refinement round before synthesis.
pub refinement_policy: RefinementPolicy,
}
/// When a text turn should run a cross-peer refinement round (Together's
/// `layers`): every worker sees all round-1 drafts and rewrites its answer
/// before the reducer synthesizes.
///
/// Measured over 40 preregistered reasoning prompts x 3 draws
/// (`evals/moa-openrouter/RESULTS.md`): a pool of four 8B-class models beat its
/// own best member **only** with this round — single-round synthesis was
/// indistinguishable from the aggregator acting solo (26/75/19, p=0.37) while
/// refine-then-synthesize won (42/66/12, p=5.2e-05). With a 32B aggregator the
/// round adds much less over single-round synthesis (p=0.015), so it is not
/// worth a second fan-out there.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum RefinementPolicy {
/// Refine when the pool is all small-tier — the case where the round is
/// what makes the collective beat its best member.
#[default]
Auto,
/// Always run the refinement round.
Always,
/// Never refine: synthesize the round-1 drafts directly.
Never,
}
/// When the asymmetric tool path should gather advisory references.
///
/// Measured on 40 preregistered tool tasks x 10 draws (see
/// `evals/moa-openrouter/RESULTS.md`): with correct advisor packing, references
/// are worth +0.017 net uplift to a weak actor but -0.037 to a strong one. They
/// help where the actor has headroom and cost where it is already reliable, so
/// the useful default is to gate on actor strength rather than always or never.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ReferencePolicy {
/// Gather references only when the acting model looks weak enough to
/// benefit. Cheapest correct default.
#[default]
Auto,
/// Always gather references, regardless of actor strength.
Always,
/// Never gather references: the actor acts alone (Hermes' `enabled: false`).
Never,
}
// ─── Turn result ─────────────────────────────────────────────────────
@ -163,9 +225,9 @@ pub struct WorkerSummary {
}
#[derive(Debug, Clone)]
struct ForcedToolChoice {
name: String,
fallback_arguments: Value,
pub(crate) struct ForcedToolChoice {
pub(crate) name: String,
pub(crate) fallback_arguments: Value,
}
struct DecisionResolution<'a> {
@ -241,9 +303,44 @@ async fn handle_query(
forced_tool: Option<&ForcedToolChoice>,
start: Instant,
) -> TurnResult {
// Tool-bearing turns take the asymmetric (Hermes-style) path: references
// advise tool-free, the best tool-caller acts. Tool authority tracks
// capability, not a majority vote — see `tool_turn`. Text-only turns keep
// the symmetric fan-out + synthesis-on-divergence path below.
if has_tools || forced_tool.is_some() {
return tool_turn::handle_tool_query(config, session, allowed_tools, forced_tool, start)
.await;
}
let assignments = worker::assign_roles(&config.models);
let grace_mode = grace_mode_for_turn(session, has_tools);
let query_uses_tools = forced_tool.is_some() || matches!(grace_mode, GraceMode::Tool);
// If the caller gave us tools, the workers get tools. Full stop.
//
// This used to be `matches!(grace_mode, GraceMode::Tool)`, which routed
// the decision through `looks_like_tool_intent` — a keyword match against
// the user's text ("read ", "search ", "file", "directory", ...). Two
// separate concerns were riding on one flag: whether tools are *available*
// and whether the chat-only answer grace applies.
//
// Recorded agentic traces show how badly that misfires
// (`evals/moa-openrouter/agentic.jsonl`). "The test suite is failing. Find
// out which test fails and why" matches no phrase, so every worker was
// dispatched without tool schemas — while the same prompt, given tools,
// produced 53 tool calls across 9 models. Same for "Is this project's test
// suite passing?" (32) and "Find every place MeshError::Timeout is
// constructed in this repo." (20). Five of ten recorded tool scenarios had
// tools silently withheld.
//
// Worse, this flag is also passed to the arbiter as its `has_tools`, so a
// pool that unanimously proposed a tool fell through to the answer path and
// leaked the proposal's payload text — an agent harness received the prose
// "calling search" instead of a `search` tool call.
//
// Tool availability is now the caller's declaration. `grace_mode` keeps
// using the heuristic, which is where a guess is actually appropriate: it
// only tunes how long we wait before shipping a partial answer.
let query_uses_tools = forced_tool.is_some() || has_tools;
let selected_tool_names = if let Some(tool) = forced_tool {
vec![tool.name.clone()]
} else if query_uses_tools {
@ -266,10 +363,34 @@ async fn handle_query(
let mut dispatched: Vec<fanout::DispatchedWorker> = Vec::with_capacity(assignments.len());
let enable_thinking = config.enable_thinking;
// Role tiers (Fast 256 tokens, Specialist 512, Strong 1024) encode a
// capability spread so the cheap worker can answer the grace path quickly.
// A homogeneous pool has no such spread, and when refinement is expected
// every draft is an *input* to the round — a 256-token draft is a ~1000-char
// stub that drags the refined answer down. Measured end-to-end, production's
// tiered budgets produced ~3.1k-char answers against a ~4.1k-char solo
// baseline; the study that showed the gain gave every peer the full budget.
// Full-budget drafts for every answer-turn worker. Role tiers (Fast 256 /
// Specialist 512 / Strong 1024) existed only so the cheap worker could
// answer the grace fast-path quickly with a short reply — but grace no
// longer finalizes answer turns (it collects, then synthesizes), so a
// truncated draft is now pure downside: it is an *input* to synthesis, and
// a 256-token stub drags the aggregated answer down. Measured: an all-small
// pool lost 5W/31L through the shipped path with role-tiered drafts
// (3399-char MoA vs 4064 solo) while the full-budget harness won 12W/2L on
// the same pool. A big pool tolerated tiering only because its aggregator
// is strong enough to rebuild a full answer from stubs. See
// `evals/moa-openrouter/RESULTS.md`.
let uniform_packing = grace_mode == GraceMode::Answer;
for assignment in &assignments {
let pack_role = if uniform_packing {
worker::WorkerRole::Generalist
} else {
assignment.role
};
let packed = context::pack_for_worker_selected(
session,
assignment.role,
pack_role,
query_uses_tools,
&selected_tool_names,
);
@ -300,20 +421,87 @@ async fn handle_query(
});
}
let (outputs, summaries, early_decision) = gather_workers_incremental(
let (mut outputs, mut summaries, early_decision) = gather_workers_incremental(
&mut join_set,
&dispatched,
query_uses_tools,
allowed_tools,
session.tools(),
fanout::GatherPolicy {
first_answer_grace: config.first_answer_grace,
grace_mode,
strong_patience: config.strong_patience,
// Refinement quality scales with how many perspectives it gets.
// MIN_DRAFTS (2) is the minimum for the round to *run*, not a good
// target to collect: on a 4-model pool it let grace stop gathering
// at 2 of 4, while the study that measured the gain refined over
// every draft. Wait for all but one, so a single straggler still
// cannot hold the turn and `worker_timeout` still bounds the wait.
// Synthesis quality scales with WIDTH — that is the whole small-pool
// finding (6x 8B beats its best member, 4x is marginal, 2x is null).
// Arming grace on the first answer let it stop collecting at ~2 of 6,
// synthesizing a committee too narrow to win. Wait for all but one
// draft on any multi-worker answer turn, so a single straggler still
// cannot hold the turn and `worker_timeout` remains the hard bound.
// Tool turns keep the fast path (first valid proposal wins).
min_grace_answers: if refinement::refinement_expected(config) {
config
.models
.len()
.saturating_sub(1)
.max(refinement::MIN_DRAFTS)
} else {
// 1, deliberately: `min_grace_answers` gates whether grace can
// ARM at all, so any higher value is a liveness hazard. Setting
// it to N-1 on an answer turn meant a 6-worker pool where two
// public-mesh peers never returned could never arm grace (only
// 4 answers ever arrive), so the turn rode `worker_timeout`
// instead — measured 61s on a public mesh against 3-11s for the
// released build, for a SHORTER answer.
//
// Width comes from the grace WINDOW (10s), not from a count
// gate: healthy peers all land inside it, and a dead peer costs
// 10s rather than 60s. This is also the configuration the
// capable-pool win was measured under (71W/8T/1L).
1
},
// Grace finalizes (ships one worker's answer and stops) ONLY on
// tool turns, where a fast validated tool call keeps agent loops
// snappy. On answer turns grace is a *collection deadline*: stop
// waiting for the slow tail, but still synthesize what arrived
// instead of shipping one worker's answer. Finalizing answer turns
// shipped a single (often role-truncated) answer and skipped
// synthesis — measured 80/80 early-exit at capable scale, turning a
// 61W/3L committee win into a 26W/40L loss. See
// `evals/moa-openrouter/RESULTS.md`.
grace_finalizes: grace_mode == GraceMode::Tool,
},
)
.await;
// Cross-peer refinement (Together's `layers`): each worker rewrites its
// answer after seeing the others'. Runs only for pool shapes where it
// measurably pays (`evals/moa-openrouter/RESULTS.md`), and is best-effort —
// on shortfall we keep the round-1 outputs.
//
// An `early_decision` here means the workers actually *agreed* — that is a
// real signal and the cheap path, so it still short-circuits. (The other
// early-exit source, the answer grace, is a timeout rather than a quality
// signal; when refinement is expected it is configured above to bound the
// gather without finalizing, so it no longer pre-empts this round.)
if early_decision.is_none()
&& refinement::should_refine(config, outputs.len())
&& let Some((refined, refine_summaries)) =
refinement::refine_round(config, session, &outputs).await
{
tracing::info!(
"moa: refinement round produced {} draft(s) from {}",
refined.len(),
outputs.len(),
);
outputs = refined;
summaries.extend(refine_summaries);
}
if outputs.is_empty() {
return TurnResult {
response_body: error_response("All MoA workers failed", MOA_ERR_ALL_WORKERS_FAILED),
@ -328,7 +516,33 @@ async fn handle_query(
// Capture whether we took the early-exit path BEFORE we resolve the
// decision: the arbiter never runs when early_decision is Some.
let took_early_exit = early_decision.is_some();
let decision = early_decision.unwrap_or_else(|| arbiter::arbitrate(&outputs, query_uses_tools));
let decision = early_decision.unwrap_or_else(|| arbiter::arbitrate(&outputs));
// Always synthesize a multi-worker answer turn — never ship one worker's
// text verbatim.
//
// `arbitrate` returns `Answer(payload)` when the drafts agree, which ships
// whichever single worker represented the cluster. That is the whole
// harness-vs-shipped gap: the eval rig always synthesizes (draft ->
// aggregate) and a 6x8B pool wins 12W/2L, while the shipped path shipped one
// 8B verbatim on agreement and lost (6W/19L on the same pool). A weak 8B
// answer alone loses to a full-budget solo; an aggregation of six beats it.
// The synthesizer sees every draft and produces the fuller, better answer —
// agreeing drafts are the best possible input to it, not a reason to skip.
//
// Applies to answer turns with >=2 successful workers. Tool turns keep
// their own routing (single best actor). A single surviving worker has
// nothing to synthesize, so it still ships directly.
let is_answer_turn = grace_mode == GraceMode::Answer;
let decision =
if is_answer_turn && outputs.len() >= 2 && matches!(decision, arbiter::Decision::Answer(_))
{
arbiter::Decision::NeedsReducer {
reason: format!("{} drafts to synthesize", outputs.len()),
}
} else {
decision
};
let (response_body, reducer_used, reducer_attempts) = resolve_decision(
config,
DecisionResolution {
@ -427,7 +641,10 @@ fn looks_like_tool_intent(text: &str) -> bool {
.any(|phrase| text.contains(phrase))
}
fn selected_tool_names_for_turn(session: &Session, allowed_tools: &[String]) -> Vec<String> {
pub(crate) fn selected_tool_names_for_turn(
session: &Session,
allowed_tools: &[String],
) -> Vec<String> {
let available = if allowed_tools.is_empty() {
session.tool_names()
} else {
@ -1129,7 +1346,7 @@ async fn resolve_decision(
// ─── Response builders ───────────────────────────────────────────────
fn best_answer(outputs: &[WorkerOutput]) -> String {
pub(crate) fn best_answer(outputs: &[WorkerOutput]) -> String {
outputs
.iter()
.filter(|o| {
@ -1146,7 +1363,7 @@ fn best_answer(outputs: &[WorkerOutput]) -> String {
.unwrap_or_default()
}
fn fallback_worker_response(outputs: &[WorkerOutput]) -> Value {
pub(crate) fn fallback_worker_response(outputs: &[WorkerOutput]) -> Value {
let answer = best_answer(outputs);
if answer.is_empty() {
error_response(
@ -1158,7 +1375,7 @@ fn fallback_worker_response(outputs: &[WorkerOutput]) -> Value {
}
}
fn tool_proposal_response(output: &WorkerOutput, has_tools: bool) -> Value {
pub(crate) fn tool_proposal_response(output: &WorkerOutput, has_tools: bool) -> Value {
if let (true, Some(name)) = (has_tools, output.tool_name.as_ref()) {
let args = output.tool_arguments.as_ref().unwrap_or(&Value::Null);
return tool_call_response(name, args);
@ -1194,7 +1411,7 @@ fn tool_proposal_response(output: &WorkerOutput, has_tools: bool) -> Value {
///
/// The ingress layer is responsible for choosing the HTTP status; this
/// body is the in-band signal.
fn error_response(message: &str, code: &str) -> Value {
pub(crate) fn error_response(message: &str, code: &str) -> Value {
json!({
"id": format!("chatcmpl-moa-{}", short_id()),
"object": "chat.completion",
@ -1240,7 +1457,7 @@ pub const MOA_ERR_ALL_REDUCERS_FAILED: &str = "all_reducers_failed";
/// MoA only received silence directives or uncertainty after reduction.
pub const MOA_ERR_NO_USABLE_ANSWER: &str = "no_usable_answer";
fn chat_response(content: &str) -> Value {
pub(crate) fn chat_response(content: &str) -> Value {
json!({
"id": format!("chatcmpl-moa-{}", short_id()),
"object": "chat.completion",
@ -1254,7 +1471,7 @@ fn chat_response(content: &str) -> Value {
})
}
fn tool_call_response(name: &str, arguments: &Value) -> Value {
pub(crate) fn tool_call_response(name: &str, arguments: &Value) -> Value {
// OpenAI tool-call `arguments` is a JSON-object *string*. Three input
// shapes have to collapse to a valid object string here:
//
@ -1316,6 +1533,7 @@ mod response_builder_tests {
model: model.to_string(),
role: WorkerRole::Fast,
elapsed_ms: 1,
truncated: false,
}
}
@ -1370,6 +1588,7 @@ mod response_builder_tests {
model: "reducer".to_string(),
role: WorkerRole::Reducer,
elapsed_ms: 1,
truncated: false,
}
}
@ -1574,6 +1793,9 @@ mod response_builder_tests {
first_answer_grace: Duration::from_millis(10),
strong_patience: Duration::ZERO,
enable_thinking: Some(false),
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
};
let forced_tool = ForcedToolChoice {
name: "lookup_probe_fact".to_string(),

View file

@ -31,6 +31,21 @@ pub struct WorkerOutput {
pub model: String,
pub role: WorkerRole,
pub elapsed_ms: u64,
/// The backend stopped this response at the token limit
/// (`finish_reason == "length"`) rather than letting the model finish.
///
/// Recorded traces show this is not rare: 15 of 140 responses from
/// open-weight models came back `length` (see
/// `evals/moa-openrouter/corpus.jsonl`). Two shapes matter:
///
/// * empty content — already surfaces as a worker error, and
/// * **partial text** — a half-finished sentence that previously
/// entered arbitration as a normal answer at the default 0.5
/// confidence, making it eligible to win the pick outright.
///
/// Truncated answers are excluded from consensus and never returned
/// verbatim; they are still handed to synthesis as partial material.
pub truncated: bool,
}
/// Normalize raw worker text into a structured output.
@ -210,6 +225,7 @@ fn try_json_parse(
model: model.to_string(),
role,
elapsed_ms,
truncated: false,
})
}
@ -331,6 +347,7 @@ fn try_kv_parse(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64) -> Op
model: model.to_string(),
role,
elapsed_ms,
truncated: false,
})
}
@ -349,6 +366,7 @@ fn heuristic_classify(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64)
model: model.to_string(),
role,
elapsed_ms,
truncated: false,
};
}
@ -363,6 +381,7 @@ fn heuristic_classify(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64)
model: model.to_string(),
role,
elapsed_ms,
truncated: false,
};
}
@ -376,6 +395,7 @@ fn heuristic_classify(raw: &str, model: &str, role: WorkerRole, elapsed_ms: u64)
model: model.to_string(),
role,
elapsed_ms,
truncated: false,
}
}

View file

@ -23,6 +23,30 @@ use std::time::Duration;
/// running a stale binary that 502s on tool calls) doesn't take down
/// the whole reducer step.
pub(crate) fn reducer_candidates(config: &GatewayConfig) -> Vec<(String, usize)> {
// Host-provided actor priority wins. In the asymmetric tool path the actor
// is the model that actually emits the tool call, so it must be the best
// available tool-caller — a judgement the host makes from gossiped
// `tool_use` capability, model size, and peer health, none of which this
// crate can see. `actor_candidates` are indices into `config.models`,
// best-first; we translate them to `(name, backend_index)` and skip any
// stale/out-of-range index defensively.
if !config.actor_candidates.is_empty() {
let ordered: Vec<(String, usize)> = config
.actor_candidates
.iter()
.filter_map(|&i| config.models.get(i))
.map(|m| (m.name.clone(), m.backend_index))
.collect();
if !ordered.is_empty() {
return ordered;
}
// Every provided index was stale — fall through to the size heuristic
// rather than return empty and fail the turn.
}
// No host guidance (or all indices stale): fall back to name-derived size
// tier, big-tier first. Preserves prior behaviour for tests and callers
// that don't populate `actor_candidates`.
let mut big = Vec::new();
let mut small = Vec::new();
for m in &config.models {
@ -127,7 +151,11 @@ pub(crate) async fn hedged_reducer_call(
SamplingParams::reducer().with_thinking(enable_thinking),
)
.await;
(name, result)
// The reducer's output *is* the final response, so there is no
// later arbitration step that could act on truncation. Keep the
// text; the 2048-token reducer budget is well clear of the
// worker budget where truncation actually bites.
(name, result.map(|reply| reply.text))
});
};

View file

@ -0,0 +1,296 @@
//! Cross-peer refinement round (Together's `layers`) for text turns.
//!
//! Round 1 workers answer independently. In the refinement round every worker
//! sees *all* round-1 drafts and rewrites its own answer, after which the
//! reducer synthesizes the refined set.
//!
//! Why it exists: measured over 40 preregistered reasoning prompts x 3 draws
//! (`evals/moa-openrouter/RESULTS.md`), a pool of four 8B-class models beat its
//! own best member only when the refinement round was present —
//! single-round synthesis alone was indistinguishable from the aggregator
//! acting solo (p=0.37), while refine-then-synthesize won (p=5.2e-05). For a
//! strong aggregator the extra round adds much less, so the round is gated.
//!
//! Mesh flavour: the round is best-effort. It refines with whichever drafts
//! arrived, bounds its own wait, and on any shortfall returns the round-1
//! outputs unchanged rather than failing the turn.
use crate::backend::{SamplingParams, call_backend};
use crate::context;
use crate::normalize::{self, WorkerOutput};
use crate::session::Session;
use crate::worker;
use crate::{GatewayConfig, RefinementPolicy, WorkerSummary};
use std::time::Instant;
/// Minimum round-1 drafts needed for refinement to be meaningful.
pub(crate) const MIN_DRAFTS: usize = 2;
/// Share of the worker budget the refinement round may spend.
///
/// Refinement sits between round 1 and the reducer, so an unbounded round would
/// let one turn pay three sequential worker budgets. Half a budget is enough
/// for a pool that answered round 1 promptly, and caps the worst case at ~2.5x
/// a plain turn instead of 3x.
const REFINEMENT_BUDGET_NUMERATOR: u32 = 1;
const REFINEMENT_BUDGET_DENOMINATOR: u32 = 2;
fn refinement_budget(worker_timeout: std::time::Duration) -> std::time::Duration {
worker_timeout / REFINEMENT_BUDGET_DENOMINATOR * REFINEMENT_BUDGET_NUMERATOR
}
/// Should this text turn run a refinement round?
///
/// `Auto` follows the evidence: refine when the pool is dominated by
/// small-tier models (where the round is what makes the collective beat its
/// best member) and skip it when a big-tier model is present to synthesize
/// directly, since there the extra round buys much less than it costs.
pub(crate) fn should_refine(config: &GatewayConfig, drafts: usize) -> bool {
drafts >= MIN_DRAFTS && refinement_expected(config)
}
/// Will this config refine, given enough drafts?
///
/// Depends only on policy and pool shape, so it can be answered *before*
/// dispatch — which the text path needs in order to decide whether the answer
/// grace may pre-empt the round.
pub(crate) fn refinement_expected(config: &GatewayConfig) -> bool {
match config.refinement_policy {
RefinementPolicy::Never => false,
RefinementPolicy::Always => config.models.len() >= MIN_DRAFTS,
RefinementPolicy::Auto => {
if config.models.len() < MIN_DRAFTS {
return false;
}
// The cross-peer refine round is an extra *serial* fan-out pass
// (draft -> synth -> refine -> synth). It only earns that cost in
// one measured case: a homogeneous pool at real scale, where the
// repeated same-model drafts are correlated enough that a round of
// cross-pollination helps (same-model 32B ×2: 48/2 with refine vs
// 35/10 without).
//
// It does NOT pay for small pools. The width sprint measured
// refine-vs-single-aggregation as null in every 8B cell (2/4/6,
// diverse and same); single aggregation alone is what wins there
// (6× diverse 8B, 12W/2L). And a diverse big pool gains ~nothing
// either (mid diverse 49/6 layered vs 47/4 single-round). So skip
// the round for any all-small pool and for diverse pools —
// matching Hermes' cheaper single-synth cadence where refine buys
// nothing. See `evals/moa-openrouter/RESULTS.md`.
let all_small = config
.models
.iter()
.all(|m| worker::model_name_is_small_tier(&m.name));
!all_small && worker::pool_is_homogeneous(&config.models)
}
}
}
/// Run one refinement round over `drafts`.
///
/// Returns the refined outputs plus a summary per refining worker. Any worker
/// that fails or times out simply doesn't contribute; if fewer than
/// [`MIN_DRAFTS`] refinements land we return `None` so the caller keeps the
/// round-1 outputs.
pub(crate) async fn refine_round(
config: &GatewayConfig,
session: &Session,
drafts: &[WorkerOutput],
) -> Option<(Vec<WorkerOutput>, Vec<WorkerSummary>)> {
let assignments = worker::assign_roles(&config.models);
let texts: Vec<String> = drafts.iter().map(|d| d.payload.clone()).collect();
let mut join_set = tokio::task::JoinSet::new();
for a in &assignments {
let packed = context::pack_for_refinement(session, &texts);
let model = a.model_name.clone();
let role = a.role;
let backend = config.backends[a.backend_index].clone();
let timeout = config.worker_timeout;
let thinking = config.enable_thinking;
join_set.spawn(async move {
let t0 = Instant::now();
let result = call_backend(
&*backend,
&model,
&packed.messages,
None, // text path: refinement never carries tools
packed.max_tokens,
timeout,
SamplingParams::worker().with_thinking(thinking),
)
.await;
(model, role, result, t0.elapsed().as_millis() as u64)
});
}
// Bounded well inside the worker budget. Refinement is an *optional*
// improvement inserted between round 1 and the reducer, so at full
// `worker_timeout` a slow pool could pay three sequential budgets for one
// turn — the worst outcome on exactly the high-latency meshes this feature
// targets. Give the round a fraction of the budget and fall back to the
// round-1 drafts if the pool can't refine in that time.
let deadline = tokio::time::sleep(refinement_budget(config.worker_timeout));
tokio::pin!(deadline);
let mut refined = Vec::new();
let mut summaries = Vec::new();
loop {
tokio::select! {
biased;
joined = join_set.join_next() => {
let Some(joined) = joined else { break };
match joined {
Ok((model, role, Ok(reply), elapsed)) => {
if reply.text.trim().is_empty() {
continue;
}
let mut out = normalize::normalize_worker_output(
&reply.text, &model, role, elapsed,
);
out.truncated = reply.truncated;
summaries.push(WorkerSummary {
model,
role,
succeeded: true,
elapsed_ms: elapsed,
output_kind: Some(out.kind),
confidence: Some(out.confidence),
});
refined.push(out);
}
Ok((model, role, Err(e), elapsed)) => {
tracing::warn!("moa: refinement worker {model} failed: {e}");
summaries.push(WorkerSummary {
model,
role,
succeeded: false,
elapsed_ms: elapsed,
output_kind: None,
confidence: None,
});
}
Err(e) => tracing::warn!("moa: refinement task cancelled: {e}"),
}
}
_ = &mut deadline => {
tracing::info!(
"moa: refinement deadline reached with {} refined draft(s)",
refined.len(),
);
break;
}
}
}
join_set.abort_all();
if refined.len() < MIN_DRAFTS {
tracing::info!(
"moa: refinement produced {} draft(s), keeping round-1 outputs",
refined.len()
);
return None;
}
Some((refined, summaries))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ModelEntry;
use std::time::Duration;
fn config(models: &[&str], policy: RefinementPolicy) -> GatewayConfig {
GatewayConfig {
backends: Vec::new(),
models: models
.iter()
.map(|n| ModelEntry {
name: (*n).to_string(),
backend_index: 0,
})
.collect(),
worker_timeout: Duration::from_secs(60),
hedge_delay: Duration::from_secs(5),
reducer_timeout: Duration::from_secs(60),
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking: Some(false),
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: policy,
}
}
/// An all-small pool wins by WIDTH + single aggregation, not the refine
/// round: the width sprint measured refine-vs-single-aggregation null in
/// every 8B cell (2/4/6, diverse and same). So Auto skips the extra serial
/// pass here — single aggregation over a wide pool is what wins.
#[test]
fn auto_skips_an_all_small_pool() {
let c = config(
&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B"],
RefinementPolicy::Auto,
);
assert!(!should_refine(&c, 3));
}
/// A *diverse* pool with a big-tier synthesizer gains ~nothing from the
/// extra round (measured 49/6 layered vs 47/4 single-round), so Auto skips
/// it to save the round-trip.
#[test]
fn auto_skips_for_a_diverse_big_tier_pool() {
let c = config(&["Qwen3-32B", "Qwen3-8B"], RefinementPolicy::Auto);
assert!(!should_refine(&c, 2));
}
/// A *homogeneous* big-tier pool (same model, incl. repeated instances)
/// produces correlated drafts, and the round is what pulls them apart —
/// same-model 32B ×2 wins 48/2 with refinement vs 35/10 without. Auto must
/// refine here even though the members are big-tier.
#[test]
fn auto_refines_a_homogeneous_big_tier_pool() {
let c = config(&["Qwen3-32B", "Qwen3-32B"], RefinementPolicy::Auto);
assert!(should_refine(&c, 2));
}
/// Repeated instances of one model (same alias) are homogeneous.
#[test]
fn auto_refines_repeated_instances_of_one_model() {
let c = config(
&["Qwen3-32B", "Qwen3-32B", "Qwen3-32B"],
RefinementPolicy::Auto,
);
assert!(should_refine(&c, 3));
}
#[test]
fn refinement_needs_at_least_two_drafts() {
let c = config(&["Qwen3-8B", "Llama-3.1-8B"], RefinementPolicy::Always);
assert!(!should_refine(&c, 1));
assert!(should_refine(&c, 2));
}
#[test]
fn explicit_policies_override_pool_shape() {
let never = config(&["Qwen3-8B", "Llama-3.1-8B"], RefinementPolicy::Never);
assert!(!should_refine(&never, 3));
let always = config(&["Qwen3-32B", "Qwen3-8B"], RefinementPolicy::Always);
assert!(should_refine(&always, 2));
}
#[test]
fn auto_is_the_default() {
assert_eq!(RefinementPolicy::default(), RefinementPolicy::Auto);
}
/// Refinement must not spend a full worker budget: it sits between round 1
/// and the reducer, so an unbounded round would make one turn pay three
/// sequential budgets on exactly the slow meshes this feature targets.
#[test]
fn refinement_budget_is_a_fraction_of_the_worker_timeout() {
let budget = refinement_budget(Duration::from_secs(60));
assert_eq!(budget, Duration::from_secs(30));
assert!(budget < Duration::from_secs(60));
}
}

View file

@ -76,6 +76,7 @@ mod tests {
model: "alpha".into(),
role: WorkerRole::Strong,
elapsed_ms: 0,
truncated: false,
}
}
@ -164,6 +165,7 @@ mod tests {
model: "beta".into(),
role: WorkerRole::Fast,
elapsed_ms: 0,
truncated: false,
};
enforce_tool_call_contract(&mut out, &["read_file".into()], None, "beta");
assert_eq!(out.kind, OutputKind::Answer);

View file

@ -0,0 +1,336 @@
//! Asymmetric tool turn: the best tool-caller (the "actor") acts with the real
//! tools; every other model advises tool-free. Tool authority tracks capability,
//! not a majority vote — which removes the majority-of-weakness failure class
//! (weak models outvoting the one strong tool-caller) by construction.
//!
//! Stays a stateless `/v1/chat/completions` turn: references are rebuilt from
//! the transcript each request, and the client still owns tool execution.
use crate::backend::{SamplingParams, call_backend};
use crate::context;
use crate::fanout::{DispatchedWorker, gather_references};
use crate::normalize::{self, WorkerOutput};
use crate::reducer::{self, hedged_reducer_call, reducer_candidates};
use crate::session::Session;
use crate::worker::{self, WorkerRole};
use crate::{
ForcedToolChoice, GatewayConfig, MOA_ERR_ALL_REDUCERS_FAILED, ReferencePolicy, TurnKind,
TurnResult, WorkerSummary, chat_response, enforce_tool_call_contract, error_response,
fallback_worker_response, selected_tool_names_for_turn, tool_call_response,
tool_proposal_response,
};
use serde_json::Value;
use std::time::Instant;
/// How much conversation prose advisors see. Enough for continuity on a
/// multi-turn session, short enough to stay cheap and cacheable.
const REFERENCE_HISTORY_MESSAGES: usize = 6;
/// Handle a fresh, tool-bearing query with the asymmetric actor design.
pub(crate) async fn handle_tool_query(
config: &GatewayConfig,
session: &Session,
allowed_tools: &[String],
forced_tool: Option<&ForcedToolChoice>,
start: Instant,
) -> TurnResult {
// Best tool-caller first (host `actor_candidates`, else name-derived tier).
let candidates = reducer_candidates(config);
let actor_top = candidates.first().map(|(name, _)| name.clone());
// Advisors are gathered only when they're likely to pay for themselves.
// Actor excluded from advisors so it doesn't pay a redundant advisory pass.
let (references, mut summaries) = if should_gather_references(config, actor_top.as_deref()) {
dispatch_and_gather_references(config, session, actor_top.as_deref()).await
} else {
tracing::debug!(
"moa: skipping advisory references (policy={:?}, actor={:?})",
config.reference_policy,
actor_top,
);
(Vec::new(), Vec::new())
};
let selected = selected_tool_names_for_turn(session, allowed_tools);
let (messages, tools) = context::pack_for_actor(session, &references, true, &selected);
let hedge = hedged_reducer_call(
&config.backends,
candidates.clone(),
messages,
tools,
config.reducer_timeout,
config.hedge_delay,
config.enable_thinking,
)
.await;
let fallback_name = actor_top.unwrap_or_default();
let (response_body, actor_name, actor_ok, attempts) = finalize_actor_output(
session,
allowed_tools,
forced_tool,
&references,
fallback_name,
hedge,
);
// Reducer role marks the acting pass, distinct from advisory summaries.
summaries.push(WorkerSummary {
model: actor_name,
role: WorkerRole::Reducer,
succeeded: actor_ok,
elapsed_ms: start.elapsed().as_millis() as u64,
output_kind: None,
confidence: None,
});
TurnResult {
response_body,
worker_summaries: summaries,
reducer_used: true,
reducer_attempts: attempts,
turn_kind: TurnKind::Fanout,
elapsed_ms: start.elapsed().as_millis() as u64,
}
}
/// Should this tool turn gather advisory references?
///
/// Under [`ReferencePolicy::Auto`] the answer tracks *actor headroom*. Measured
/// over 40 preregistered tool tasks x 10 draws with correct advisor packing
/// (`evals/moa-openrouter/RESULTS.md`):
///
/// * weak actor (qwen3-8b): +0.017 net uplift — references help
/// * strong actor (qwen3-32b): -0.037 net uplift — references cost
///
/// Per-stratum the split is sharper still: references gained where the actor
/// had headroom (search +10, execute +4) and lost where it was already perfect
/// (inspect -7). So we advise a small-tier actor and let a big-tier one act
/// alone.
///
/// Size tier is a coarse proxy for tool-calling strength; it is the same signal
/// the host already ranks actors by, and it needs no extra round trip. If a
/// pool has only one model there is nobody to advise, so we skip regardless.
fn should_gather_references(config: &GatewayConfig, actor: Option<&str>) -> bool {
if config.models.len() < 2 {
return false;
}
match config.reference_policy {
ReferencePolicy::Never => false,
ReferencePolicy::Always => true,
// Unknown actor: fall back to advising, which is the prior behaviour.
ReferencePolicy::Auto => actor.is_none_or(worker::model_name_is_small_tier),
}
}
/// Fan out every non-actor model as a tool-free advisor and collect their
/// advice within a bounded window.
async fn dispatch_and_gather_references(
config: &GatewayConfig,
session: &Session,
exclude: Option<&str>,
) -> (Vec<WorkerOutput>, Vec<WorkerSummary>) {
let assignments = worker::assign_roles(&config.models);
let mut join_set = tokio::task::JoinSet::new();
let mut dispatched: Vec<DispatchedWorker> = Vec::new();
let enable_thinking = config.enable_thinking;
for a in &assignments {
if Some(a.model_name.as_str()) == exclude {
continue; // the actor advises itself when it acts
}
// Advisor packing: conversation prose only, no agent system prompt, no
// tool transcript, no request for a tool call. Measured: the old
// worker packing cost -0.102 net uplift vs -0.037 for this one (same
// actor, same 40 tasks) — see evals/moa-openrouter/RESULTS.md.
let packed = context::pack_for_reference(session, REFERENCE_HISTORY_MESSAGES);
let model_name = a.model_name.clone();
let role = a.role;
let backend = config.backends[a.backend_index].clone();
let timeout = config.worker_timeout;
dispatched.push(DispatchedWorker {
model: model_name.clone(),
role,
});
join_set.spawn(async move {
let t0 = Instant::now();
let result = call_backend(
&*backend,
&model_name,
&packed.messages,
packed.tools.as_ref(),
packed.max_tokens,
timeout,
SamplingParams::worker().with_thinking(enable_thinking),
)
.await;
(model_name, role, result, t0.elapsed().as_millis() as u64)
});
}
if dispatched.is_empty() {
return (Vec::new(), Vec::new());
}
// Bounded wait: proceed at a majority of advisors so slow/absent peers on a
// mixed mesh can't hold up the actor.
let min_references = dispatched.len().div_ceil(2).max(1);
gather_references(
&mut join_set,
&dispatched,
config.worker_timeout,
min_references,
)
.await
}
/// Turn the actor's hedged result into a response body + accounting.
fn finalize_actor_output(
session: &Session,
allowed_tools: &[String],
forced_tool: Option<&ForcedToolChoice>,
references: &[WorkerOutput],
fallback_actor_name: String,
hedge: Result<reducer::HedgedReducerOk, reducer::HedgedReducerErr>,
) -> (Value, String, bool, u32) {
match hedge {
Ok(reducer::HedgedReducerOk {
winner,
text,
attempts,
}) => {
let mut acted =
normalize::normalize_worker_output(&text, &winner, WorkerRole::Reducer, 0);
enforce_tool_call_contract(&mut acted, allowed_tools, session.tools(), &winner);
(
actor_body(&acted, forced_tool, references),
winner,
true,
attempts,
)
}
Err(reducer::HedgedReducerErr { err, attempts }) => {
tracing::warn!("moa: all {attempts} actor candidate(s) failed: {err}");
let body = if let Some(t) = forced_tool {
// A forced tool call is honoured even if the actor died.
tool_call_response(&t.name, &t.fallback_arguments)
} else if !references.is_empty() {
// Degrade to the best advisory answer rather than fail outright.
fallback_worker_response(references)
} else {
error_response(
&format!("Actor failed (tried {attempts}): {err}"),
MOA_ERR_ALL_REDUCERS_FAILED,
)
};
(body, fallback_actor_name, false, attempts)
}
}
}
/// Map the actor's classified output to an OpenAI response body.
fn actor_body(
acted: &WorkerOutput,
forced_tool: Option<&ForcedToolChoice>,
references: &[WorkerOutput],
) -> Value {
match acted.kind {
// The whole point: the actor emits the executable tool call.
normalize::OutputKind::ToolProposal => tool_proposal_response(acted, true),
normalize::OutputKind::Uncertainty => match forced_tool {
Some(t) => tool_call_response(&t.name, &t.fallback_arguments),
None => fallback_worker_response(references),
},
// Actor chose to answer directly (tool available but not needed).
_ => match forced_tool {
Some(t) => tool_call_response(&t.name, &t.fallback_arguments),
None => chat_response(&acted.payload),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ModelEntry;
use std::time::Duration;
fn config_with(models: &[&str], policy: ReferencePolicy) -> GatewayConfig {
GatewayConfig {
backends: Vec::new(),
models: models
.iter()
.map(|n| ModelEntry {
name: (*n).to_string(),
backend_index: 0,
})
.collect(),
worker_timeout: Duration::from_secs(60),
hedge_delay: Duration::from_secs(5),
reducer_timeout: Duration::from_secs(60),
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking: Some(false),
actor_candidates: Vec::new(),
reference_policy: policy,
refinement_policy: Default::default(),
}
}
const POOL: &[&str] = &["Qwen3-32B", "Qwen3-8B", "Ministral-8B"];
/// A strong actor is measurably worse with advice (-0.037 net uplift), so
/// Auto must let it act alone.
#[test]
fn auto_skips_references_for_a_big_tier_actor() {
let config = config_with(POOL, ReferencePolicy::Auto);
assert!(!should_gather_references(&config, Some("Qwen3-32B")));
}
/// A weak actor has headroom advice can fill (+0.017), so Auto advises it.
#[test]
fn auto_gathers_references_for_a_small_tier_actor() {
let config = config_with(POOL, ReferencePolicy::Auto);
assert!(should_gather_references(&config, Some("Qwen3-8B")));
}
/// Unknown actor keeps the prior behaviour rather than silently degrading.
#[test]
fn auto_advises_when_the_actor_is_unknown() {
let config = config_with(POOL, ReferencePolicy::Auto);
assert!(should_gather_references(&config, None));
}
#[test]
fn explicit_policies_override_actor_strength() {
let never = config_with(POOL, ReferencePolicy::Never);
assert!(!should_gather_references(&never, Some("Qwen3-8B")));
let always = config_with(POOL, ReferencePolicy::Always);
assert!(should_gather_references(&always, Some("Qwen3-32B")));
}
/// Nobody left to advise once the actor is excluded.
#[test]
fn a_single_model_pool_never_gathers_references() {
for policy in [
ReferencePolicy::Auto,
ReferencePolicy::Always,
ReferencePolicy::Never,
] {
let config = config_with(&["Qwen3-8B"], policy);
assert!(
!should_gather_references(&config, Some("Qwen3-8B")),
"{policy:?} must not advise a one-model pool"
);
}
}
#[test]
fn auto_is_the_default_policy() {
assert_eq!(ReferencePolicy::default(), ReferencePolicy::Auto);
}
}

View file

@ -130,6 +130,56 @@ pub fn has_quality_gap<'a>(workers: impl IntoIterator<Item = (&'a str, WorkerRol
///
/// Mirrors `pick_model_classified`'s sizing heuristic in the main
/// router so MoA picks the same "strong" model as `auto` would.
/// Public size-tier classifier for out-of-crate callers (the host uses it to
/// break ties when ranking actor candidates by capability).
///
/// True when the model name advertises a single-digit billion-parameter count
/// (1B9B) — the "small tier". Multi-digit sizes (31B, 70B) and names that
/// encode no size (MiniMax-M2.5) are big-tier. Same heuristic MoA uses
/// internally for role assignment, exposed so the host doesn't re-implement it.
pub fn model_name_is_small_tier(name: &str) -> bool {
is_single_digit_b_name(name)
}
/// Canonical base of a model name, mirroring the host's dedup logic: lowercase,
/// drop an `@branch` segment (keeping any `:quant` tag), strip common
/// prefixes/suffixes, keep only alphanumerics. Two aliases of the same model
/// map to the same base.
pub fn canonical_base_name(name: &str) -> String {
let lower = name.to_lowercase();
let no_branch = match lower.find('@') {
Some(at) => {
let after = &lower[at + 1..];
let rest = after.find(':').map(|c| &after[c..]).unwrap_or("");
format!("{}{}", &lower[..at], rest)
}
None => lower,
};
no_branch
.replace("-gguf", "")
.replace("unsloth/", "")
.replace("meshllm/", "")
.chars()
.filter(char::is_ascii_alphanumeric)
.collect()
}
/// A pool is homogeneous when every member shares one canonical base — i.e. it
/// is the same model, possibly as repeated instances or quant variants.
///
/// Refinement is most valuable exactly here: identical/near-identical members
/// produce correlated drafts, and the cross-peer round is what pulls them apart
/// (measured: same-model 32B ×2 wins 48/2 with refinement vs 35/10 without,
/// while a diverse strong pool is ~unchanged). See
/// `evals/moa-openrouter/RESULTS.md`.
pub fn pool_is_homogeneous(models: &[crate::backend::ModelEntry]) -> bool {
let mut bases = models.iter().map(|m| canonical_base_name(&m.name));
match bases.next() {
Some(first) => bases.all(|b| b == first),
None => false,
}
}
pub(crate) fn is_single_digit_b_name(name: &str) -> bool {
let bytes = name.as_bytes();
for i in 0..bytes.len() {
@ -230,6 +280,45 @@ pub fn extract_thinking(text: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ModelEntry;
fn entries(names: &[&str]) -> Vec<ModelEntry> {
names
.iter()
.map(|n| ModelEntry {
name: (*n).to_string(),
backend_index: 0,
})
.collect()
}
#[test]
fn homogeneous_pool_detects_repeated_instances() {
assert!(pool_is_homogeneous(&entries(&["Qwen3-32B", "Qwen3-32B"])));
}
#[test]
fn homogeneous_pool_matches_aliases_of_one_model() {
// Same base once prefixes/-gguf/@branch are normalised away.
assert!(pool_is_homogeneous(&entries(&[
"Qwen3-8B",
"unsloth/Qwen3-8B",
"Qwen3-8B@main",
])));
}
#[test]
fn different_models_are_not_homogeneous() {
assert!(!pool_is_homogeneous(&entries(&[
"Qwen3-8B",
"Llama-3.1-8B"
])));
}
#[test]
fn empty_pool_is_not_homogeneous() {
assert!(!pool_is_homogeneous(&[]));
}
#[test]
fn truncate_chars_shorter_than_limit_is_passthrough() {

View file

@ -0,0 +1,174 @@
//! Diagnostic: why does an all-small pool win in the eval rig but not through
//! `handle_turn`? Dumps every request the shipped path sends (worker prompts +
//! reducer prompt), plus how many drafts actually reached synthesis, so the
//! difference can be *seen* rather than hypothesised.
//!
//! Not an assertion test — run with --nocapture and read the output.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use mesh_mixture_of_agents as moa;
use serde_json::{Value, json};
/// Records every request body it is handed, then returns a canned answer.
struct RecordingBackend {
name: String,
reply: String,
delay: Duration,
log: Arc<Mutex<Vec<(String, Value)>>>,
}
#[async_trait]
impl moa::ModelBackend for RecordingBackend {
async fn chat_completion(
&self,
model: &str,
messages: &[Value],
tools: Option<&Value>,
max_tokens: u32,
_timeout: Duration,
params: moa::SamplingParams,
) -> Result<Value, String> {
self.log.lock().unwrap().push((
self.name.clone(),
json!({
"model": model,
"max_tokens": max_tokens,
"temperature": params.temperature,
"top_p": params.top_p,
"enable_thinking": params.enable_thinking,
"has_tools": tools.is_some(),
"messages": messages,
}),
));
if !self.delay.is_zero() {
tokio::time::sleep(self.delay).await;
}
Ok(json!({
"choices": [{"message": {"content": self.reply}, "finish_reason": "stop"}]
}))
}
}
fn user_turn(content: &str) -> Value {
json!({
"model": "mesh",
"messages": [{"role": "user", "content": content}],
})
}
#[tokio::test(flavor = "multi_thread")]
async fn dump_all_small_pool_requests() {
let log: Arc<Mutex<Vec<(String, Value)>>> = Arc::new(Mutex::new(Vec::new()));
// Six same-tier 8B-class peers, distinct answers so synthesis has real
// material and nothing can be mistaken for consensus.
let names = [
"qwen3-8b",
"llama-3.1-8b",
"granite-4.1-8b",
"ministral-8b",
"qwen2.5-7b",
"qwen3.5-9b",
];
let mut backends: Vec<Arc<dyn moa::ModelBackend>> = Vec::new();
let mut models = Vec::new();
for (i, n) in names.iter().enumerate() {
models.push(moa::ModelEntry {
name: (*n).to_string(),
backend_index: i,
});
backends.push(Arc::new(RecordingBackend {
name: (*n).to_string(),
reply: format!("DRAFT-FROM-{n}: backpressure means slowing the producer."),
delay: Duration::ZERO,
log: log.clone(),
}));
}
let config = moa::GatewayConfig {
backends,
models,
worker_timeout: Duration::from_secs(90),
hedge_delay: Duration::from_secs(5),
reducer_timeout: Duration::from_secs(60),
first_answer_grace: Duration::from_secs(10),
strong_patience: Duration::from_secs(20),
enable_thinking: None,
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
};
let prompt = "Explain backpressure in distributed systems.";
let result = moa::handle_turn(&config, &user_turn(prompt)).await;
let entries = log.lock().unwrap().clone();
println!("\n================ SHIPPED handle_turn ================");
println!(
"turn_kind={:?} reducer_used={} reducer_attempts={} workers_dispatched={}",
result.turn_kind,
result.reducer_used,
result.reducer_attempts,
result.worker_summaries.len()
);
println!("total backend calls: {}", entries.len());
for (i, (who, body)) in entries.iter().enumerate() {
let msgs = body["messages"].as_array().cloned().unwrap_or_default();
let is_reducer = msgs.iter().any(|m| {
m["content"]
.as_str()
.map(|s| s.contains("Worker outputs") || s.contains("Synthesize"))
.unwrap_or(false)
});
println!(
"\n--- call {i}: {who} {} | max_tokens={} temp={} thinking={:?} ---",
if is_reducer { "[REDUCER]" } else { "[worker]" },
body["max_tokens"],
body["temperature"],
body["enable_thinking"],
);
for m in &msgs {
let role = m["role"].as_str().unwrap_or("?");
let content = m["content"].as_str().unwrap_or("");
println!(" [{role}] {content}");
}
}
// How many drafts actually reached synthesis?
let reducer_call = entries.iter().find(|(_, b)| {
b["messages"]
.as_array()
.map(|ms| {
ms.iter().any(|m| {
m["content"]
.as_str()
.map(|s| s.contains("Worker outputs"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
match reducer_call {
Some((_, b)) => {
let sys = b["messages"][0]["content"].as_str().unwrap_or("");
let n = sys.matches("[Response").count();
println!(
"\n>>> DRAFTS THAT REACHED SYNTHESIS: {n} of {}",
names.len()
);
}
None => println!("\n>>> NO REDUCER CALL — synthesis never ran"),
}
println!("\n================ RIG (what won 12W/2L) ================");
println!("peer draft call : messages=[user: <prompt>] max_tokens=1024 temp=0.8");
println!(
"synthesis call : messages=[system: COMMITTEE_SYNTH_PROMPT + \"Candidate responses:\" \
+ [Response N] x6, user: <prompt>] max_tokens=1024 temp=0.3"
);
println!("(no MoA preamble on peers, no agent system prompt, no truncation)\n");
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
{
"_preregistration": "Authored before running the scaled study. Do not edit labels in response to results. 40 tasks, 4 strata x 10. This measures TOOL-SELECTION COHERENCE (did the actor pick a reasonable tool + arguments), not end-to-end agent task success. accept_tools is a SET: pass iff the emitted tool is in the set (empty set = pass iff NO tool call). arg_must_contain / arg_must_not_contain apply to the chosen tool's argument JSON when present.",
"tools": ["list_dir", "read_file", "search", "run_command"],
"tasks": [
{"id": "inspect_src", "category": "inspect", "prompt": "I'm new to this Rust project. Start by looking at what's in the src directory.", "accept_tools": ["list_dir"], "arg_must_contain": "src"},
{"id": "inspect_read_cargo", "category": "inspect", "prompt": "Read the project's Cargo.toml so we can see its dependencies.", "accept_tools": ["read_file"], "arg_must_contain": "Cargo.toml"},
{"id": "inspect_tests_dir", "category": "inspect", "prompt": "What test files are in the tests directory?", "accept_tools": ["list_dir"], "arg_must_contain": "test"},
{"id": "inspect_readme", "category": "inspect", "prompt": "Open the README so I can see the project overview.", "accept_tools": ["read_file"], "arg_must_contain": "README"},
{"id": "inspect_network_mod", "category": "inspect", "prompt": "List what's inside the src/network directory.", "accept_tools": ["list_dir"], "arg_must_contain": "network"},
{"id": "inspect_main_entry", "category": "inspect", "prompt": "Read the main entry point file, src/main.rs.", "accept_tools": ["read_file"], "arg_must_contain": "main.rs"},
{"id": "inspect_toplevel", "category": "inspect", "prompt": "Show me the top-level files and folders in this repository.", "accept_tools": ["list_dir"], "arg_must_contain": null},
{"id": "inspect_workflows", "category": "inspect", "prompt": "What GitHub Actions workflows exist? Look in .github/workflows.", "accept_tools": ["list_dir"], "arg_must_contain": "workflows"},
{"id": "inspect_read_config", "category": "inspect", "prompt": "Read the config file at src/config.rs.", "accept_tools": ["read_file"], "arg_must_contain": "config"},
{"id": "inspect_docs", "category": "inspect", "prompt": "What documents are in the docs folder?", "accept_tools": ["list_dir"], "arg_must_contain": "docs"},
{"id": "search_error_ctor", "category": "search", "prompt": "Find every place MeshError::Timeout is constructed in this repo.", "accept_tools": ["search"], "arg_must_contain": "Timeout"},
{"id": "search_todos", "category": "search", "prompt": "Find all TODO comments in the source.", "accept_tools": ["search"], "arg_must_contain": "TODO"},
{"id": "search_fn_def", "category": "search", "prompt": "Where is the function handle_turn defined?", "accept_tools": ["search"], "arg_must_contain": "handle_turn"},
{"id": "search_spawn", "category": "search", "prompt": "Find all the places that call tokio::spawn.", "accept_tools": ["search"], "arg_must_contain": "spawn"},
{"id": "search_literal", "category": "search", "prompt": "Search the codebase for the string actor_candidates.", "accept_tools": ["search"], "arg_must_contain": "actor_candidates"},
{"id": "search_tests_matching", "category": "search", "prompt": "Find all test functions whose name contains early_decision.", "accept_tools": ["search"], "arg_must_contain": "early_decision"},
{"id": "search_type_def", "category": "search", "prompt": "Where is the enum CapabilityLevel defined?", "accept_tools": ["search"], "arg_must_contain": "CapabilityLevel"},
{"id": "search_unwrap", "category": "search", "prompt": "Find all uses of .unwrap() so we can audit them.", "accept_tools": ["search"], "arg_must_contain": "unwrap"},
{"id": "search_struct", "category": "search", "prompt": "Find where the struct GatewayConfig is declared.", "accept_tools": ["search"], "arg_must_contain": "GatewayConfig"},
{"id": "search_log_error", "category": "search", "prompt": "Find every place we emit an error-level log line.", "accept_tools": ["search"], "arg_must_contain": null},
{"id": "exec_run_tests", "category": "execute", "prompt": "Run the test suite for this project.", "accept_tools": ["run_command"], "arg_must_contain": "test"},
{"id": "exec_triage_tests", "category": "execute", "prompt": "The test suite is failing. Run the tests to find out which test fails and why.", "accept_tools": ["run_command"], "arg_must_contain": "test"},
{"id": "exec_build", "category": "execute", "prompt": "Check whether the project compiles right now.", "accept_tools": ["run_command"], "arg_must_contain": null},
{"id": "exec_clippy", "category": "execute", "prompt": "Run the linter (cargo clippy) and report any warnings.", "accept_tools": ["run_command"], "arg_must_contain": "clippy"},
{"id": "exec_git_status", "category": "execute", "prompt": "What's the current git status of the working tree?", "accept_tools": ["run_command"], "arg_must_contain": "git"},
{"id": "exec_bench", "category": "execute", "prompt": "Run the benchmark suite so we can see current throughput.", "accept_tools": ["run_command"], "arg_must_contain": null},
{"id": "exec_rust_version", "category": "execute", "prompt": "What version of the Rust toolchain is installed here?", "accept_tools": ["run_command"], "arg_must_contain": null},
{"id": "exec_fmt", "category": "execute", "prompt": "Format all the code with the standard formatter.", "accept_tools": ["run_command"], "arg_must_contain": "fmt"},
{"id": "exec_named_test", "category": "execute", "prompt": "Run just the test named early_decision_single_survivor.", "accept_tools": ["run_command"], "arg_must_contain": null},
{"id": "exec_git_log", "category": "execute", "prompt": "Show the last few git commits.", "accept_tools": ["run_command"], "arg_must_contain": "git"},
{"id": "notool_question_op", "category": "no_tool", "prompt": "Answer from your own knowledge, do not look at any files: what does the Rust ? operator do?", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_vec_vecdeque", "category": "no_tool", "prompt": "From your own knowledge only: what's the difference between Vec and VecDeque in Rust?", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_merkle", "category": "no_tool", "prompt": "Explain what a Merkle tree is, in two sentences. Do not use any tools.", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_async", "category": "no_tool", "prompt": "Explain async/await in Rust conceptually. Answer directly without tools.", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_capital", "category": "no_tool", "prompt": "What is the capital of Australia? Just answer.", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_mutex", "category": "no_tool", "prompt": "Explain what a mutex is and why you'd use one. No tools needed.", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_static", "category": "no_tool", "prompt": "From your own knowledge: what does the 'static lifetime mean in Rust?", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_actor_model", "category": "no_tool", "prompt": "Summarize the actor model of concurrency in a short paragraph. Do not look at files.", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_arithmetic", "category": "no_tool", "prompt": "What is 17 multiplied by 23? Just give the number.", "accept_tools": [], "arg_must_contain": null},
{"id": "notool_borrow", "category": "no_tool", "prompt": "Explain the Rust borrow checker in one paragraph, from your own knowledge.", "accept_tools": [], "arg_must_contain": null}
]
}

View file

@ -0,0 +1,48 @@
{
"_note": "Realistic agent-session REASONING/ANSWER turns (not tool selection) — the open-ended, quality-varying turns where a committee of diverse peers should help per Together's MoA. Some prompts embed simulated prior context (tool output / code) as an agent loop would. Judged for answer quality by an out-of-pool judge, not label-match. 40 prompts, 4 strata x 10. Preregistered: authored before the scaled run.",
"tasks": [
{"id": "reason_root_cause", "category": "reason_over_output", "prompt": "A test failed with this output:\n\n```\nthread 'mesh::gossip::tests::peer_merge' panicked at crates/mesh-llm-host-runtime/src/mesh/gossip.rs:412:\nassertion `left == right` failed\n left: 3\n right: 2\n```\nThe test merges two peer sets and asserts the count. What are the most likely root causes, and what would you check first?"},
{"id": "reason_diff_review", "category": "reason_over_output", "prompt": "Review this change for correctness and risks:\n\n```rust\npub fn dedup_peers(mut peers: Vec<Peer>) -> Vec<Peer> {\n peers.sort_by_key(|p| p.id);\n peers.dedup_by_key(|p| p.id);\n peers\n}\n```\nThe caller expects the most recently-seen peer to win on duplicate ids. Is that guaranteed here?"},
{"id": "reason_log_triage", "category": "reason_over_output", "prompt": "A node logs this repeatedly then stops serving:\n\n```\nWARN moa: reducer hedge -> qwen3-32b\nWARN moa: all 3 reducer candidates failed: HTTP 504\nWARN target unhealthy, cooldown 30s\n```\nWhat is most likely happening across the mesh, and what's the first thing to rule out?"},
{"id": "reason_perf", "category": "reason_over_output", "prompt": "Inference throughput dropped from 40 tok/s to 12 tok/s after enabling a second model on the same GPU. No OOM. What are the likely causes in priority order?"},
{"id": "reason_data", "category": "reason_over_output", "prompt": "A/B on two routing strategies:\n\n```\nstrategy A: p50=180ms p99=2100ms error=0.4%\nstrategy B: p50=150ms p99=5200ms error=0.2%\n```\nWhich would you ship for an interactive agent, and what would change your mind?"},
{"id": "reason_flaky", "category": "reason_over_output", "prompt": "A test passes locally 20/20 times but fails ~15% of the time in CI. The test spawns two async tasks and asserts on a shared counter. What classes of bug produce exactly this signature, and how would you confirm which one it is?"},
{"id": "reason_memory", "category": "reason_over_output", "prompt": "RSS grows steadily from 400MB to 6GB over 12 hours of serving, then the process is OOM-killed. Heap profiler shows no single dominant allocation site. What explanations fit this shape, and what would you measure next?"},
{"id": "reason_regression", "category": "reason_over_output", "prompt": "After a dependency bump, one integration test now fails with `connection reset by peer` but only on Linux CI, never on macOS. Walk through how you'd narrow this down without bisecting the whole dependency tree."},
{"id": "reason_metric_conflict", "category": "reason_over_output", "prompt": "Dashboards show request success rate at 99.8%, but users report frequent failures. Both measurements are probably correct. Explain how that can happen and what to instrument to reconcile them."},
{"id": "reason_race_output", "category": "reason_over_output", "prompt": "This appears in logs during shutdown:\n\n```\nINFO worker 3 finished\nINFO shutdown complete\nERROR worker 3 write after close\n```\nThe messages are out of causal order. What does that tell you about the shutdown path?"},
{"id": "plan_fix", "category": "planning", "prompt": "We need to add per-request timeouts to an existing HTTP proxy that currently has none, without breaking streaming responses. Outline a concrete implementation plan with the main risks."},
{"id": "plan_migration", "category": "planning", "prompt": "We're adding a new optional field to a gossip protocol message that older nodes must still parse. Describe the safe rollout plan and what to verify for mixed-version meshes."},
{"id": "plan_debug", "category": "planning", "prompt": "Intermittent 5% of inference requests return empty responses under load, not reproducible locally. Lay out a debugging plan that would isolate the cause without disrupting production."},
{"id": "reason_ambiguous_req", "category": "planning", "prompt": "A user says 'make the mesh faster'. Before writing any code, what clarifying questions and measurements would you establish, and what are the top candidate levers?"},
{"id": "plan_test", "category": "planning", "prompt": "Design a test strategy to validate that a change to request routing doesn't break mixed-version meshes, given you have two machines with different hardware."},
{"id": "plan_rollback", "category": "planning", "prompt": "A release went out 40 minutes ago. Error rate is up 3x but not catastrophic, and the change touched both a schema migration and the request path. Plan the next 30 minutes."},
{"id": "plan_refactor", "category": "planning", "prompt": "A 3,000-line module owns request routing, retry policy, and metrics. We want to split it without a big-bang rewrite while other people keep shipping features in it. Plan the sequence."},
{"id": "plan_cache", "category": "planning", "prompt": "We want to add a response cache in front of an expensive model call. Plan it: what to key on, what must never be cached, how to invalidate, and how you'd prove it isn't serving stale answers."},
{"id": "plan_capacity", "category": "planning", "prompt": "Traffic is expected to 5x in six weeks. Current bottleneck is unknown. Plan the work to be ready, in priority order, assuming you can't just buy 5x the hardware."},
{"id": "plan_observability", "category": "planning", "prompt": "A distributed system has logs but no tracing, and debugging cross-node issues takes days. Plan an incremental path to useful tracing without instrumenting everything at once."},
{"id": "explain_concept", "category": "explain", "prompt": "Explain the difference between hedged requests and retries in a distributed system, and when each is the wrong choice."},
{"id": "explain_tradeoff", "category": "explain", "prompt": "Explain the tradeoffs between synthesizing multiple model answers versus routing to the single best model, for an interactive coding agent."},
{"id": "explain_code", "category": "explain", "prompt": "Explain what this does and any subtle behavior:\n\n```rust\nlet grace = tokio::select! {\n biased;\n r = js.join_next() => r,\n _ = tokio::time::sleep(remaining), if armed => return decide(&outputs),\n};\n```"},
{"id": "compare_approaches", "category": "explain", "prompt": "For deduplicating model names advertised by different peers (e.g. 'unsloth/Qwen3-8B-GGUF:Q4_K_M' vs 'Qwen3-8B-Q4_K_M'), compare normalization-by-canonical-name against embedding similarity. Which is more appropriate and why?"},
{"id": "explain_failure_mode", "category": "explain", "prompt": "In a mixture-of-agents system, explain the 'majority of weakness' failure mode and two distinct ways to prevent it."},
{"id": "explain_backpressure", "category": "explain", "prompt": "Explain backpressure to someone who has only used unbounded queues. Include what actually goes wrong without it, and why 'just add a bigger queue' is usually the wrong fix."},
{"id": "explain_idempotency", "category": "explain", "prompt": "Explain why retries require idempotency, what an idempotency key actually buys you, and a concrete case where retries silently corrupt state without one."},
{"id": "explain_quantization", "category": "explain", "prompt": "Explain what quantizing a model to 4 bits actually does, what you give up, and why two 4-bit quantizations of the same model can behave noticeably differently."},
{"id": "explain_consistency", "category": "explain", "prompt": "Explain eventual consistency using a gossip-based peer membership system as the example. Be concrete about what a client can and cannot rely on."},
{"id": "explain_tail_latency", "category": "explain", "prompt": "Explain why average latency is a misleading metric for interactive systems, and what you'd track instead. Include why tail latency gets worse as you add more parallel dependencies."},
{"id": "review_error_handling", "category": "code_review", "prompt": "Review this for production readiness:\n\n```rust\npub async fn fetch(url: &str) -> String {\n let r = reqwest::get(url).await.unwrap();\n r.text().await.unwrap()\n}\n```\nBe specific about what fails and what you'd change first."},
{"id": "review_lock_scope", "category": "code_review", "prompt": "Review for concurrency problems:\n\n```rust\nlet mut state = self.state.lock().await;\nlet peers = state.peers.clone();\nfor p in peers {\n let resp = self.client.get(&p.url).send().await?;\n state.record(p.id, resp.status());\n}\n```"},
{"id": "review_unbounded", "category": "code_review", "prompt": "Review this worker pattern:\n\n```rust\nloop {\n let msg = rx.recv().await.unwrap();\n tokio::spawn(async move { handle(msg).await });\n}\n```\nWhat breaks under load, and what's the minimal change that fixes it?"},
{"id": "review_error_swallow", "category": "code_review", "prompt": "Review:\n\n```rust\nfor peer in peers {\n if let Err(e) = sync(peer).await {\n tracing::debug!(\"sync failed: {e}\");\n }\n}\n```\nThe team reports that sync failures go unnoticed for days. Explain why and what you'd change."},
{"id": "review_timeout_math", "category": "code_review", "prompt": "A client has a 30s timeout. It calls service A (25s timeout), which calls service B (25s timeout), which retries B' up to 3 times with 10s timeouts. Review this timeout budget and say what actually happens under partial failure."},
{"id": "review_partial_write", "category": "code_review", "prompt": "Review:\n\n```rust\nlet mut f = File::create(path)?;\nf.write_all(&data)?;\n```\nThis is used to persist config that must survive a crash. What's wrong and what's the correct pattern?"},
{"id": "review_retry_storm", "category": "code_review", "prompt": "Review this retry policy: on any error, retry immediately up to 5 times, then return the error. All 200 clients share this policy against one service. Explain the failure mode and give a concrete better policy."},
{"id": "review_api_shape", "category": "code_review", "prompt": "Review this API:\n\n```rust\npub fn configure(a: bool, b: bool, c: bool, timeout: u64) -> Result<(), String>\n```\nCall sites look like `configure(true, false, true, 30)`. Suggest a better shape and justify it."},
{"id": "review_test_quality", "category": "code_review", "prompt": "Review this test:\n\n```rust\n#[test]\nfn test_routing() {\n let r = route(\"model-a\");\n assert!(r.is_ok());\n}\n```\nWhat does it actually guarantee, and what would you replace it with?"},
{"id": "review_cancellation", "category": "code_review", "prompt": "Review for cancellation-safety:\n\n```rust\ntokio::select! {\n _ = shutdown.recv() => return,\n result = write_batch(&mut buffer) => result?,\n}\n```\n`write_batch` drains `buffer` as it writes. What can go wrong on shutdown?"}
]
}

File diff suppressed because it is too large Load diff

View file

@ -94,6 +94,9 @@ fn three_failing_backends() -> moa::GatewayConfig {
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking: None,
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
}
}

View file

@ -102,6 +102,9 @@ fn build_config(
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking,
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
}
}

View file

@ -0,0 +1,204 @@
//! Pin the partial-survival robustness contract: when *some* dispatched
//! workers fail mid-turn but at least one answers, the turn must still complete
//! with the survivor(s) — never hang, never fail.
//!
//! `sim_all_workers_fail` covers total failure (clean structured error). This
//! covers the far more common mesh reality: nodes flicker, so a subset of the
//! committee dies mid-turn while the rest answer. MoA must degrade to the
//! survivors, not the error path.
use async_trait::async_trait;
use mesh_mixture_of_agents as moa;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
/// Answers with fixed text after a delay.
struct AnswerBackend {
text: String,
delay: Duration,
}
/// Always fails, simulating a peer that dropped mid-turn.
struct DeadBackend;
impl AnswerBackend {
fn new(text: impl Into<String>, delay: Duration) -> Arc<Self> {
Arc::new(Self {
text: text.into(),
delay,
})
}
}
#[async_trait]
impl moa::ModelBackend for AnswerBackend {
async fn chat_completion(
&self,
_model: &str,
_messages: &[Value],
_tools: Option<&Value>,
_max_tokens: u32,
_timeout: Duration,
_sampling: moa::SamplingParams,
) -> Result<Value, String> {
tokio::time::sleep(self.delay).await;
Ok(json!({"choices": [{"message": {"content": self.text}, "finish_reason": "stop"}]}))
}
}
#[async_trait]
impl moa::ModelBackend for DeadBackend {
async fn chat_completion(
&self,
_model: &str,
_messages: &[Value],
_tools: Option<&Value>,
_max_tokens: u32,
_timeout: Duration,
_sampling: moa::SamplingParams,
) -> Result<Value, String> {
Err("peer dropped mid-turn".into())
}
}
fn config(backends: Vec<Arc<dyn moa::ModelBackend>>, names: &[&str]) -> moa::GatewayConfig {
let models = names
.iter()
.enumerate()
.map(|(i, n)| moa::ModelEntry {
name: (*n).to_string(),
backend_index: i,
})
.collect();
moa::GatewayConfig {
backends,
models,
worker_timeout: Duration::from_secs(5),
hedge_delay: Duration::from_millis(50),
reducer_timeout: Duration::from_secs(5),
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking: Some(false),
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
}
}
fn request() -> Value {
json!({
"model": "mesh",
"messages": [{"role": "user", "content": "explain backpressure"}],
"max_tokens": 256,
})
}
/// Half the committee dies, the rest answer: the turn completes with the
/// survivors, and their failure is accounted rather than hanging.
#[tokio::test(flavor = "multi_thread")]
async fn turn_completes_when_some_workers_die() {
let backends: Vec<Arc<dyn moa::ModelBackend>> = vec![
AnswerBackend::new("queues fill up when the consumer is slow", Duration::ZERO),
Arc::new(DeadBackend),
AnswerBackend::new("it is a flow-control signal upstream", Duration::ZERO),
Arc::new(DeadBackend),
];
let cfg = config(
backends,
&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B", "Granite-4.1-8B"],
);
let result = moa::handle_turn(&cfg, &request()).await;
assert_ne!(
result.turn_kind,
moa::TurnKind::Failed,
"turn must complete with survivors when only some workers fail"
);
let body = serde_json::to_string(&result.response_body).unwrap();
assert!(
body.contains("choices"),
"a well-formed response must be returned, got: {body}"
);
// Every dispatched worker is accounted for, dead ones included — none is
// silently dropped. We deliberately do NOT assert exact succeeded/failed
// counts: early-exit consensus may abort a live worker once a usable answer
// is in hand, so the split between "succeeded" and "aborted" is timing
// dependent. The contract is that all four appear and the two dead ones are
// recorded as not-succeeded.
assert_eq!(
result.worker_summaries.len(),
4,
"all four dispatched workers must appear in summaries"
);
let failed = result
.worker_summaries
.iter()
.filter(|s| !s.succeeded)
.count();
assert!(
failed >= 2,
"the two dead workers must be recorded as failed, not dropped (got {failed})"
);
}
/// A single survivor is still enough: three of four die, the turn answers.
#[tokio::test(flavor = "multi_thread")]
async fn turn_completes_with_a_lone_survivor() {
let backends: Vec<Arc<dyn moa::ModelBackend>> = vec![
Arc::new(DeadBackend),
Arc::new(DeadBackend),
AnswerBackend::new("the one worker that lived", Duration::ZERO),
Arc::new(DeadBackend),
];
let cfg = config(
backends,
&["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B", "Granite-4.1-8B"],
);
let result = moa::handle_turn(&cfg, &request()).await;
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
let body = serde_json::to_string(&result.response_body).unwrap();
assert!(body.contains("choices"), "got: {body}");
}
/// A slow-dying worker must not hold the turn hostage: the survivor answers
/// immediately and the turn does not wait out a long tail on the failing one.
#[tokio::test(flavor = "multi_thread")]
async fn a_slow_failing_worker_does_not_stall_the_turn() {
struct SlowDead;
#[async_trait]
impl moa::ModelBackend for SlowDead {
async fn chat_completion(
&self,
_m: &str,
_msg: &[Value],
_t: Option<&Value>,
_mt: u32,
timeout: Duration,
_s: moa::SamplingParams,
) -> Result<Value, String> {
// Fail near the worker timeout, not instantly.
tokio::time::sleep(timeout.min(Duration::from_millis(400))).await;
Err("slow drop".into())
}
}
let backends: Vec<Arc<dyn moa::ModelBackend>> = vec![
AnswerBackend::new("fast survivor A", Duration::ZERO),
AnswerBackend::new("fast survivor B", Duration::ZERO),
Arc::new(SlowDead),
];
let cfg = config(backends, &["Qwen3-8B", "Llama-3.1-8B", "Ministral-8B"]);
let started = std::time::Instant::now();
let result = moa::handle_turn(&cfg, &request()).await;
let elapsed = started.elapsed();
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
assert!(
elapsed < Duration::from_secs(4),
"a slow-failing worker must not stall the turn (took {elapsed:?})"
);
}

View file

@ -0,0 +1,642 @@
//! Replay real recorded open-model traces through the MoA gateway.
//!
//! The fixture in `tests/fixtures/real_traces.json` was recorded from 9
//! open-weight models on OpenRouter (see `evals/moa-openrouter/`). Each case
//! holds every worker's **raw OpenAI-shaped response** — structured
//! `tool_calls`, `content`, and `finish_reason` — plus its observed latency.
//!
//! That matters because the two things this suite is here to protect are
//! exactly the two things you lose if you flatten worker responses to text:
//!
//! 1. `tool_calls` — Together's MoA aggregator reads `.content`, which is
//! empty when a model returns a tool call, so its synthesis step receives
//! a list of blank strings on every agentic turn.
//! 2. `finish_reason` — 39 of the recorded responses came back `"length"`,
//! and 24 of those carry *partial text*. Before truncation was plumbed
//! through, a half-finished sentence entered arbitration as a normal
//! answer at the default 0.5 confidence and could be returned verbatim.
//!
//! These replay through the real `HttpBackend`-shaped path: the fixture is
//! served as a JSON body, so `extract_text_from_response` does the parsing
//! under test rather than the test reconstructing its output.
//!
//! Not covered here: the HTTP-400 "Reasoning is mandatory" retry in
//! `HttpBackend`. That needs a live endpoint (verified against
//! minimax-m2.5, which failed 12/12 requests until the thinking flags were
//! dropped) and is not reachable through a `ModelBackend` fake.
use async_trait::async_trait;
use mesh_mixture_of_agents as moa;
use serde_json::{Value, json};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
/// Recorded latencies span 0.5s30s. Replaying them literally would make
/// this suite take minutes, so compress the timeline while preserving
/// *ordering* — which is what early exit, first-answer grace, and strong
/// patience actually key off.
const LATENCY_DIVISOR: u64 = 40;
// ─── Fixture model ───────────────────────────────────────────────────
#[derive(Debug, Clone, serde::Deserialize)]
struct RecordedWorker {
model: String,
#[allow(dead_code)]
tier: String,
elapsed_ms: u64,
finish_reason: Option<String>,
content: Option<String>,
tool_calls: Option<Value>,
error: Option<String>,
}
impl RecordedWorker {
/// Rebuild the exact OpenAI-shaped body this model returned.
fn response_body(&self) -> Value {
let mut message = json!({ "role": "assistant" });
let obj = message.as_object_mut().unwrap();
obj.insert(
"content".to_string(),
match &self.content {
Some(c) => json!(c),
None => Value::Null,
},
);
if let Some(tcs) = &self.tool_calls {
obj.insert("tool_calls".to_string(), tcs.clone());
}
json!({
"choices": [{
"index": 0,
"message": message,
"finish_reason": self.finish_reason.clone().unwrap_or_else(|| "stop".into()),
}]
})
}
fn truncated(&self) -> bool {
self.finish_reason.as_deref() == Some("length")
}
/// Text a truncated worker would have contributed, if any. Used to assert
/// it is never echoed back to the caller verbatim.
fn partial_text(&self) -> Option<&str> {
if !self.truncated() || self.tool_calls.is_some() {
return None;
}
self.content
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
}
}
#[derive(Debug, Clone, serde::Deserialize)]
struct RecordedCase {
id: String,
scenario: String,
step: u32,
has_tools: bool,
messages: Vec<Value>,
workers: Vec<RecordedWorker>,
}
#[derive(Debug, serde::Deserialize)]
struct Fixture {
cases: Vec<RecordedCase>,
}
fn load_fixture() -> Fixture {
let raw = include_str!("fixtures/real_traces.json");
serde_json::from_str(raw).expect("real_traces.json should parse")
}
// ─── Replay backend ──────────────────────────────────────────────────
/// Text every replayed reducer returns. Distinct from any recorded worker
/// payload so a test can tell synthesis apart from a verbatim relay.
const SYNTHESIZED: &str = "SYNTHESIZED-BY-REDUCER";
/// Serves one recorded worker response after its recorded (compressed)
/// latency. An `error` row fails the same way a dead peer would, so the
/// durability paths get exercised rather than mocked away.
///
/// A reducer call is a *different* call with different input, so replaying the
/// recorded worker body for it would be wrong — it would hand the reducer's
/// slot back the same truncated text the worker produced, and any assertion
/// about "what the caller received" would be measuring the fixture rather than
/// the gateway. Reducer calls are detected by their packed context and answered
/// with [`SYNTHESIZED`].
struct ReplayBackend {
worker: RecordedWorker,
calls: AtomicUsize,
}
impl ReplayBackend {
fn new(worker: RecordedWorker) -> Arc<Self> {
Arc::new(Self {
worker,
calls: AtomicUsize::new(0),
})
}
}
/// Does this call carry reducer context? `pack_for_reducer_selected` always
/// emits a `## Worker outputs` section; worker prompts never do.
fn is_reducer_call(messages: &[Value]) -> bool {
messages
.first()
.and_then(|m| m.get("content"))
.and_then(Value::as_str)
.is_some_and(|s| s.contains("## Worker outputs"))
}
#[async_trait]
impl moa::ModelBackend for ReplayBackend {
async fn chat_completion(
&self,
_model: &str,
messages: &[Value],
_tools: Option<&Value>,
_max_tokens: u32,
_timeout: Duration,
sampling: moa::SamplingParams,
) -> Result<Value, String> {
self.calls.fetch_add(1, Ordering::SeqCst);
// MoA policy: workers never think. If this ever regresses, the
// recorded traces stop being representative of what we replay.
assert_ne!(
sampling.enable_thinking,
Some(true),
"MoA must not ask a worker to enable thinking"
);
// The reducer is a fresh call, not a replay of this model's worker
// turn. Answer it with synthesized text.
if is_reducer_call(messages) {
return Ok(json!({
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": SYNTHESIZED},
"finish_reason": "stop",
}]
}));
}
tokio::time::sleep(Duration::from_millis(
(self.worker.elapsed_ms / LATENCY_DIVISOR).max(1),
))
.await;
match &self.worker.error {
Some(e) => Err(e.clone()),
None => Ok(self.worker.response_body()),
}
}
}
fn config_for(case: &RecordedCase) -> moa::GatewayConfig {
let mut backends: Vec<Arc<dyn moa::ModelBackend>> = Vec::new();
let mut models = Vec::new();
for w in &case.workers {
models.push(moa::ModelEntry {
name: w.model.clone(),
backend_index: backends.len(),
});
backends.push(ReplayBackend::new(w.clone()));
}
moa::GatewayConfig {
backends,
models,
worker_timeout: Duration::from_secs(10),
hedge_delay: Duration::from_millis(200),
reducer_timeout: Duration::from_secs(5),
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
// Mirrors `effective_enable_thinking_for_moa`, which is now
// unconditionally off.
enable_thinking: Some(false),
actor_candidates: Vec::new(),
// This suite exists to verify fan-out accounting (every dispatched
// worker attributed, aborted stragglers reconciled), so it pins the
// fan-out on. Production defaults to `Auto`, which skips advisors for
// a strong actor — covered by `tool_turn`'s gate tests.
reference_policy: moa::ReferencePolicy::Always,
refinement_policy: Default::default(),
}
}
fn request_body(case: &RecordedCase) -> Value {
let mut body = json!({
"model": "mesh",
"messages": case.messages,
"max_tokens": 512,
});
if case.has_tools {
// Same schemas the traces were recorded against.
body.as_object_mut().unwrap().insert(
"tools".to_string(),
json!([
tool_schema("list_dir", &[("path", "string")]),
tool_schema("read_file", &[("path", "string")]),
tool_schema("search", &[("pattern", "string"), ("path", "string")]),
tool_schema("run_command", &[("cmd", "string")]),
tool_schema(
"edit_file",
&[
("path", "string"),
("before", "string"),
("after", "string")
],
),
]),
);
}
body
}
fn tool_schema(name: &str, params: &[(&str, &str)]) -> Value {
let props: serde_json::Map<String, Value> = params
.iter()
.map(|(p, ty)| (p.to_string(), json!({"type": ty})))
.collect();
json!({
"type": "function",
"function": {
"name": name,
"description": format!("{name} tool"),
"parameters": {
"type": "object",
"properties": props,
"required": params.iter().map(|(p, _)| *p).collect::<Vec<_>>(),
}
}
})
}
fn response_text(body: &Value) -> String {
body.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
fn response_tool_calls(body: &Value) -> Vec<(String, String)> {
body.pointer("/choices/0/message/tool_calls")
.and_then(Value::as_array)
.map(|tcs| {
tcs.iter()
.filter_map(|tc| {
Some((
tc.pointer("/function/name")?.as_str()?.to_string(),
tc.pointer("/function/arguments")?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default()
}
// ─── Tests ───────────────────────────────────────────────────────────
/// The fixture must actually contain the shapes these tests rely on. If a
/// re-record loses them, fail loudly here rather than passing vacuously
/// everywhere else.
#[test]
fn fixture_covers_the_shapes_under_test() {
let fx = load_fixture();
assert!(fx.cases.len() >= 40, "expected a substantial corpus");
let truncated_partial = fx
.cases
.iter()
.flat_map(|c| &c.workers)
.filter(|w| w.partial_text().is_some())
.count();
assert!(
truncated_partial >= 10,
"fixture must contain truncated-with-partial-text responses \
(the shape that could previously be returned verbatim); found {truncated_partial}"
);
let with_tools = fx
.cases
.iter()
.filter(|c| c.workers.iter().any(|w| w.tool_calls.is_some()))
.count();
assert!(
with_tools >= 20,
"fixture must contain agentic tool-call cases; found {with_tools}"
);
let models: std::collections::BTreeSet<&str> = fx
.cases
.iter()
.flat_map(|c| &c.workers)
.map(|w| w.model.as_str())
.collect();
assert!(
models.len() >= 5,
"fixture should span a heterogeneous pool; found {models:?}"
);
}
/// Truncated worker text must never reach the caller verbatim.
///
/// A response cut off at the token limit is a half-finished sentence. It may
/// inform synthesis, but shipping it as the final answer is a bug — and it
/// was reachable before `finish_reason` was plumbed through, because such an
/// answer looked normal to the parser and carried the same default 0.5
/// confidence as everyone else.
#[tokio::test(flavor = "multi_thread")]
async fn truncated_worker_text_is_never_returned_verbatim() {
let fx = load_fixture();
let mut checked = 0usize;
for case in &fx.cases {
let partials: Vec<String> = case
.workers
.iter()
.filter_map(|w| w.partial_text().map(str::to_string))
.collect();
if partials.is_empty() {
continue;
}
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
let text = response_text(&result.response_body);
if text.is_empty() {
continue;
}
for partial in &partials {
assert_ne!(
text.trim(),
partial.as_str(),
"case `{}` returned a truncated worker payload verbatim",
case.id
);
checked += 1;
}
}
assert!(
checked > 0,
"no truncated payloads were exercised — fixture or filter is wrong"
);
}
/// Every recorded case must produce a well-formed response, or a clean
/// structured failure when every worker died. No panics, no empty 200s.
#[tokio::test(flavor = "multi_thread")]
async fn every_recorded_case_produces_a_wellformed_turn() {
let fx = load_fixture();
for case in &fx.cases {
let live = case.workers.iter().filter(|w| w.error.is_none()).count();
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
let body = &result.response_body;
// Worker accounting: every dispatched worker is attributed, even
// when early-exit consensus aborted the stragglers.
if result.turn_kind != moa::TurnKind::ToolResult {
assert_eq!(
result.worker_summaries.len(),
case.workers.len(),
"case `{}`: every dispatched worker must appear in worker_summaries",
case.id
);
}
if live == 0 {
assert_eq!(
result.turn_kind,
moa::TurnKind::Failed,
"case `{}`: all-dead pool must fail cleanly",
case.id
);
continue;
}
let text = response_text(body);
let tools = response_tool_calls(body);
assert!(
!text.trim().is_empty() || !tools.is_empty(),
"case `{}` ({:?}) produced neither text nor a tool call: {body}",
case.id,
result.turn_kind
);
// Any tool call we emit must carry a parseable JSON object for
// `arguments` — agent harnesses reject anything else.
for (name, args) in &tools {
let parsed: Value = serde_json::from_str(args).unwrap_or_else(|e| {
panic!(
"case `{}`: tool `{name}` args not JSON ({e}): {args:?}",
case.id
)
});
assert!(
parsed.is_object(),
"case `{}`: tool `{name}` arguments must be a JSON object, got {parsed}",
case.id
);
}
}
}
/// When workers agree on a tool *name* but not its *arguments*, the winning
/// arguments must be the ones the majority actually proposed.
///
/// This is the `explore_error_handling` step-0 shape: all 9 models call
/// `list_dir`, 8 with `{"path": "src"}` and `mistral-small-3.2-24b` with
/// `{"path": "rust_project/src"}` — a directory that does not exist. Every
/// OpenAI-shape tool call is normalized to a fixed 0.9 confidence, so the
/// old confidence-only tiebreak had no way to prefer the majority.
#[tokio::test(flavor = "multi_thread")]
async fn majority_arguments_win_when_workers_agree_on_the_tool() {
let fx = load_fixture();
let cases: Vec<&RecordedCase> = fx
.cases
.iter()
.filter(|c| {
if c.step != 0 || !c.has_tools {
return false;
}
let live: Vec<&RecordedWorker> =
c.workers.iter().filter(|w| w.error.is_none()).collect();
if live.is_empty() || live.iter().any(|w| w.tool_calls.is_none()) {
return false; // need an all-tool turn
}
let names: std::collections::BTreeSet<String> = live
.iter()
.filter_map(|w| w.tool_calls.as_ref())
.flat_map(|tcs| tcs.as_array().cloned().unwrap_or_default())
.filter_map(|tc| {
tc.pointer("/function/name")
.and_then(Value::as_str)
.map(str::to_string)
})
.collect();
let args: std::collections::BTreeSet<String> = live
.iter()
.filter_map(|w| w.tool_calls.as_ref())
.flat_map(|tcs| tcs.as_array().cloned().unwrap_or_default())
.filter_map(|tc| {
tc.pointer("/function/arguments")
.and_then(Value::as_str)
.map(str::to_string)
})
.collect();
names.len() == 1 && args.len() > 1
})
.collect();
assert!(
!cases.is_empty(),
"fixture must contain a name-unanimous / args-divergent case"
);
for case in cases {
// Majority argument string among live tool proposals.
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
for w in case.workers.iter().filter(|w| w.error.is_none()) {
for tc in w
.tool_calls
.as_ref()
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
{
if let Some(a) = tc.pointer("/function/arguments").and_then(Value::as_str) {
*counts.entry(a.to_string()).or_default() += 1;
}
}
}
let (majority, majority_n) = counts
.iter()
.max_by_key(|(_, n)| **n)
.map(|(a, n)| (a.clone(), *n))
.unwrap();
// Only meaningful when there genuinely is a majority.
if majority_n < 2 {
continue;
}
let minority: Vec<&String> = counts
.keys()
.filter(|a| **a != majority && counts[*a] < majority_n)
.collect();
if minority.is_empty() {
continue;
}
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
let tools = response_tool_calls(&result.response_body);
// The reducer may legitimately rewrite the turn into prose; only
// assert when a tool call was emitted from worker proposals.
if tools.is_empty() || result.reducer_used {
continue;
}
let majority_val: Value = serde_json::from_str(&majority).unwrap_or(Value::Null);
for (name, args) in &tools {
let got: Value = serde_json::from_str(args).unwrap_or(Value::Null);
assert_eq!(
got, majority_val,
"case `{}`: tool `{name}` should use the majority arguments \
({majority_n} workers proposed {majority}), not a minority variant. \
Got {args}",
case.id
);
}
}
}
/// Agentic turns must keep producing structured tool calls.
///
/// This is the property Together's design cannot hold: its aggregator reads
/// `.content`, so on a turn where every worker returns a tool call it
/// synthesizes from empty strings.
#[tokio::test(flavor = "multi_thread")]
async fn unanimous_tool_turns_emit_a_structured_tool_call() {
let fx = load_fixture();
let mut asserted = 0usize;
for case in fx.cases.iter().filter(|c| c.step == 0 && c.has_tools) {
let live: Vec<&RecordedWorker> =
case.workers.iter().filter(|w| w.error.is_none()).collect();
if live.is_empty() || live.iter().any(|w| w.tool_calls.is_none()) {
continue;
}
let names: std::collections::BTreeSet<String> = live
.iter()
.filter_map(|w| w.tool_calls.as_ref())
.flat_map(|tcs| tcs.as_array().cloned().unwrap_or_default())
.filter_map(|tc| {
tc.pointer("/function/name")
.and_then(Value::as_str)
.map(str::to_string)
})
.collect();
if names.len() != 1 {
continue; // divergent tool choice may legitimately go to the reducer
}
let expected = names.into_iter().next().unwrap();
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
let tools = response_tool_calls(&result.response_body);
assert!(
!tools.is_empty(),
"case `{}`: {} workers unanimously proposed `{expected}`, but the turn \
returned no tool call (kind={:?}). Tool calls must survive fan-out.",
case.id,
live.len(),
result.turn_kind,
);
for (name, _) in &tools {
assert_eq!(
name, &expected,
"case `{}`: emitted tool `{name}` but every worker proposed `{expected}`",
case.id
);
}
asserted += 1;
}
assert!(
asserted > 0,
"no unanimous tool turns exercised — fixture or filter is wrong"
);
}
/// Recorded scenarios where no tool is warranted must not invent one.
#[tokio::test(flavor = "multi_thread")]
async fn conceptual_questions_do_not_invent_tool_calls() {
let fx = load_fixture();
for case in fx
.cases
.iter()
.filter(|c| c.scenario.contains("no_tool_needed"))
{
let result = moa::handle_turn(&config_for(case), &request_body(case)).await;
let tools = response_tool_calls(&result.response_body);
assert!(
tools.is_empty(),
"case `{}`: no worker proposed a tool, so the turn must not emit one; got {tools:?}",
case.id
);
assert!(
!response_text(&result.response_body).trim().is_empty(),
"case `{}`: expected a prose answer",
case.id
);
}
}

View file

@ -0,0 +1,473 @@
//! Pin the cross-peer refinement round's behaviour under mesh conditions.
//!
//! Refinement is what makes a pool of small models beat its best member
//! (`evals/moa-openrouter/RESULTS.md`: 42/66/12, p=5.2e-05 for an all-8B pool),
//! so it runs on exactly the hardware where peers are slowest and least
//! reliable. A second fan-out is a second chance to hang the turn, so the round
//! must be strictly best-effort.
//!
//! Contracts pinned here:
//!
//! 1. **It runs and its output is used** — on an all-small pool the refined
//! drafts, not the round-1 drafts, are what reach the reducer.
//! 2. **A hanging peer cannot cost the turn** — refinement is bounded by
//! `worker_timeout`; the turn completes with whoever answered.
//! 3. **Total failure degrades, never breaks** — if every refiner errors, the
//! round-1 outputs are used and the turn still answers.
//! 4. **Pool shape gates it** — a big-tier model present means `Auto` skips the
//! extra fan-out.
use async_trait::async_trait;
use mesh_mixture_of_agents as moa;
use serde_json::{Value, json};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
/// Marker text so we can prove which round's text reached the reducer.
const ROUND1: &str = "ROUND1-DRAFT";
const REFINED: &str = "REFINED-DRAFT";
/// What a peer does when asked to refine.
#[derive(Clone, Copy)]
enum RefineBehavior {
/// Return the refined marker after a delay.
Ok(Duration),
/// Hang past any deadline.
Hang,
/// Fail outright.
Fail,
}
/// A mesh peer: answers round 1, then behaves per `on_refine`.
///
/// Call kind is inferred from the system prompt, which is how the three phases
/// are distinguishable without touching engine internals.
struct MeshPeer {
/// Distinct per peer so round-1 answers do NOT cluster into consensus —
/// otherwise early-exit short-circuits the turn and synthesis (the only
/// path refinement feeds) never runs.
round1_text: String,
round1_delay: Duration,
on_refine: RefineBehavior,
refine_calls: Arc<AtomicUsize>,
}
impl MeshPeer {
fn new(
round1_text: &str,
round1_delay: Duration,
on_refine: RefineBehavior,
) -> (Arc<Self>, Arc<AtomicUsize>) {
let counter = Arc::new(AtomicUsize::new(0));
(
Arc::new(Self {
round1_text: format!("{ROUND1} {round1_text}"),
round1_delay,
on_refine,
refine_calls: counter.clone(),
}),
counter,
)
}
}
fn system_text(messages: &[Value]) -> String {
messages
.iter()
.find(|m| m.get("role").and_then(Value::as_str) == Some("system"))
.and_then(|m| m.get("content").and_then(Value::as_str))
.unwrap_or("")
.to_string()
}
fn reply(text: &str) -> Value {
json!({"choices": [{"message": {"content": text}, "finish_reason": "stop"}]})
}
#[async_trait]
impl moa::ModelBackend for MeshPeer {
async fn chat_completion(
&self,
_model: &str,
messages: &[Value],
_tools: Option<&Value>,
_max_tokens: u32,
_timeout: Duration,
_sampling: moa::SamplingParams,
) -> Result<Value, String> {
let sys = system_text(messages);
// Reducer synthesis: echo back whichever drafts it was given, so the
// test can assert which round's text made it through.
if sys.contains("## Worker outputs") {
let saw_refined = sys.contains(REFINED);
return Ok(reply(if saw_refined {
"FINAL-FROM-REFINED"
} else {
"FINAL-FROM-ROUND1"
}));
}
// Refinement round.
// Refinement round: peers are shown each other's drafts under a
// "Candidate responses:" header. Checked after the reducer branch
// above, which is identified by its "## Worker outputs" section.
if sys.contains("Candidate responses:") {
self.refine_calls.fetch_add(1, Ordering::SeqCst);
return match self.on_refine {
RefineBehavior::Ok(d) => {
tokio::time::sleep(d).await;
Ok(reply(REFINED))
}
RefineBehavior::Hang => {
tokio::time::sleep(Duration::from_secs(3600)).await;
Ok(reply(REFINED))
}
RefineBehavior::Fail => Err("peer unavailable".into()),
};
}
// Round 1.
tokio::time::sleep(self.round1_delay).await;
Ok(reply(&self.round1_text))
}
}
fn config_with_grace(
models: &[(&str, Arc<MeshPeer>)],
policy: moa::RefinementPolicy,
worker_timeout: Duration,
first_answer_grace: Duration,
) -> moa::GatewayConfig {
let mut cfg = config(models, policy, worker_timeout);
cfg.first_answer_grace = first_answer_grace;
cfg
}
fn config(
models: &[(&str, Arc<MeshPeer>)],
policy: moa::RefinementPolicy,
worker_timeout: Duration,
) -> moa::GatewayConfig {
let mut backends: Vec<Arc<dyn moa::ModelBackend>> = Vec::new();
let mut entries = Vec::new();
for (name, backend) in models {
entries.push(moa::ModelEntry {
name: (*name).to_string(),
backend_index: backends.len(),
});
backends.push(backend.clone());
}
moa::GatewayConfig {
backends,
models: entries,
worker_timeout,
hedge_delay: Duration::from_millis(50),
reducer_timeout: Duration::from_secs(5),
// Disable the cheap paths so every turn reaches synthesis, which is
// the only path refinement participates in.
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking: Some(false),
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: policy,
}
}
/// Distinct prompts per worker so round-1 answers disagree and the turn is
/// forced into synthesis rather than early-exit consensus.
fn request() -> Value {
json!({
"model": "mesh",
"messages": [{"role": "user", "content": "explain backpressure"}],
"max_tokens": 256,
})
}
#[tokio::test(flavor = "multi_thread")]
async fn refined_drafts_are_what_reach_the_reducer() {
let (a, ca) = MeshPeer::new(
"queues fill up",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (b, cb) = MeshPeer::new(
"latency grows unbounded",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (c, cc) = MeshPeer::new(
"memory exhausts eventually",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let cfg = config(
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
moa::RefinementPolicy::Always,
Duration::from_secs(5),
);
let result = moa::handle_turn(&cfg, &request()).await;
assert!(
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst) + cc.load(Ordering::SeqCst) >= 2,
"an all-small pool must run the refinement round"
);
// The contract is that the *refined* text is what reaches the client. It
// may arrive either via the reducer ("FINAL-FROM-REFINED") or directly,
// when the refined drafts agree and the arbiter takes consensus — both are
// correct; what must never happen is round-1 text being returned.
let body = serde_json::to_string(&result.response_body).unwrap();
assert!(
body.contains(REFINED) || body.contains("FINAL-FROM-REFINED"),
"refined drafts must reach the client, got: {body}"
);
assert!(
!body.contains(ROUND1),
"round-1 drafts must not survive a successful refinement round, got: {body}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_hanging_refiner_cannot_hold_the_turn() {
let (a, _) = MeshPeer::new(
"queues fill up",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (b, _) = MeshPeer::new(
"latency grows unbounded",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
// One peer never returns from refinement — the mesh reality this guards.
let (c, _) = MeshPeer::new(
"memory exhausts eventually",
Duration::ZERO,
RefineBehavior::Hang,
);
let worker_timeout = Duration::from_millis(400);
let cfg = config(
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
moa::RefinementPolicy::Always,
worker_timeout,
);
let started = std::time::Instant::now();
let result = moa::handle_turn(&cfg, &request()).await;
let elapsed = started.elapsed();
assert!(
elapsed < worker_timeout * 4,
"a hanging refiner must not extend the turn (took {elapsed:?})"
);
assert_ne!(
result.turn_kind,
moa::TurnKind::Failed,
"the turn must still answer with the refiners that did return"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn total_refinement_failure_falls_back_to_round_one() {
let (a, _) = MeshPeer::new("queues fill up", Duration::ZERO, RefineBehavior::Fail);
let (b, _) = MeshPeer::new(
"latency grows unbounded",
Duration::ZERO,
RefineBehavior::Fail,
);
let (c, _) = MeshPeer::new(
"memory exhausts eventually",
Duration::ZERO,
RefineBehavior::Fail,
);
let cfg = config(
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
moa::RefinementPolicy::Always,
Duration::from_secs(5),
);
let result = moa::handle_turn(&cfg, &request()).await;
assert_ne!(
result.turn_kind,
moa::TurnKind::Failed,
"every refiner failing must degrade to round-1, not fail the turn"
);
let body = serde_json::to_string(&result.response_body).unwrap();
assert!(
body.contains("FINAL-FROM-ROUND1"),
"round-1 drafts must be used when refinement produces nothing, got: {body}"
);
}
/// The production-settings check: refinement must still run when
/// `first_answer_grace` is the real 3s default rather than the ZERO used by the
/// other tests here.
///
/// Grace produces an `early_decision`, and refinement is skipped whenever one
/// exists — so if grace fired on a fast all-small pool, this feature would be
/// dead code on real traffic. Grace only arms once the window has *elapsed*, so
/// a pool that answers promptly reaches synthesis (and refinement) first; this
/// pins that ordering.
#[tokio::test(flavor = "multi_thread")]
async fn refinement_still_runs_under_production_grace() {
let (a, ca) = MeshPeer::new(
"queues fill up",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (b, cb) = MeshPeer::new(
"latency grows unbounded",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (c, cc) = MeshPeer::new(
"memory exhausts eventually",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let cfg = config_with_grace(
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
moa::RefinementPolicy::Always,
Duration::from_secs(5),
// The production default from `build_moa_config`.
Duration::from_secs(3),
);
let result = moa::handle_turn(&cfg, &request()).await;
assert!(
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst) + cc.load(Ordering::SeqCst) >= 2,
"refinement must still run at the production grace setting, or the \
feature is dead code on real traffic"
);
let body = serde_json::to_string(&result.response_body).unwrap();
assert!(
body.contains(REFINED) || body.contains("FINAL-FROM-REFINED"),
"refined drafts must reach the client under production grace, got: {body}"
);
}
/// Straggling peers must NOT cost the refinement round.
///
/// This is the mesh case: one peer answers instantly, the others lag. The
/// answer grace would normally ship the fast lone answer and skip refinement —
/// but on an all-small pool refinement is the only step that beats the best
/// member (26/75/19, p=0.37 without it vs 42/66/12 with). Since variable peer
/// latency is the norm on consumer hardware, letting grace win here would
/// silently disable the feature exactly where it matters, while still paying
/// for the fan-out.
///
/// So when refinement is expected, grace is disabled for the turn. Round 1 is
/// still bounded by `worker_timeout` and refinement by its own half-budget, so
/// this costs bounded latency, never an unbounded wait.
#[tokio::test(flavor = "multi_thread")]
async fn straggling_peers_do_not_cost_the_refinement_round() {
let grace = Duration::from_millis(150);
// First peer answers immediately; the others straggle well past the grace.
let (a, ca) = MeshPeer::new(
"queues fill up",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (b, cb) = MeshPeer::new(
"latency grows unbounded",
Duration::from_millis(600),
RefineBehavior::Ok(Duration::ZERO),
);
let (c, cc) = MeshPeer::new(
"memory exhausts eventually",
Duration::from_millis(600),
RefineBehavior::Ok(Duration::ZERO),
);
let cfg = config_with_grace(
&[("Qwen3-8B", a), ("Llama-3.1-8B", b), ("Ministral-8B", c)],
moa::RefinementPolicy::Always,
Duration::from_secs(5),
grace,
);
let result = moa::handle_turn(&cfg, &request()).await;
assert!(
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst) + cc.load(Ordering::SeqCst) >= 2,
"a fast peer answering first must not skip refinement on an all-small pool"
);
let body = serde_json::to_string(&result.response_body).unwrap();
assert!(
body.contains(REFINED) || body.contains("FINAL-FROM-REFINED"),
"refined drafts must reach the client despite stragglers, got: {body}"
);
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
}
/// The grace must still work normally when refinement is NOT in play — a
/// big-tier pool keeps its fast chat path.
#[tokio::test(flavor = "multi_thread")]
async fn grace_still_short_circuits_when_refinement_is_not_expected() {
let grace = Duration::from_millis(100);
let (a, ca) = MeshPeer::new(
"queues fill up",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (b, cb) = MeshPeer::new(
"latency grows unbounded",
Duration::from_secs(30), // would stall the turn if grace didn't fire
RefineBehavior::Ok(Duration::ZERO),
);
let cfg = config_with_grace(
// Big-tier present => Auto does not refine => grace stays enabled.
&[("Qwen3-32B", a), ("Qwen3-8B", b)],
moa::RefinementPolicy::Auto,
Duration::from_secs(60),
grace,
);
let started = std::time::Instant::now();
let result = moa::handle_turn(&cfg, &request()).await;
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(5),
"grace must still short-circuit a slow peer when refinement is off (took {elapsed:?})"
);
assert_eq!(
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst),
0,
"a big-tier pool must not refine"
);
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_big_tier_pool_skips_the_extra_fanout() {
let (a, ca) = MeshPeer::new(
"queues fill up",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let (b, cb) = MeshPeer::new(
"latency grows unbounded",
Duration::ZERO,
RefineBehavior::Ok(Duration::ZERO),
);
let cfg = config(
// A big-tier model is present, so Auto should not pay for a 2nd round.
&[("Qwen3-32B", a), ("Qwen3-8B", b)],
moa::RefinementPolicy::Auto,
Duration::from_secs(5),
);
let result = moa::handle_turn(&cfg, &request()).await;
assert_eq!(
ca.load(Ordering::SeqCst) + cb.load(Ordering::SeqCst),
0,
"Auto must skip refinement when a big-tier model can synthesize directly"
);
assert_ne!(result.turn_kind, moa::TurnKind::Failed);
}

View file

@ -112,6 +112,9 @@ fn mixed_pool(
first_answer_grace: Duration::ZERO,
strong_patience,
enable_thinking: None,
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
}
}
@ -142,15 +145,14 @@ async fn small_consensus_is_held_until_strong_lands() {
.map(|w| (&w.model, w.succeeded))
.collect::<Vec<_>>()
);
// The contract is "when the strong worker lands with a usable answer,
// it wins" — not merely "it was allowed to finish". The strong worker
// disagrees with the small-tier consensus ("Sydney"), so its answer
// ("Canberra") must be the one that ships.
// The strong worker's draft must reach synthesis rather than being
// pre-empted by small-tier consensus. Answer turns always synthesize now,
// so the contract is "the strong draft is an input the aggregator weighs",
// not "its text ships verbatim" — in production the aggregator is itself
// the big-tier model.
assert!(
response_text(&result).contains("Canberra"),
"strong worker's answer must win over small-tier consensus once it lands; \
got {:?}",
response_text(&result)
result.reducer_used,
"disagreeing drafts must be synthesized, not resolved by consensus alone"
);
}
@ -218,17 +220,24 @@ async fn same_tier_pool_keeps_early_exit() {
// tier analysis must disable the gate anyway.
strong_patience: Duration::from_secs(10),
enable_thinking: None,
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
};
let started = std::time::Instant::now();
let result = moa::handle_turn(&config, &user_turn("Capital of Japan? One word.")).await;
let elapsed = started.elapsed();
// Answer turns always synthesize now (agreeing drafts are the best input
// to synthesis, not a reason to ship one worker verbatim), so this is a
// Fanout turn rather than an early exit.
assert_eq!(
result.turn_kind,
moa::TurnKind::EarlyExit,
"same-tier consensus must keep the early-exit path"
moa::TurnKind::Fanout,
"answer turns synthesize instead of shipping one worker's text"
);
// ROBUSTNESS (unchanged contract): a slow worker must not stall the turn.
assert!(
elapsed < Duration::from_secs(5),
"same-tier pool must not wait for the slow worker; took {elapsed:?}"

View file

@ -116,6 +116,9 @@ fn config_with_three_recording_workers() -> (
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking: None,
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
};
(config, fast, mid, strong)
}

View file

@ -115,6 +115,9 @@ fn four_workers_two_fast_consensus() -> moa::GatewayConfig {
first_answer_grace: Duration::ZERO,
strong_patience: Duration::ZERO,
enable_thinking: None,
actor_candidates: Vec::new(),
reference_policy: Default::default(),
refinement_policy: Default::default(),
}
}
@ -136,11 +139,13 @@ async fn early_exit_summaries_account_for_aborted_workers() {
let result = moa::handle_turn(&config, &body).await;
// Sanity: this should be the early-exit path.
// Answer turns always synthesize now, so consensus among the fast workers
// still aborts the slow tail but the turn is Fanout, not EarlyExit. The
// contract under test is worker *accounting*, which is unchanged.
assert_eq!(
result.turn_kind,
moa::TurnKind::EarlyExit,
"two fast agreeing workers should produce TurnKind::EarlyExit; got {:?}",
moa::TurnKind::Fanout,
"answer turns synthesize after aborting the slow tail; got {:?}",
result.turn_kind
);

View file

@ -643,6 +643,43 @@ fn read_tensor_infos(
Ok(tensors)
}
/// Sum the element counts of every tensor in a GGUF file → total stored
/// parameter count. Reads only the header and tensor-info table (dimensions),
/// never tensor data.
///
/// This is the authoritative model size: the exact number of stored weights,
/// independent of the file name. Name parsing (`NNb` in the model id) is a
/// brittle fallback — aliases and fine-tunes need not encode a size, names can
/// carry unrelated digits, and an unparseable name must read as *unknown*, not
/// as a guessed tier. Returns `None` on any parse failure (→ unknown).
pub fn scan_gguf_total_parameters(path: &Path) -> Option<u64> {
let GgufHeader {
file: mut f,
n_tensors,
n_kv,
} = open_gguf_header(path)?;
skip_all_kv_pairs(&mut f, n_kv)?;
let mut total: u64 = 0;
for _ in 0..n_tensors {
let _name = read_gguf_string(&mut f).ok()?;
let n_dims = read_u32(&mut f).ok()?;
if n_dims > MAX_GGUF_TENSOR_DIMS {
return None;
}
let mut elements: u64 = 1;
for _ in 0..n_dims {
let dim = read_u64(&mut f).ok()?;
elements = elements.checked_mul(dim)?;
}
let _ggml_type = read_u32(&mut f).ok()?;
let _offset = read_u64(&mut f).ok()?;
total = total.checked_add(elements)?;
}
Some(total)
}
/// Scan GGUF tensor names and return whether any tensor matches the predicate.
/// Reads only the header and tensor-info table, never tensor data.
pub fn scan_gguf_tensor_names_any(

View file

@ -98,21 +98,41 @@ callable in the mesh.
| Callable models | Workers fanned out | Roles assigned |
|---:|:---:|:---|
| 0 or 1 | — | MoA bails (503 to client; needs ≥2) |
| 0 or 1 | — | degrades to serving that model directly (no 503) |
| 2 | 2 | fast + strong |
| 3 | 3 | fast + specialist + strong |
| 4 | 4 | fast + specialist + specialist + strong |
| N | N | fast + (N-2) specialists + strong |
Models are tier-sorted before role assignment:
single-digit-B names ("Qwen3-8B", "llama-3-7b") form the small tier and
get `Fast`; everything else (multi-digit B, or names without an explicit
size) forms the big tier. Within the big tier, role assignment is
*first-encountered*, not deterministically by parameter count — we don't
parse parameter sizes from the alias string, only the broad small-vs-big
bucket. `Strong` and the reducer head both come from the big tier; if
deterministic largest-first ordering becomes important, a size-aware
sort within the big tier would go here.
On an **answer** turn every worker is packed at the full budget rather
than its role budget: the role tiers exist so the cheap worker can answer
the grace fast-path quickly, but a truncated draft is an *input* to
synthesis, so brevity there drags the aggregated answer down.
Tiering comes from **verified parameter counts**, not the alias string.
The serving node sums its GGUF tensor element counts and gossips the
result via `ServedModelMetadata.parameter_count_b`; the gateway reads
that and splits at `SMALL_TIER_MAX_B` (10B). A model with no verified
size ranks small, so an unparseable alias can never pose as a big-tier
worker and displace a real one. Name parsing is no longer used — it
mis-tiered real models (e.g. `gemma-4-E4B` stores 7.5B, not 4B).
Pool shaping, in order (all measured; see
`evals/moa-openrouter/RESULTS.md`):
- **Admission**: verified-small workers are dropped only when ≥2
verified-big remain. A lone big + smalls keeps the mix, because
dropping the smalls there would collapse the committee to a solo model.
- **All-small pools do not convene a committee.** Measured through the
shipped path, an 8B-class pool with an 8B reducer never beat its best
member and lost about a third of decided trials (2×8B 0W/37L; 6×8B
5W/23L, p=0.0009). Such a pool collapses to its strongest member and
the request degrades to serving that model directly. This is a
statement about a weak reducer synthesizing weak drafts — a capable
pool is the opposite (71W/8T/1L, p<0.0001) so as soon as a mesh gains
a big-tier model the pool is no longer all-small and MoA engages.
- **Committee cap**: 6 for all-small, 4 once a verified big is present.
Fan-out costs ~2N+1 calls per turn and quality is flat past ~4.
**The reducer is not a separate fan-out slot.** It's the same strong
model (or next-strongest if the primary is slow/broken), invoked
@ -275,8 +295,10 @@ The MoA intercept lives in `ingress.rs` (~line 234). When `model == "mesh"`:
2. `handle_turn()` runs the stateless MoA pipeline
3. Response is sent as SSE (streaming clients) or plain JSON
Activation: requires ≥2 distinct models available in the mesh. Returns 503
with explanation if fewer.
Activation: requires ≥2 committee-eligible models in the mesh. If fewer (a
single model, or an all-small pool collapsed to its best member), the request
degrades: the virtual `mesh` name is rewritten to a real served model and routed
normally. Only a node serving nothing at all returns 503.
---
@ -388,10 +410,10 @@ client, no recursive hook loops.
|----------|-------------------|
| 2+ workers agree quickly | Early-exit, faster than single-model |
| 1 worker much faster than others | Returns fast worker if confident |
| Remote peer timeout (15s worker / 15s reducer) | Degrades to local-only, adds latency |
| Remote peer timeout (60s worker / 60s reducer) | Grace ships what arrived at the 10s window; a dead peer costs 10s, not 60s |
| First reducer candidate slow / cold KV | Hedges to second candidate after 5s, races for first OK |
| First reducer candidate broken (502s) | Fast-fails to next candidate immediately, no hedge wait |
| Only 1 model available | Returns 503, does not activate MoA |
| Only 1 model available | Degrades to serving that model directly |
| All workers fail | Returns error response |
### What to watch for

View file

@ -0,0 +1,81 @@
# MoA trace recording (OpenRouter)
Records real fan-out responses from open-weight models and turns them into a
deterministic replay fixture for the MoA test suite.
The point: fake worker backends can't tell you whether the arbiter handles
*real* model behaviour — divergent tool arguments, truncated answers, malformed
tool-call text, 60x latency spreads. These scripts capture that behaviour once,
so tests can replay it forever without GPUs, network, or nondeterminism.
## Why the raw response shape is preserved
Both recorders store the **full OpenAI-shaped response** per worker —
structured `tool_calls`, `content`, `finish_reason`, usage — never flattened to
text. That matters because the two things lost by flattening are exactly the two
things the tests exist to protect:
- `tool_calls` — empty `content` on an agentic turn. Together's MoA aggregator
reads `.content`, so its synthesis step receives a list of blank strings on
every tool turn.
- `finish_reason``"length"` means the backend cut the response off. Partial
text parses as a normal answer, so without this field a half-finished
sentence can be returned verbatim.
## Scripts
| Script | Purpose |
|---|---|
| `orclient.py` | Minimal stdlib-only OpenRouter client (no `requests`). Owns the worker pool and tier mapping. |
| `probe_tools.py` | One-off probe: fan out with tools, then aggregate Together-style to show what their design does with tool calls. Writes `fanout.jsonl`. |
| `record.py` | Single-shot fan-out over assorted prompts incl. MT-Bench. Writes `corpus.jsonl`. |
| `record_agentic.py` | Multi-step agentic loop: fan out → take consensus tool call → feed a canned tool result → fan out again. Writes `agentic.jsonl`. |
| `make_fixture.py` | Merges both corpora into `crates/mesh-mixture-of-agents/tests/fixtures/real_traces.json`. |
## Usage
```bash
export OPENROUTER_API_KEY=... # required
python3 record.py # -> corpus.jsonl
python3 record_agentic.py # -> agentic.jsonl
python3 make_fixture.py # -> tests/fixtures/real_traces.json
```
Stdlib only, no install step. A full re-record is a few hundred calls against
cheap open models — on the order of a couple of dollars.
## Worker pool
Nine open-weight tool-capable models, chosen so tiers line up with how
`mesh-llm` classifies names (single-digit-B ⇒ small tier):
- small: `qwen3-8b`, `qwen3.5-9b`, `ministral-8b`, `ministral-3b`
- big: `qwen3-14b`, `qwen3-32b`, `qwen3-30b-a3b`, `minimax-m2.5`,
`mistral-small-3.2-24b`
Of 367 models on OpenRouter, 80 are open-weight *and* tool-capable, so the pool
can be widened without changing any code but the list in `orclient.py`.
## Two endpoint behaviours worth knowing
Both were found by recording, and both are now handled in `HttpBackend`:
1. **Thinking-disable flags are not universally accepted.** `minimax-m2.5`
returns `HTTP 400: Reasoning is mandatory for this endpoint` and failed
12/12 requests until the flags were dropped and the call retried.
2. **Reasoning models starve on a short budget.** With thinking on,
`qwen3-32b` spent 408 reasoning tokens against a 384-token cap and returned
`finish_reason=length` with `content: null` — 1620 characters of reasoning
and no answer. This is why MoA forces thinking off for every worker.
## Caveats
- Workers run at `temperature: 0.8`, so each row is **one draw** from a
stochastic process. `DRAWS = 2` per case; treat the corpus as a regression
baseline, not ground truth about live behaviour.
- Fixtures build a `GatewayConfig` directly, so they **bypass**
`build_moa_config` / `canonical_base_name`. Model-dedup bugs cannot be caught
here and need their own unit tests.
- `record_agentic.py` feeds canned tool results, not a real filesystem. It
exercises MoA's arbitration over real model output, not end-to-end agent
correctness.

View file

@ -0,0 +1,585 @@
# MoA evidence: what actually helps?
Results from the live OpenRouter studies in
`crates/mesh-mixture-of-agents/tests/eval_openrouter.rs`. All tool-selection
numbers use the preregistered 40-task fixture (`tests/fixtures/ablation_tasks.json`,
4 strata × 10), 10 draws, paired hierarchical bootstrap
(`analyze_ablation.py`).
Method for every ablation: **one pinned actor, identical sampling / token
budget / prompt scaffold across arms — only the references vary.**
- **A** actor alone
- **B** actor + real references
- **C** actor + shuffled references (advice generated for a *different* task)
Primary metric: net uplift = P(rescue) P(harm), equal-weight mean over tasks.
Arm C separates *advice content* from *extra tokens + a think-carefully prompt*.
## Headline: most of the "harm" was our packing, not references
| actor | reference packing | B pass | net uplift | 95% CI |
|---|---|---|---|---|
| strong (qwen3-32b) | original | 359/400 | 0.102 | [0.170, 0.045] |
| strong (qwen3-32b) | Hermes-style | 385/400 | 0.037 | [0.090, 0.003] |
| weak (qwen3-8b) | original | 365/400 | 0.013 | [0.090, +0.070] |
| weak (qwen3-8b) | Hermes-style | **377/400** | **+0.017** | [0.053, +0.100] |
Two monotonic effects:
1. **Fixing the packing helps in both actor conditions** (+0.065 strong,
+0.030 weak).
2. **References are worth more to a weaker actor** (+0.054 weak-vs-strong at
matched packing).
The only *statistically significant* cell in the matrix is the original-packing
strong-actor harm — i.e. the bug. After the fix, nothing is significant:
strong is marginal (upper bound 0.003), weak is a positive point estimate with
a CI spanning zero.
### What the packing bug was
Our references were packed with the agent's full system prompt, the tool-call
transcript, and a preamble instructing them to *"respond with your best answer
or tool call"* — while holding no tool schemas. So advisors (a) role-played the
actor instead of advising it, (b) anchored on the trajectory already taken
(destroying the error-independence aggregation depends on), and (c) emitted
tool-shaped prose that pulled the actor off its own better choice.
`context::pack_for_reference` follows Hermes: conversation user/assistant prose
only, no system prompt, no tool transcript, advisor framing, 600-token cap.
## Where references help: actor headroom
Weak actor + Hermes packing, per stratum:
| stratum | A alone | B real | C shuffled |
|---|---|---|---|
| inspect | 100/100 | 93/100 | 100/100 |
| search | 90/100 | **100/100** | 92/100 |
| execute | 80/100 | **84/100** | 62/100 |
| no_tool | 100/100 | 100/100 | 100/100 |
References **help exactly where the actor has headroom** (search +10, execute
+4) and **hurt where it was already perfect** (inspect 7). That is a gating
signal, not a global verdict.
Note `execute` arm C: irrelevant advice costs 18 points. Relevant advice on the
same stratum is +4. Advice content matters enormously; the risk is not "extra
tokens", it is *wrong-context* advice.
## Paired B vs C (advice content, both arms carry equal extra context)
| configuration | BC | 95% CI | |
|---|---|---|---|
| weak + Hermes | +0.057 | [0.018, +0.138] | spans 0 |
| weak + original | 0.010 | [0.080, +0.062] | spans 0 |
| strong + Hermes | 0.015 | [0.035, +0.000] | spans 0 |
| strong + original | 0.075 | [0.125, 0.033] | **significant** |
With correct packing the content effect flips sign with actor strength: real
advice beats shuffled for a weak actor, and is indistinguishable for a strong
one.
## Interventions that did NOT help tool selection
| intervention | result |
|---|---|
| pre-hoc *structured* proposals (diverse vs homogeneous vs solo) | flat, 3739/40 all arms |
| post-hoc deterministic correction (schema-validate + re-prompt) | +0.000 — never fired; the weak actor already emits structurally valid calls ~95% of the time |
| post-hoc semantic correction (different-family critic reviews the concrete call) | slightly negative — the revision still runs through the weak actor, so the capability gap persists |
Residual tool-selection failures are **semantic** (wrong tool for the job), not
structural. Validation and criticism cannot close a capability gap.
## Reasoning / answer turns (committee) — the one place MoA clearly wins
40 preregistered agent-session reasoning turns (4 strata × 10) × 3 draws = 120
trials. Fixed aggregator (`qwen3-32b`); peers `qwen3-14b`,
`mistral-small-24b`, `minimax-m2.5`. Judged pairwise by an **out-of-pool,
different-family** judge (`gpt-4o-mini`), **position-swapped** — a win counts
only if it survives both orderings, otherwise it is a tie.
- **A** aggregator alone
- **B** committee: aggregator synthesizes 3 peer drafts (single round)
- **C** layered: peers first refine seeing each other's drafts, then synthesize
(Together's `layers`)
| comparison | win / tie / loss | mean | 95% CI | sign test |
|---|---|---|---|---|
| **committee (B) vs solo (A)** | **86 / 16 / 18** | +0.567 | [+0.392, +0.733] | **p = 8.2e-12** |
| **layered (C) vs solo (A)** | **90 / 11 / 19** | +0.592 | [+0.408, +0.758] | **p = 3.1e-12** |
| layered (C) vs committee (B) | 57 / 30 / 33 | +0.200 | [+0.008, +0.392] | p = 0.015 |
Consistent across every stratum (B vs A): planning 25/2/3, explain 22/2/6,
code_review 20/6/4, reason_over_output 19/6/5.
### Length control
The verbosity confound runs the *opposite* way here, which strengthens the
result:
| | mean chars |
|---|---|
| A solo | 3136 |
| B committee | 2548 |
| C layered | 2196 |
The committee produces **shorter** answers than solo and still wins. Restricted
to the 61 trials where B was shorter than A, B wins **4014** (p = 5.4e-4). So
the preference is not length-driven.
**Note on the judge.** These numbers were collected with the pre-fix judge
wording that was later found to reward length (see "Withdrawn" below). This
section's result survives that finding, because the bias ran *against* the
winner here: the shorter arm won anyway, and won on the shorter-only subset.
The small-pool and e2e sections did not have that protection and were re-run.
### This reverses the pilot — and why
An earlier 15-prompt pilot found B vs A at 6/2/2 (p=0.29, "not significant")
and layered *losing* to single-round 2/2/6. Both conclusions were wrong,
because 20 of 30 pilot trials were silently dropped: `response_text` read only
`/message/content`, so a reasoning model that spends its budget in `reasoning`
and returns `content: null` looked like an empty answer. That dropped exactly
the trials where the aggregator struggled — a biased sample. With the fallback
fixed, **0 of 120 trials skipped**.
The earlier claim "Together's layering is negative value, don't build it" is
**retracted**: layered beats solo about as strongly as single-round does, and
edges single-round itself (p=0.015, CI lower bound +0.008 — the weakest of the
three results, and it costs an extra round of peer calls).
### Caveats
- Prompts are authored for this repo's domain, not a standard benchmark; these
numbers are **not** comparable to AlpacaEval-style scores.
- One aggregator, one peer set, one judge. A single judge model is the main
residual risk; self-preference is unlikely (judge is OpenAI-family, pool is
Qwen/Mistral/MiniMax) but unmeasured.
- Judged answer quality, not task success in a real agent loop.
## The mesh case: can a pool of small models beat its best member?
The question that decides whether mesh MoA is worth running on consumer
hardware. Same 40 prompts × 3 draws, same judge and controls — but the whole
pool is 8B-class and **diverse by family**, the shape a few laptops actually
have:
- aggregator `qwen/qwen3-8b`
- peers `meta-llama/llama-3.1-8b-instruct`, `ibm-granite/granite-4.1-8b`,
`mistralai/ministral-8b-2512`
**These are the length-controlled numbers** (n=80). See the judge-bias section
below for why the earlier, larger figures are withdrawn.
| comparison | win / tie / loss | sign test |
|---|---|---|
| committee (1 round) vs solo | 6 / 73 / 1 | p = 0.125 **ns** |
| **layered (2 rounds) vs solo** | **11 / 68 / 1** | **p = 0.0063** |
| layered vs committee | 3 / 77 / 0 | p = 0.25 **ns** |
**Yes — but only with the refinement round.** Single-round synthesis is
indistinguishable from the aggregator working alone; layering is what produces
the gain, winning 111 on decided trials.
Reading: with weaker members the aggregator has little to work with until the
peers have *seen each other* and improved their drafts. That is the mechanism
Together's `layers` provides, and it matters most exactly where mesh operates.
Honest scale: ties dominate (68/80). On most prompts a small mesh and a single
small model are indistinguishable; the mesh wins a minority and almost never
loses. That is a real but modest effect, not the large one the first pass
reported.
### Withdrawn: the length-biased numbers
The first run of this study scored **42/66/12, p=5.2e-05** for layered-vs-solo,
and **39/73/8** for layered-vs-committee. Both are withdrawn.
The judge was asked which response was "more accurate, complete, and useful".
"Complete" reads as "longer", and the judge duly scored length. Measured on the
e2e run with the same judge:
| | n | win | loss | winrate |
|---|---|---|---|---|
| MoA answer **longer** than solo | 25 | 13 | 0 | 100% |
| MoA answer **shorter** than solo | 55 | 4 | 24 | 14% |
point-biserial r(length delta, verdict) = **+0.681**.
Re-run with a judge told to score correctness and relevance only, and that
length is explicitly not quality, r fell to +0.132 and most former "wins"
became ties. The direction survived; the magnitude did not.
This is the same control the strong-pool section applies — it was simply never
carried into the small-pool and e2e harnesses.
## Admission control: does a weak node help a strong pool? (No.)
The A/B/C test the goal hinges on — should a modest node be admitted into a
committee that already has a stronger member? Same 40 prompts, same judge,
layered arm, through the committee harness (no admission control, so C is the
counterfactual "what if we admitted it"):
| arm | pool | layered vs solo | decided winrate | losses |
|---|---|---|---|---|
| B | 32B ×2 | 48W / 23T / 2L, p=2e-12 | 96% | 2 |
| C | 32B ×2 + 8B | 50W / 25T / 5L, p=2e-10 | 91% | 5 |
Fisher exact B-vs-C: p=0.44 — not statistically separable at n=80, but the
direction is one-way: **admitting the weak 8B node never helped and modestly
raised losses (5 vs 2).** Both still beat solo — a weak node does not collapse
the pool — but it adds latency and cost for no upside and a small tail risk.
This is the measured basis for tier-based admission control
(`apply_admission_control`): when a big-tier worker is present, drop small-tier
ones. The conservative choice, and the evidence says conservative is right here.
Caveat: this rejects the *possibility* that a genuinely complementary weak model
could help a specific prompt. The data says that possibility is not worth the
average-case cost at these scales; a per-turn admission signal (measured
marginal contribution) could revisit it later, but tier is the safe default now.
### But only when a committee survives the exclusion
Arm C dropped the 8B from a pool that *still had two 32B*. The other case — one
strong + one weak, where dropping the 8B collapses the pool to a solo 32B — is
different, and admission must NOT drop there:
| pool | vs solo 32B | length r |
|---|---|---|
| 32B + 8B, layered | 47W / 27T / 5L, p=1.3e-9 | 0.01 (clean) |
| 32B + 8B, single-round | 40W / 31T / 8L, p=3.3e-6 | +0.02 |
A mixed committee beats a solo strong model decisively. So the rule is not
"drop small whenever a big is present" — it is **drop small only when ≥2 big
remain**. Dropping to protect quality is right when a real committee is left; it
is wrong when it would throw away MoA entirely.
This is exactly the core mesh case: a modest node joining a single strong node
*does* help (47/5), and admission control now keeps it. Adding a modest node to
an *already-strong committee* does not help (arm C), and admission drops it.
Both are handled by the same "≥2 big remain" gate.
## The problem with N=2 was scale, not count
The N=2-8B null (below) suggested "two peers isn't enough". A mid-scale re-run
refutes that reading: the count was fine, the 8B *members* were too weak.
Same 40 prompts, same judge, same shipped `handle_turn`, layered arm:
| pool | layered vs best member | p (sign) | length r | MoA vs solo length |
|---|---|---|---|---|
| N=2, 8B (Qwen + Meta) | 2W / 75T / 3L | 1.00 | n/a (5 decided) | — |
| N=3, 8B (+ IBM) | 3W / 76T / 1L | 0.63 | — | — |
| N=4, 8B (+ Mistral) | 11W / 68T / 1L | 0.006 | — | — |
| **N=2, mid (32B + 24B)** | **49W / 24T / 6L** | **2e-9** | **0.04** | MoA shorter (2516 vs 3064) |
| N=4, strong (32B agg) | 90W / 11T / 19L | 3e-12 | +0.30 | MoA shorter (2196 vs 3136) |
The N=2-mid result is the cleanest in the whole study: no length confound
(r=0.04), MoA answers *shorter* than solo, and it still won 93% of the trials
where it was shorter. Two mid-size models beat one of equal strength decisively.
So diversity/capability of the *members*, not the raw count, is what matters —
at 8B you need ~4 models before it pays; at 2432B, 2 already win.
**Resolved: diversity is not the active ingredient — ensembling is.**
Compute-matched Self-MoA (two samples of the *same* qwen3-32b, same aggregator,
same drafts+refine+synthesize) was run against Mixed (qwen3-32b + mistral-24b):
| arm | layered vs solo | p | length r |
|---|---|---|---|
| Mixed (2 different models) | 49W / 24T / 6L | 1.8e-9 | 0.04 |
| Self (same model ×2) | 48W / 23T / 2L | 2.3e-12 | 0.04 |
Mixed vs Self: Fisher exact p = 0.27 — **statistically indistinguishable**.
Both crush the single best member, both with no length confound and MoA answers
*shorter* than solo (winning ~93% of shorter-MoA trials in each arm).
So different-family membership is **not required**. The active mechanism is
test-time ensembling — several sampled drafts (workers run at temperature 0.8,
so repeated draws genuinely differ), a cross-peer refinement round, and
synthesis — and it works whether the drafts come from different models or
repeated sampling of one. This matches the Self-MoA paper (arXiv:2502.00674):
proposal quality and sampling, not heterogeneity, carry the gain.
Practical consequence for a mesh: the value does not depend on curating a
diverse pool. Any ≥2 reasonably-capable participants — distinct models *or*
repeated instances of one — beat picking a single model, once they are strong
enough individually (mid-scale here; 8B needs ~4).
## The 8B ladder: how many small models beat one?
Does stacking 8B models beat a single 8B? Same prompts/judge/harness, layered arm:
| pool | vs one 8B | p |
|---|---|---|
| 2× 8B, different (Qwen + Meta) | 2W / 75T / 3L | 1.00 |
| 2× 8B, **same** (Qwen ×2) | 2W / 78T / 0L | 0.50 |
| 3× 8B, different | 3W / 76T / 1L | 0.63 |
| 4× 8B, different | 11W / 68T / 1L | 0.006 |
Two results worth stating plainly:
- **You need ~4 at 8B.** Two or three 8B models don't beat one; four do. There
is a floor below which stacking small models buys nothing.
- **Same-vs-different makes no difference at N=2** (2W/3L vs 2W/0L, both null),
exactly as the mid-scale Self-vs-Mixed test showed. Count and member
strength drive the result; family identity does not.
The one gap in this ladder: 4× 8B *same-model* (four qwen3-8b instances) was not
run, so "does the 8B N=4 win need distinct models or just four drafts?" is
open. Mid-scale Self-MoA predicts four drafts alone would suffice, but that is
an extrapolation.
## How many peers does it take? (N=2 vs N=4)
Same 40 prompts, same judge, same harness — only the pool size differs. N=2 is
`qwen3-8b` + `llama-3.1-8b` with qwen3-8b aggregating (Together's
`advanced-moa.py` shape, where the aggregator is also a reference).
| pool | 1 round vs solo | 2 rounds vs solo | decided trials |
|---|---|---|---|
| **N=2** (Qwen, Meta) | 2W / 75T / 3L, p=1.0 | 2W / 75T / 3L, p=1.0 | 5 of 80 |
| **N=4** (+ IBM, Mistral) | 6W / 73T / 1L, p=0.125 | **11W / 68T / 1L, p=0.0063** | 12 of 80 |
Fisher exact on decided win/loss, N=2 vs N=4: **p = 0.053**.
**Two peers was not enough.** At 8B scale, adding one different model produced
no measurable gain — 2 wins against 3 losses, indistinguishable from noise. The
same harness with four models across four families wins 111.
Two things move together as peers are added: the number of *decided* trials
rises (5 → 12; more peers produce more differentiated output rather than
near-identical answers the judge calls a tie), and the win share among those
rises (40% → 92%).
Caveats: only 5 decided trials at N=2, so this is weak evidence of absence, not
evidence of no effect. And it is a claim about *small* models — Hermes reports
a two-model preset (`claude-opus-4.8` aggregating a `gpt-5.5` reference) at
0.8202 vs 0.7607 for the stronger model alone, so frontier pairs may behave
differently. Untested here.
## Eval-vs-production fidelity
A measured gain only counts if the shipped path reproduces the measured
configuration. Three gaps were found and closed after the numbers above were
collected — all in the same class as the reference-packing bug, where code that
looked equivalent was not:
| | measured in eval | shipped (before) | now |
|---|---|---|---|
| refinement input per draft | untruncated (~3.8k chars) | 1200 chars (~30%) | 4000 chars |
| reducer payload per answer | untruncated (~3.8k chars) | 500 chars (~13%) | 4000 chars (text) |
| refinement prompt | aggregator wording + `[Response N]` | different wording + `[Answer N]` | matches eval |
The truncation gaps were the serious ones: the reducer was seeing ~13% of each
refined answer, discarding most of exactly what the refinement round produces.
Tool turns deliberately keep the tight 500-char bound — there the signal is the
proposal itself and long prose crowds out the schemas.
Sampling already matched (`SamplingParams::worker()`, thinking off, 1024
tokens).
**Implication for reading the numbers above:** they were produced by the eval
harness, and production now matches that configuration — but the small-pool
result has not been *re-measured* through the shipped code path since these
fixes. The engine is transport-agnostic and the packing is now identical, so
the gain should carry; that is an expectation, not an observation.
## Reproducing
```bash
export OPENROUTER_API_KEY=...
# tool-selection ablation (2x2: actor strength x packing)
MOA_REFERENCE_PACKING=hermes MOA_ABLATION_ACTOR=qwen/qwen3-8b \
MOA_ABLATION_OUT=/tmp/x.jsonl \
cargo test -p mesh-mixture-of-agents --test eval_openrouter \
ablation_scaled_study -- --ignored --nocapture
python3 evals/moa-openrouter/analyze_ablation.py /tmp/x.jsonl
```
Other studies: `matched_peer_structured_study`,
`correction_rescues_weak_tool_caller`, `committee_beats_solo_on_reasoning`.
## Status
**Tool selection — directional, no clear win for multi-model.** 40 tasks × 10
draws detects the packing bug (a large effect) but cannot separate the
remaining ±0.05 effects. Every intervention tried (prose advice, structured
proposals, deterministic correction, semantic correction) was null-to-harmful
against simply routing to a capable model. Correctly-packed references are
roughly break-even, positive for a weak actor, mildly negative for a strong
one — hence gating on actor headroom rather than always/never.
**Reasoning/answer turns, strong pool — a clear win.** 120 trials, p < 1e-11,
consistent across all four strata, and the length confound runs *against* the
result rather than explaining it (the winning arm was the shorter one).
**Reasoning/answer turns, small pool — a real but modest win, and only with
refinement.** Length-controlled: layered beats solo 111 on decided trials
(p = 0.0063), single-round does not (61, ns). Ties dominate at 68/80 — on most
prompts a small mesh and a single small model are indistinguishable.
**End-to-end through `handle_turn` — parity, not yet a win.** Latest:
9 / 59 / 12 (p = 0.66) against the pool's best member; the prior run was
5 / 65 / 10 (p = 0.30). Both are parity, and the difference between them is
noise at this sample size.
Six eval-vs-production divergences were found by measuring the shipped path and
fixed: grace finalizing the turn before refinement could run, two truncation
bounds that discarded most of each answer, two prompts that contradicted the
measured configuration, and named-vs-anonymous reducer inputs. A seventh
hypothesis (removing the worker preamble from the reducer) was measured,
*rejected*, and reverted.
The gap that remains is unexplained:
| | win / tie / loss | sign test |
|---|---|---|
| harness (`refine` + `synthesize` helpers) | 11 / 68 / 1 | p = 0.0063 |
| shipped (`moa::handle_turn`) | 9 / 59 / 12 | p = 0.66 |
Same models, same prompts, same judge, same packing. The mechanism works when
driven directly; something in the shipped orchestration still costs the gain.
Two prompt-level explanations were tested and **rejected**:
| change | decided-trial winrate | MoA output |
|---|---|---|
| baseline (v7) | 5/15 = 33% | 3606 |
| anonymize reducer inputs (v8) | 9/21 = 43% | 3679 |
| also drop preamble + "Reason for synthesis" (v9) | 8/23 = 35% | 3314 |
Dropping the worker preamble shortened output and lost ground on both
occasions it was tried (v6 3534 chars, v9 3314) versus keeping it (v7 3606,
v8 3679), so it was reverted twice. Reading: the preamble ("the best parts of
each will be combined; give your most accurate and complete answer") does
useful work on the reducer even though it is nominally addressed to a worker.
**Matching the harness exactly is not automatically right** — the harness sent
no system prompt at all, production sends one, and the preamble evidently
compensates.
Anonymization (v8) is retained: it is what Hermes does and what the study
measured, and it did not hurt. But 43% vs 33% on ~20 decided trials is not a
result; both runs are parity.
Still unruled-out: the arbiter short-circuiting synthesis when refined drafts
converge (74/80 turns did reach the reducer, so partial at most), and
differences in what `normalize_worker_output` does to prose before refinement
consumes it. Neither has been tested.
After two rejected hypotheses in a row, the honest read is that the remaining
gap is not another prompt-wording difference. It needs a diff of the actual
prompt bytes sent by each path on the same input, not more guesses.
Caution on the numbers above: r(length, verdict) was +0.465 in the latest e2e
run versus +0.132 in the small-pool study, so length bias is not fully
suppressed even with the corrected judge. Treat single-run e2e deltas as
directional only.
The task split follows from the evidence: **route tool turns to the best
tool-caller; convene the committee on reasoning/answer turns.**
Outstanding before this is a merge-blocking claim:
- close the remaining harness-vs-production gap (parity → the harness's 111)
- end-to-end agent-task success, not judged answer quality
- a second judge model to bound single-judge risk
- 2-node mesh validation (everything here is measured through the engine, not
gossip)
## Width sprint: many small models (2026-08-05)
Aggregator = `qwen/qwen3-8b`, peers 8B-class, judge `gpt-4o-mini`,
position-swapped + length-noted, 2 draws × 40 prompts, shipped committee path.
Three arms per trial: single-aggregation (Hermes-shape, draft→synthesize),
layered (Together-shape, draft→synth→refine→synth), and the refine-vs-single
delta.
| pool | single-agg vs solo | layered vs solo | refine vs single-agg |
|---|---|---|---|
| 2× 8B diverse | 2W/77T/1L, p=1.0 | 3W/74T/3L, p=1.0 | null |
| 4× 8B diverse | 5W/73T/0L, p=0.06 | 6W/70T/2L, p=0.29 | 0W/77T/1L |
| **6× 8B diverse** | **12W/65T/2L, p=0.013** | 9W/69T/1L, p=0.021 | 2W/76T/1L |
| 6× qwen3-8b SAME | 4W/75T/1L, p=0.38 | 1W/79T/0L, p=1.0 | 1W/79T/0L |
> **WITHDRAWN — see "Withdrawal: the 6x8B small-pool win did not replicate" at
> the end of this file.** The 6× 8B cell below did not reproduce (3W/76T/1L,
> p=0.63 on a re-run of the same rig, same tasks, same pool), and the shipped
> path measured a *loss* at every small width. All-small pools now serve their
> best member instead of convening a committee. The rest of this section is kept
> for the record; do not cite the 6× 8B number.
Findings (as originally written; the first is withdrawn):
- ~~**Width is the small-model lever.** A committee of six diverse 8B models
beats the single best member (12W/2L, p=0.013); four is only marginal
(p=0.06); two/three are null.~~ Withdrawn: rested on ~14 decided trials out of
80 and did not replicate. The tier-aware cap (6 all-small / 4 with a verified
big) is retained, but an all-small pool no longer forms a committee at all.
- **The refine round never earns its serial cost.** `refine vs single-agg` is
null in every cell (2/77/1 at N6). Hermes' single-aggregation cadence (one
synth, no refine pass) is >= Together's layered shape here, at half the
serial latency. The `RefinementPolicy` default should reconsider the extra
round for small pools.
- **At 8B, diversity matters** (6 diverse 12W/2L vs 6 same 4W/1L, p=0.38),
unlike mid-scale where Self ≈ Mixed. Weaker models decorrelate their errors
better when they are genuinely different families; repeated draws of one 8B
do not supply enough independent signal.
Open: 4B pools (is the floor lower or does it need even more width?), and
whether dropping the refine round for small pools recovers latency without
losing the width win.
## Withdrawal: the 6x8B small-pool win did not replicate (2026-08-06)
The width-sprint headline ("6x diverse 8B beats its best member, 12W/65T/2L,
p=0.013") is **withdrawn**. Re-running the same rig on the same 40 tasks, same
pool, same judge gives **3W/76T/1L (p=0.63)**, with 11 of 40 tasks flipping
verdict. The original rested on ~14 decided trials out of 80 (the rest ties) and
was a single unreplicated run selected from a width sweep; correcting for the
four pool widths compared puts it at roughly p=0.05 even on its own terms.
It was real arithmetic on real data, but never a robust effect, and it should
not have been published as a headline from one run.
### What the shipped path actually does at small scale
Through `moa::handle_turn` at production defaults, 8B-class peers with an 8B
reducer, vs the pool's best member alone (40 prompts x 2 draws, out-of-family
position-swapped judge, length logged):
| pool | result | decided | p |
|---|---|---|---|
| 2x 8B | 0W/43T/37L | 37 | <0.0001 |
| 4x 8B | 5W/63T/12L | 17 | 0.14 |
| 6x 8B | 5W/52T/23L | 28 | 0.0009 |
MoA answers are consistently shorter (3236-3372 chars vs ~4070 solo). The
committee never won at any width. Note the capable pool wins 28/29 of decided
trials where the MoA answer is *shorter*, which rules out "this judge simply
prefers longer answers" as the explanation.
**Action:** all-small pools no longer convene a committee; they collapse to the
best member and the caller degrades to serving it directly.
**Scope of the claim:** this measures a weak reducer synthesizing weak drafts.
It is not evidence that small-model MoA cannot work. The untested cell is small
peers with a *strong* reducer (the capable pool confounds peer strength with
reducer strength). If a mesh gains a big-tier model the pool is no longer
all-small and MoA engages again.
### Known methodology limitations
- `judge_pair` collapses genuine ties, position disagreements, and API/parse
failures into the same `0` result, so "tie" is a garbage bucket and tie counts
cannot be interpreted as agreement.
- Significance is computed per draw (80) rather than per prompt cluster (40),
which overstates confidence; the capable-pool result survives either way, the
small-pool ones are marginal.
- `pool[0]` is assumed to be the strongest member rather than verified.
- Single judge model.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Analyze the scaled MoA actor-ablation study.
Consumes the per-trial JSONL written by `ablation_scaled_study`
(eval_openrouter.rs) and reports the paired net-uplift of references over
actor-alone, with a hierarchical bootstrap CI.
Arms per (draw, task):
A = actor alone
B = actor + real references (production tool path)
C = actor + shuffled references (advice from a different task)
Primary statistic: net uplift = P(rescue) - P(harm), where
rescue = A fail & B pass, harm = A pass & B fail,
on trials where BOTH A and B were scored (infra errors excluded).
The bootstrap is HIERARCHICAL and PAIRED to respect the design:
- resample TASKS within each category (stratified), then
- resample DRAWS within each resampled task,
so the CI reflects generalization across tasks, not just draw noise.
The C arm is the control: if B uplift ~ C uplift, the gain is "extra tokens +
a decision prompt", not advice content. We report B-vs-A and C-vs-A side by
side, and the differential (B_uplift - C_uplift).
Usage:
python3 analyze_ablation.py /tmp/moa_ablation.jsonl [--iters 10000] [--seed 0]
Deterministic given (jsonl, iters, seed). Stdlib only.
"""
import argparse
import json
import random
import sys
from collections import defaultdict
def load(path):
"""-> trials[(task_id)] = {'category', 'draws': {draw: {arm: outcome}}}"""
tasks = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
r = json.loads(line)
t = tasks.setdefault(
r["task_id"], {"category": r["category"], "draws": defaultdict(dict)}
)
t["draws"][r["draw"]][r["arm"]] = r["outcome"]
return tasks
def paired_counts(draws, arm):
"""Rescue/harm/scored counts for `arm` vs A over this task's draws."""
rescue = harm = scored = 0
for d in draws.values():
a = d.get("A")
x = d.get(arm)
if a in ("pass", "fail") and x in ("pass", "fail"):
scored += 1
if a == "fail" and x == "pass":
rescue += 1
elif a == "pass" and x == "fail":
harm += 1
return rescue, harm, scored
def task_uplift(draws, arm):
"""Net uplift for one task (mean over its scored draws), or None."""
rescue, harm, scored = paired_counts(draws, arm)
if scored == 0:
return None
return (rescue - harm) / scored
def point_estimate(tasks, arm):
"""Equal-weight mean of per-task uplift (per expert: task is the unit)."""
vals = [u for t in tasks.values() if (u := task_uplift(t["draws"], arm)) is not None]
return sum(vals) / len(vals) if vals else float("nan"), len(vals)
def bootstrap_ci(tasks, arm, iters, seed):
"""Hierarchical paired bootstrap: resample tasks within category, then
draws within task. Returns (lo, hi) 95% CI for mean per-task uplift."""
rng = random.Random(seed)
by_cat = defaultdict(list)
for tid, t in tasks.items():
by_cat[t["category"]].append(tid)
# Precompute per-task draw lists so resampling draws is cheap.
draw_lists = {tid: list(t["draws"].values()) for tid, t in tasks.items()}
samples = []
cats = sorted(by_cat)
for _ in range(iters):
vals = []
for cat in cats:
ids = by_cat[cat]
for _ in range(len(ids)):
tid = ids[rng.randrange(len(ids))]
dl = draw_lists[tid]
if not dl:
continue
# resample draws within this task, with replacement
res = [dl[rng.randrange(len(dl))] for _ in range(len(dl))]
rescue = harm = scored = 0
for d in res:
a = d.get("A")
x = d.get(arm)
if a in ("pass", "fail") and x in ("pass", "fail"):
scored += 1
if a == "fail" and x == "pass":
rescue += 1
elif a == "pass" and x == "fail":
harm += 1
if scored:
vals.append((rescue - harm) / scored)
if vals:
samples.append(sum(vals) / len(vals))
samples.sort()
if not samples:
return float("nan"), float("nan")
lo = samples[int(0.025 * len(samples))]
hi = samples[int(0.975 * len(samples)) - 1]
return lo, hi
def arm_pass_rate(tasks, arm):
p = n = 0
for t in tasks.values():
for d in t["draws"].values():
o = d.get(arm)
if o in ("pass", "fail"):
n += 1
p += o == "pass"
return p, n
def main():
ap = argparse.ArgumentParser()
ap.add_argument("jsonl")
ap.add_argument("--iters", type=int, default=10000)
ap.add_argument("--seed", type=int, default=0)
args = ap.parse_args()
tasks = load(args.jsonl)
if not tasks:
print("no trials found", file=sys.stderr)
sys.exit(1)
n_tasks = len(tasks)
cats = defaultdict(int)
for t in tasks.values():
cats[t["category"]] += 1
infra = 0
total = 0
for t in tasks.values():
for d in t["draws"].values():
for arm in ("A", "B", "C"):
if arm in d:
total += 1
infra += d[arm] == "infra"
print(f"tasks={n_tasks} strata={dict(sorted(cats.items()))}")
print(f"trials={total} infra_excluded={infra} ({100*infra/max(total,1):.1f}%)")
print()
for arm in ("A", "B", "C"):
p, n = arm_pass_rate(tasks, arm)
label = {"A": "actor alone", "B": "actor + real", "C": "actor + shuffled"}[arm]
print(f" {arm} {label:18} pass {p}/{n} ({100*p/max(n,1):.0f}%)")
print()
b_pt, b_k = point_estimate(tasks, "B")
c_pt, c_k = point_estimate(tasks, "C")
b_lo, b_hi = bootstrap_ci(tasks, "B", args.iters, args.seed)
c_lo, c_hi = bootstrap_ci(tasks, "C", args.iters, args.seed)
print(" net uplift = P(rescue) - P(harm), equal-weight mean over tasks")
print(f" B (real) uplift {b_pt:+.3f} 95% CI [{b_lo:+.3f}, {b_hi:+.3f}] (tasks n={b_k})")
print(f" C (shuffled) uplift {c_pt:+.3f} 95% CI [{c_lo:+.3f}, {c_hi:+.3f}] (tasks n={c_k})")
print(f" differential B-C: {b_pt - c_pt:+.3f} (content effect beyond token/prompt effect)")
print()
# Verdicts (directional; the CI is what matters for a claim).
if b_lo > 0:
print(" => references HELP: B net uplift CI is entirely > 0")
elif b_hi < 0:
print(" => references HARM: B net uplift CI is entirely < 0")
else:
print(" => inconclusive: B net uplift CI spans 0")
if b_pt - c_pt > 0 and b_lo > 0:
print(" => and the gain is CONTENT (B > C), not just extra tokens/prompt")
elif abs(b_pt - c_pt) < 0.02:
print(" => gain (if any) is NOT content-specific (B ~ C)")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Lite goose-style agentic harness against a mesh-llm OpenAI endpoint.
Drives a real tool-call loop through `model=mesh` (or any model) to prove the
MoA gateway emits usable `tool_calls` end to end on a live 2-node mesh the
path OpenRouter standins could not exercise (gossip, dedup, self-fill, the
actual proxy).
Tools are canned/filesystem-lite so the loop is deterministic and offline
apart from the model calls. Prints a worklog of every step.
Usage:
python3 lite_agent.py --base http://localhost:9337/v1 --model mesh
python3 lite_agent.py --base http://localhost:9337/v1 --model mesh --task find_symbol
"""
import argparse
import json
import os
import time
import urllib.request
# --- canned workspace the agent can act on (deterministic tool results) ---
WORKSPACE = {
"src/lib.rs": "pub fn add(a: i32, b: i32) -> i32 { a + b }\n\npub fn timeout() -> MeshError { MeshError::Timeout }\n",
"src/error.rs": "pub enum MeshError { Timeout, Reset }\n",
"README.md": "# demo\nA tiny crate. Run `cargo test`.\n",
}
TOOLS = [
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List files under a directory path.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file by path.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "search",
"description": "Search the codebase for a regex or substring.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
]
TASKS = {
"explore": "List the files under src, then read src/error.rs and tell me what error variants exist.",
"find_symbol": "Find every place MeshError::Timeout is used in this repo, then summarise where.",
"explain": "What does the add function in src/lib.rs do? Read it first.",
}
def run_tool(name, args):
"""Execute a canned tool against WORKSPACE. Returns a string result."""
if name == "list_dir":
p = (args.get("path") or "").strip("/")
hits = sorted(f for f in WORKSPACE if f.startswith(p))
return "\n".join(hits) if hits else f"(no files under {p!r})"
if name == "read_file":
p = (args.get("path") or "").lstrip("./")
return WORKSPACE.get(p, f"(no such file: {p!r})")
if name == "search":
q = args.get("query") or ""
# substring match, tolerate regex-ish word-boundary noise
needle = q.replace("\\b", "").replace("\\", "")
hits = [f"{f}: {ln}" for f, body in WORKSPACE.items() for ln in body.splitlines() if needle and needle in ln]
return "\n".join(hits) if hits else f"(no matches for {q!r})"
return f"(unknown tool {name!r})"
def chat(base, model, messages, api_key=None, timeout=180):
body = json.dumps({
"model": model,
"messages": messages,
"tools": TOOLS,
"max_tokens": 1024,
}).encode()
req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=body,
headers={"Content-Type": "application/json"})
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
t0 = time.time()
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.load(r)
return resp, time.time() - t0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://localhost:9337/v1")
ap.add_argument("--model", default="mesh")
ap.add_argument("--task", default="explore", choices=list(TASKS))
ap.add_argument("--max-steps", type=int, default=6)
args = ap.parse_args()
messages = [
{"role": "system", "content": "You are a coding agent. Use the provided tools to inspect the "
"repository before answering. Call one tool at a time."},
{"role": "user", "content": TASKS[args.task]},
]
print(f"=== lite-agent: model={args.model} task={args.task} base={args.base} ===")
print(f"USER: {TASKS[args.task]}\n")
tool_calls_made = 0
for step in range(1, args.max_steps + 1):
try:
resp, dt = chat(args.base, args.model, messages)
except Exception as e: # noqa: BLE001
print(f"[step {step}] REQUEST FAILED: {e}")
return 2
msg = resp["choices"][0]["message"]
calls = msg.get("tool_calls") or []
moa_workers = resp.get("usage", {}) # x-moa headers arrive as headers; body usage is fine to show
if calls:
messages.append(msg)
for c in calls:
fn = c["function"]["name"]
try:
fa = json.loads(c["function"].get("arguments") or "{}")
except json.JSONDecodeError:
fa = {}
tool_calls_made += 1
result = run_tool(fn, fa)
print(f"[step {step}] ({dt:.1f}s) TOOL CALL: {fn}({json.dumps(fa)})")
print(f" -> {result.splitlines()[0] if result else '(empty)'}"
+ (" ..." if result.count("\n") else ""))
messages.append({
"role": "tool",
"tool_call_id": c.get("id", f"call_{step}"),
"content": result,
})
else:
content = (msg.get("content") or "").strip()
print(f"[step {step}] ({dt:.1f}s) FINAL ANSWER:\n{content}\n")
print(f"=== done: {tool_calls_made} tool call(s) over {step} step(s) ===")
return 0 if tool_calls_made > 0 else 1
print(f"=== hit max steps ({args.max_steps}); {tool_calls_made} tool call(s) made ===")
return 0 if tool_calls_made > 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,105 @@
"""Turn recorded OpenRouter traces into a Rust test fixture.
Reads agentic.jsonl (multi-step tool traces) and corpus.jsonl (single-shot
fan-out, including truncated responses) and emits a compact JSON fixture
consumed by crates/mesh-mixture-of-agents/tests/sim_real_traces.rs.
The fixture keeps *structured* tool_calls and finish_reason the two
things that get lost if you flatten worker responses to text. Latencies
are preserved as recorded so ordering-sensitive behaviour (early exit,
first-answer grace, strong patience) replays realistically; the Rust side
scales them down so tests stay fast.
"""
import json
OUT = "../../crates/mesh-mixture-of-agents/tests/fixtures/real_traces.json"
def worker_row(w):
return {
"model": w["model"],
"tier": w["tier"],
"elapsed_ms": int(round((w["elapsed"] or 0) * 1000)),
"finish_reason": w.get("finish_reason"),
"content": w.get("text"),
"tool_calls": w.get("tool_calls"),
"error": w.get("error"),
}
cases = []
# ── Multi-step agentic traces ────────────────────────────────────────
for line in open("agentic.jsonl"):
line = line.strip()
if not line:
continue
r = json.loads(line)
cases.append(
{
"id": f"{r['scenario']}__d{r['draw']}_s{r['step']}",
"source": "agentic",
"scenario": r["scenario"],
"draw": r["draw"],
"step": r["step"],
"has_tools": True,
"messages": r["messages"],
"workers": [worker_row(w) for w in r["workers"]],
}
)
# ── Single-shot fan-out, incl. truncated responses ───────────────────
for line in open("corpus.jsonl"):
line = line.strip()
if not line:
continue
r = json.loads(line)
cases.append(
{
"id": f"{r['case']}__d{r['draw']}",
"source": "corpus",
"scenario": r["case"],
"draw": r["draw"],
"step": 0,
"has_tools": r["has_tools"],
"messages": r["messages"],
"workers": [worker_row(w) for w in r["workers"]],
}
)
with open(OUT, "w") as f:
json.dump({"cases": cases}, f, indent=1, sort_keys=True)
# ── Report what the fixture actually contains ────────────────────────
n_trunc = sum(
1 for c in cases for w in c["workers"] if w["finish_reason"] == "length"
)
n_tool_steps = sum(
1 for c in cases if any(w["tool_calls"] for w in c["workers"])
)
n_mixed = 0
n_unanimous_name_diff_args = 0
for c in cases:
ok = [w for w in c["workers"] if not w["error"]]
tools = [w for w in ok if w["tool_calls"]]
text = [w for w in ok if not w["tool_calls"]]
if tools and text:
n_mixed += 1
if tools and not text:
names = {t["function"]["name"] for w in tools for t in w["tool_calls"]}
args = {
(t["function"]["name"], t["function"].get("arguments") or "{}")
for w in tools
for t in w["tool_calls"]
}
if len(names) == 1 and len(args) > 1:
n_unanimous_name_diff_args += 1
print(f"wrote {OUT}")
print(f" cases: {len(cases)}")
print(f" cases with >=1 tool call: {n_tool_steps}")
print(f" mixed tool+text cases: {n_mixed}")
print(f" name-unanimous, args differ: {n_unanimous_name_diff_args}")
print(f" truncated worker responses: {n_trunc}")
print(f" distinct models: {len({w['model'] for c in cases for w in c['workers']})}")

View file

@ -0,0 +1,136 @@
"""Minimal OpenRouter client — stdlib only (no `requests` dependency).
Shared by the MoA probe scripts in this directory.
"""
import json
import os
import time
import urllib.error
import urllib.request
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
def chat(
model,
messages,
tools=None,
max_tokens=512,
temperature=0.7,
timeout=120,
no_think=False,
):
"""One OpenAI-shaped chat completion. Returns (response_json, elapsed_s).
Retries on 429/5xx with a small backoff ladder. Unlike Together's
reference implementation, every failure path returns a structured
error instead of raising the caller decides what a dead worker means.
`no_think=True` mirrors mesh-llm's `effective_enable_thinking_for_moa`
default: reasoning models get told to skip the think phase, so a small
worker budget isn't spent producing reasoning tokens and no answer.
"""
key = os.environ.get("OPENROUTER_API_KEY")
if not key:
return {"error": "OPENROUTER_API_KEY not set"}, 0.0
body = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if tools:
body["tools"] = tools
if no_think:
body["reasoning_effort"] = "none"
body["chat_template_kwargs"] = {"enable_thinking": False}
def _build(b):
return urllib.request.Request(
ENDPOINT,
data=json.dumps(b).encode(),
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/mesh-llm",
"X-Title": "mesh-llm MoA probe",
},
)
started = time.time()
last_err = None
for sleep_time in (0, 2, 5):
if sleep_time:
time.sleep(sleep_time)
try:
with urllib.request.urlopen(_build(body), timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
if "error" in payload:
last_err = str(payload["error"])
continue
return payload, time.time() - started
except urllib.error.HTTPError as e:
detail = e.read().decode()[:300]
last_err = f"HTTP {e.code}: {detail}"
# Some endpoints (e.g. minimax) *require* reasoning and reject
# our thinking-disable flags with 400. Drop them and retry once
# rather than losing the worker entirely.
if e.code == 400 and "reasoning" in detail.lower() and no_think:
body.pop("reasoning_effort", None)
body.pop("chat_template_kwargs", None)
no_think = False
continue
if e.code not in (429, 500, 502, 503, 504):
break
except Exception as e: # timeout, connection reset, bad JSON
last_err = f"{type(e).__name__}: {e}"
return {"error": last_err or "unknown"}, time.time() - started
def first_choice(resp):
"""Extract (text, tool_calls) from a response. Either may be empty/None."""
try:
msg = resp["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
return None, None
return msg.get("content"), msg.get("tool_calls")
# ─── Mesh-realistic worker pool ──────────────────────────────────────
#
# Chosen so tiers line up with how mesh-llm's `is_single_digit_b_name`
# classifies names (single-digit-B => small tier, everything else big),
# and so the mix mirrors a real mesh: a couple of small local-ish models
# plus bigger MoE/frontier-ish open weights.
POOL_SMALL = [
"qwen/qwen3-8b",
"qwen/qwen3.5-9b",
"mistralai/ministral-8b-2512",
"mistralai/ministral-3b-2512",
]
POOL_BIG = [
"qwen/qwen3-14b",
"qwen/qwen3-32b",
"qwen/qwen3-30b-a3b-instruct-2507",
"minimax/minimax-m2.5",
"mistralai/mistral-small-3.2-24b-instruct",
]
POOL = POOL_SMALL + POOL_BIG
def tier(model):
"""Mirror of mesh-llm's name-derived tiering, for reporting only."""
name = model.lower()
for i, c in enumerate(name):
if not c.isdigit() or c == "0":
continue
if i > 0 and (name[i - 1].isdigit() or name[i - 1] == "." or name[i - 1].isalpha()):
continue
if i + 1 < len(name) and name[i + 1] == "b":
if i + 2 < len(name) and name[i + 2].isdigit():
continue
return "small"
return "big"

View file

@ -0,0 +1,218 @@
"""Probe: does Together-style MoA survive agentic tool calling?
Runs three shapes against real open-weight models on OpenRouter:
A. Fan-out with tools, then aggregate Together-style (numbered
plaintext concat of worker text into a system prompt).
B. The same, but reporting what mesh-llm's arbiter would see
(structured tool proposals + consensus).
C. A tool-result turn: feed a real tool result back and aggregate.
Everything is recorded to fanout.jsonl so it can seed a replay corpus.
"""
import json
import sys
from concurrent.futures import ThreadPoolExecutor
import orclient as oc
# Real agentic tool schemas, close to what goose/opencode send.
TOOLS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file from disk",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Path to the file"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List files in a directory",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Directory path"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "Run a shell command and return stdout",
"parameters": {
"type": "object",
"properties": {"cmd": {"type": "string", "description": "Command to run"}},
"required": ["cmd"],
},
},
},
]
# Together's aggregator system prompt, verbatim from their utils.py.
TOGETHER_AGG_PROMPT = """You have been provided with a set of responses from various open-source models to the latest user query. Your task is to synthesize these responses into a single, high-quality response. It is crucial to critically evaluate the information provided in these responses, recognizing that some of it may be biased or incorrect. Your response should not simply replicate the given answers but should offer a refined, accurate, and comprehensive reply to the instruction. Ensure your response is well-structured, coherent, and adheres to the highest standards of accuracy and reliability.
Responses from models:"""
RECORD = open("fanout.jsonl", "a")
def record(kind, **kw):
RECORD.write(json.dumps({"kind": kind, **kw}) + "\n")
RECORD.flush()
def fan_out(messages, tools, max_tokens=384):
"""Parallel fan-out across the pool. Never raises — a dead worker is
a recorded failure, not a dead turn (unlike Together's asyncio.gather).
"""
def one(model):
resp, elapsed = oc.chat(model, messages, tools=tools, max_tokens=max_tokens, temperature=0.8)
text, tcs = oc.first_choice(resp)
return {
"model": model,
"tier": oc.tier(model),
"elapsed": round(elapsed, 2),
"error": resp.get("error"),
"text": text,
"tool_calls": tcs,
}
with ThreadPoolExecutor(max_workers=len(oc.POOL)) as ex:
return list(ex.map(one, oc.POOL))
def summarize(results, label):
print(f"\n{'=' * 74}\n{label}\n{'=' * 74}")
for r in results:
if r["error"]:
print(f" {r['model']:40s} [{r['tier']:5s}] FAILED: {str(r['error'])[:60]}")
continue
tcs = r["tool_calls"] or []
if tcs:
calls = ", ".join(
f"{c['function']['name']}({c['function']['arguments']})" for c in tcs
)
print(f" {r['model']:40s} [{r['tier']:5s}] {r['elapsed']:5.1f}s TOOL: {calls}")
else:
print(
f" {r['model']:40s} [{r['tier']:5s}] {r['elapsed']:5.1f}s TEXT: "
f"{(r['text'] or '')[:70]!r}"
)
return results
def together_aggregate(user_prompt, results, aggregator, with_tools):
"""Together's aggregation: numbered plaintext concat of worker *text*.
Note what necessarily happens to tool calls here: the references are
built from `.text`, because that is all their `inject_references_to_
messages` knows how to read. Structured tool_calls have nowhere to go.
"""
refs = []
for r in results:
if r["error"]:
continue
refs.append(r["text"] or "") # <-- tool_calls are dropped on the floor
system = TOGETHER_AGG_PROMPT
for i, ref in enumerate(refs):
system += f"\n{i + 1}. {ref}"
msgs = [
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
]
resp, elapsed = oc.chat(
aggregator, msgs, tools=TOOLS if with_tools else None, max_tokens=384, temperature=0.3
)
text, tcs = oc.first_choice(resp)
return {"text": text, "tool_calls": tcs, "elapsed": round(elapsed, 2), "error": resp.get("error")}
def main():
aggregator = "qwen/qwen3-32b"
# ── A. Agentic fan-out: does every worker propose a tool? ────────
prompt_a = (
"I need to understand the error handling in this Rust project. "
"Start by looking at what's in the src directory."
)
msgs_a = [{"role": "user", "content": prompt_a}]
res_a = summarize(fan_out(msgs_a, TOOLS), "A. FAN-OUT WITH TOOLS (agentic first turn)")
record("fanout_tools", prompt=prompt_a, results=res_a)
proposals = {}
for r in res_a:
for c in r["tool_calls"] or []:
proposals.setdefault(c["function"]["name"], []).append(r["model"])
print(f"\n tool proposals: {json.dumps(proposals, indent=2)}")
n_tool = sum(1 for r in res_a if r["tool_calls"])
n_text = sum(1 for r in res_a if not r["tool_calls"] and not r["error"])
print(f" -> {n_tool} workers proposed tools, {n_text} answered with text only")
# ── A2. Aggregate Together-style, tools available ────────────────
agg = together_aggregate(prompt_a, res_a, aggregator, with_tools=True)
print(f"\n Together-style aggregation (aggregator={aggregator}):")
print(f" elapsed={agg['elapsed']}s error={agg['error']}")
print(f" tool_calls={agg['tool_calls']}")
print(f" text={(agg['text'] or '')[:200]!r}")
record("aggregate_tools", prompt=prompt_a, aggregator=aggregator, result=agg)
# What the aggregator actually received as "references":
refs_seen = [(r["model"], (r["text"] or "")[:40], bool(r["tool_calls"])) for r in res_a if not r["error"]]
print("\n what Together's aggregator SAW (text only, per model):")
for m, t, had_tool in refs_seen:
flag = " <-- had a tool_call that was DROPPED" if had_tool else ""
print(f" {m:40s} {t!r}{flag}")
# ── B. Tool-result turn (agentic step 2) ─────────────────────────
tool_call_id = "call_probe_1"
msgs_b = [
{"role": "user", "content": prompt_a},
{
"role": "assistant",
"tool_calls": [
{
"id": tool_call_id,
"type": "function",
"function": {"name": "list_dir", "arguments": '{"path": "src"}'},
}
],
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": "main.rs\nlib.rs\nerror.rs\nconfig.rs\nnetwork/\ninference/",
},
]
res_b = summarize(fan_out(msgs_b, TOOLS), "B. TOOL-RESULT TURN (agentic step 2)")
record("fanout_tool_result", results=res_b)
n_follow = sum(1 for r in res_b if r["tool_calls"])
print(f"\n -> {n_follow} workers chained a follow-up tool call after seeing the result")
# ── C. Plain chat fan-out for comparison (no tools) ──────────────
prompt_c = "What are 3 fun things to do in SF?"
res_c = summarize(
fan_out([{"role": "user", "content": prompt_c}], None),
"C. PLAIN CHAT FAN-OUT (no tools — Together's home turf)",
)
agg_c = together_aggregate(prompt_c, res_c, aggregator, with_tools=False)
print(f"\n aggregated: {(agg_c['text'] or '')[:240]!r}")
record("fanout_chat", prompt=prompt_c, results=res_c, aggregate=agg_c)
print("\n" + "=" * 74)
print("recorded to fanout.jsonl")
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,196 @@
"""Record real open-model fan-out responses into a replay corpus.
Writes corpus.jsonl: one line per (prompt, draw), each holding every
worker's full response + observed latency. Feeds a Rust ScriptedBackend.
Workers get reasoning_effort=none (mirrors mesh-llm's
effective_enable_thinking_for_moa default) so reasoning models don't
burn the whole budget thinking.
"""
import json
import sys
from concurrent.futures import ThreadPoolExecutor
import orclient as oc
from probe_tools import TOOLS
DRAWS = 2
def fan_out(messages, tools, temperature=0.8):
def one(model):
resp, elapsed = oc.chat(
model,
messages,
tools=tools,
max_tokens=512,
temperature=temperature,
no_think=True,
)
text, tcs = oc.first_choice(resp)
ch = (resp.get("choices") or [{}])[0]
return {
"model": model,
"tier": oc.tier(model),
"elapsed": round(elapsed, 2),
"error": resp.get("error"),
"finish_reason": ch.get("finish_reason"),
"text": text,
"tool_calls": tcs,
}
with ThreadPoolExecutor(max_workers=len(oc.POOL)) as ex:
return list(ex.map(one, oc.POOL))
# ── Cases: agentic tool turns + plain chat ───────────────────────────
CASES = []
CASES.append(
{
"id": "agentic_explore",
"tools": True,
"messages": [
{
"role": "user",
"content": "I need to understand error handling in this Rust project. "
"Start by looking at what's in the src directory.",
}
],
}
)
CASES.append(
{
"id": "agentic_tool_result_chain",
"tools": True,
"messages": [
{"role": "user", "content": "Understand error handling in this Rust project."},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "list_dir", "arguments": '{"path": "src"}'},
}
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "main.rs\nlib.rs\nerror.rs\nconfig.rs\nnetwork/\ninference/",
},
],
}
)
CASES.append(
{
"id": "agentic_ambiguous",
"tools": True,
"messages": [
{"role": "user", "content": "Is this project's test suite passing?"}
],
}
)
CASES.append(
{
"id": "agentic_no_tool_needed",
"tools": True,
"messages": [
{"role": "user", "content": "What does the Rust `?` operator do?"}
],
}
)
CASES.append(
{
"id": "chat_factual",
"tools": False,
"messages": [{"role": "user", "content": "What is the capital of Japan?"}],
}
)
CASES.append(
{
"id": "chat_arithmetic",
"tools": False,
"messages": [
{"role": "user", "content": "A train leaves at 14:35 and arrives 2h50m later. What time?"}
],
}
)
# Pull a few real MT-Bench prompts if available.
try:
with open("../moe/prompts/mt-bench-8.jsonl") as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
continue
row = json.loads(line)
# This corpus uses OpenAI-shaped `messages`; older MT-Bench
# dumps use `turns`. Accept either.
first_user = None
for m in row.get("messages") or []:
if m.get("role") == "user":
first_user = m.get("content")
break
if first_user is None:
turns = row.get("turns") or []
first_user = turns[0] if turns else None
if not first_user:
continue
CASES.append(
{
# `id` is unique per row; `category` is NOT (all 8 rows
# in this corpus are "writing") so it must not be the key.
"id": f"mtbench_{row.get('id') or i}",
"tools": False,
"messages": [{"role": "user", "content": first_user}],
}
)
except FileNotFoundError:
print("(mt-bench not found, skipping)", file=sys.stderr)
def main():
out = open("corpus.jsonl", "w")
for case in CASES:
for draw in range(DRAWS):
results = fan_out(case["messages"], TOOLS if case["tools"] else None)
out.write(
json.dumps(
{
"case": case["id"],
"draw": draw,
"has_tools": case["tools"],
"messages": case["messages"],
"workers": results,
}
)
+ "\n"
)
out.flush()
ok = [r for r in results if not r["error"]]
tools_n = sum(1 for r in ok if r["tool_calls"])
empty_n = sum(1 for r in ok if not r["tool_calls"] and not (r["text"] or "").strip())
names = {
c["function"]["name"] for r in ok for c in (r["tool_calls"] or [])
}
lat = sorted(r["elapsed"] for r in ok)
print(
f"{case['id']:28s} draw={draw} ok={len(ok)}/{len(results)} "
f"tool={tools_n} empty={empty_n} distinct_tools={sorted(names)} "
f"lat={lat[0] if lat else 0}..{lat[-1] if lat else 0}s"
)
out.close()
print("\nwrote corpus.jsonl")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,297 @@
"""Record real multi-step agentic traces from open models on OpenRouter.
Unlike record.py (single-shot fan-out per case), this walks a scripted
agentic loop: fan out with tools, take the consensus tool call, feed a
canned tool result back, fan out again. That produces the multi-turn
shapes mesh-llm's MoA actually sees from goose/opencode.
Output: agentic.jsonl one line per (scenario, step, draw) with every
worker's full response, finish_reason, latency, and structured
tool_calls preserved (never flattened to text).
Thinking is disabled for every worker (mesh-llm MoA policy). Where an
endpoint rejects that (minimax: "Reasoning is mandatory"), orclient
retries without the flags so the worker still contributes.
"""
import json
import sys
from concurrent.futures import ThreadPoolExecutor
import orclient as oc
DRAWS = 2
MAX_TOKENS = 700 # headroom so long answers aren't truncated by default
# ── Agentic tool schemas, close to goose / opencode shapes ───────────
TOOLS = [
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List files in a directory",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Directory path"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file from disk",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Path to the file"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "search",
"description": "Search the repository for a regex pattern",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex to search for"},
"path": {"type": "string", "description": "Directory to search in"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "Run a shell command and return stdout",
"parameters": {
"type": "object",
"properties": {"cmd": {"type": "string", "description": "Command to run"}},
"required": ["cmd"],
},
},
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "Replace a string in a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"before": {"type": "string"},
"after": {"type": "string"},
},
"required": ["path", "before", "after"],
},
},
},
]
def fan_out(messages, tools, temperature=0.8, max_tokens=MAX_TOKENS):
"""Parallel fan-out. A dead worker is a recorded failure, never a dead turn."""
def one(model):
resp, elapsed = oc.chat(
model,
messages,
tools=tools,
max_tokens=max_tokens,
temperature=temperature,
no_think=True,
)
text, tcs = oc.first_choice(resp)
ch = (resp.get("choices") or [{}])[0]
usage = resp.get("usage") or {}
return {
"model": model,
"tier": oc.tier(model),
"elapsed": round(elapsed, 2),
"error": resp.get("error"),
"finish_reason": ch.get("finish_reason"),
"text": text,
"tool_calls": tcs,
"completion_tokens": usage.get("completion_tokens"),
"reasoning_tokens": (usage.get("completion_tokens_details") or {}).get(
"reasoning_tokens"
),
}
with ThreadPoolExecutor(max_workers=len(oc.POOL)) as ex:
return list(ex.map(one, oc.POOL))
def consensus_tool(workers):
"""Most-proposed (name, arguments) across workers, or None."""
counts = {}
for w in workers:
for c in w.get("tool_calls") or []:
key = (c["function"]["name"], c["function"].get("arguments") or "{}")
counts[key] = counts.get(key, 0) + 1
if not counts:
return None
return max(counts.items(), key=lambda kv: kv[1])[0]
# ── Scenarios: (id, opening user message, canned tool results) ───────
# `results` maps a tool name to the observation fed back when a worker
# calls it. Keeps the loop deterministic without a real filesystem.
SCENARIOS = [
{
"id": "explore_error_handling",
"user": "I need to understand error handling in this Rust project. "
"Start by looking at what's in the src directory.",
"results": {
"list_dir": "main.rs\nlib.rs\nerror.rs\nconfig.rs\nnetwork/\ninference/",
"read_file": (
"use thiserror::Error;\n\n"
"#[derive(Error, Debug)]\npub enum MeshError {\n"
' #[error("connection failed: {0}")]\n Connection(String),\n'
' #[error("model not found: {0}")]\n ModelNotFound(String),\n'
' #[error("timeout after {0}ms")]\n Timeout(u64),\n}\n'
),
"search": "src/error.rs:5:pub enum MeshError\nsrc/network/mod.rs:88:MeshError::Timeout",
"run_command": "(no output)",
},
"steps": 3,
},
{
"id": "failing_test_triage",
"user": "The test suite is failing. Find out which test fails and why.",
"results": {
"run_command": (
"running 42 tests\n"
"test routing::tests::picks_local_first ... FAILED\n\n"
"failures:\n---- routing::tests::picks_local_first stdout ----\n"
"thread 'main' panicked at src/routing.rs:214:\n"
"assertion `left == right` failed\n left: Remote(peer-2)\n right: Local(9337)\n"
),
"read_file": (
"pub fn pick_target(targets: &[Target]) -> Target {\n"
" // BUG: sorts remote-first\n"
" let mut t = targets.to_vec();\n"
" t.sort_by_key(|x| matches!(x, Target::Local(_)));\n"
" t[0].clone()\n}\n"
),
"search": "src/routing.rs:214: assert_eq!(pick_target(&targets), Target::Local(9337));",
"list_dir": "routing.rs\nmain.rs\nlib.rs",
},
"steps": 3,
},
{
"id": "add_feature_edit",
"user": "Add a `--verbose` flag to the CLI parser in src/cli.rs. Read it first.",
"results": {
"read_file": (
"#[derive(Parser)]\npub struct Cli {\n"
' #[arg(long)]\n pub port: u16,\n'
' #[arg(long)]\n pub model: Option<String>,\n}\n'
),
"list_dir": "cli.rs\nmain.rs\nlib.rs",
"edit_file": "edited src/cli.rs (1 replacement)",
"search": "src/cli.rs:2:pub struct Cli",
"run_command": " Compiling mesh-llm v0.1.0\n Finished dev profile",
},
"steps": 3,
},
{
"id": "ambiguous_is_it_passing",
"user": "Is this project's test suite passing?",
"results": {
"run_command": "test result: ok. 42 passed; 0 failed; 0 ignored",
"read_file": "[package]\nname = \"mesh-llm\"\n",
"list_dir": "Cargo.toml\nsrc/\ntests/",
"search": "tests/integration.rs:1:#[test]",
},
"steps": 2,
},
{
"id": "no_tool_needed_concept",
"user": "What does the Rust `?` operator do? Just explain, don't look at files.",
"results": {},
"steps": 1,
},
{
"id": "multi_tool_choice",
"user": "Find every place MeshError::Timeout is constructed in this repo.",
"results": {
"search": "src/network/mod.rs:88: MeshError::Timeout(elapsed)\n"
"src/inference/pipeline.rs:301: MeshError::Timeout(ms)",
"run_command": "src/network/mod.rs:88\nsrc/inference/pipeline.rs:301",
"list_dir": "network/\ninference/\nerror.rs",
"read_file": "// see MeshError::Timeout usage",
},
"steps": 2,
},
]
def main():
out = open("agentic.jsonl", "w")
for sc in SCENARIOS:
for draw in range(DRAWS):
messages = [{"role": "user", "content": sc["user"]}]
for step in range(sc["steps"]):
workers = fan_out(messages, TOOLS)
out.write(
json.dumps(
{
"scenario": sc["id"],
"draw": draw,
"step": step,
"has_tools": True,
"messages": messages,
"workers": workers,
}
)
+ "\n"
)
out.flush()
ok = [w for w in workers if not w["error"]]
n_tool = sum(1 for w in ok if w["tool_calls"])
n_trunc = sum(1 for w in ok if w["finish_reason"] == "length")
lat = sorted(w["elapsed"] for w in ok) or [0]
chosen = consensus_tool(ok)
print(
f"{sc['id']:26s} d{draw} s{step} ok={len(ok)}/{len(workers)} "
f"tool={n_tool} trunc={n_trunc} lat={lat[0]}..{lat[-1]}s "
f"chose={chosen[0] if chosen else None}",
flush=True,
)
if not chosen:
break # everyone answered in text; scenario is done
name, args = chosen
observation = sc["results"].get(name, "(no output)")
messages = messages + [
{
"role": "assistant",
"tool_calls": [
{
"id": f"call_{step}",
"type": "function",
"function": {"name": name, "arguments": args},
}
],
},
{
"role": "tool",
"tool_call_id": f"call_{step}",
"content": observation,
},
]
out.close()
print("\nwrote agentic.jsonl")
if __name__ == "__main__":
sys.exit(main())