fix(moa): route tool-result follow-ups to reducer, not fan-out

PR #566 review feedback (Apr 2026):

> The tool-result path isn't ready for agent loops:
> - A tool-result follow-up was treated like another fanout turn.
> - It wasn't handled like a controlled reducer/synthesis turn.
> - Tool results should be handled carefully and predictably, not
>   sprayed back through the whole fanout path.

`Session::classify_turn` only routed to `TurnType::ToolResult` when
the very last message had `role: "tool"`. Many agent harnesses send
the tool result followed by a short `user` nudge ("continue", "what
did you find?"). That landed at the very-last-message check as
`user`, so the gateway classified the turn as Continuation, fanned
out to all workers, and invited a worker to re-propose the same tool
call whose result was already in context.

The session-state fallback at `last_was_tool_call &&
has_unprocessed_tool_results` was dead code in production: the
gateway never invokes `record_assistant_response` between turns, so
`last_was_tool_call` is always false.

## Test (added first, observed failing)

`crates/mesh-mixture-of-agents/tests/sim_tool_result_routes_to_reducer.rs`
\u2014 three scenarios, all using mock backends that count calls:

* OpenAI canonical shape (last msg role=tool) \u2014 must classify as
  `ToolResult`, exactly one backend call (reducer only). Already
  passed pre-fix; pinned to prevent regression.
* Trailing-user-after-unsynthesised-tool-result \u2014 must also classify
  as `ToolResult`, exactly one backend call. **Failed pre-fix with
  `TurnKind::EarlyExit`** (fanned out, multiple worker calls).
* Plain fresh user question \u2014 must still fan out. Pins that we don't
  over-trigger the tool-result path.

## Fix

Scan messages from the end in `Session::classify_turn`:

* First message we hit with `role: "tool"` \u2192 classify as
  `ToolResult`. The tool result has not yet been synthesised by an
  assistant message after it.
* First message we hit with `role: "assistant"` \u2192 stop. The
  assistant has already spoken since the last tool result; the next
  turn is a normal continuation.
* Other roles (`user`, `system`) \u2192 keep scanning. A user nudge
  after an unsynthesised tool result still belongs in the
  reducer-only path.

If the scan reaches the start without hitting either, fall through to
the existing `Fresh`/`Continuation` classification.

## Validation

`cargo test -p mesh-mixture-of-agents` \u2014 70 unit + 5 integration
tests pass (this PR\u2019s 3 new tests + the two earlier sim files).

`cargo test -p mesh-llm-host-runtime --lib` \u2014 1434/1434 pass.

`cargo clippy -p mesh-mixture-of-agents --all-targets -- -D warnings`
\u2014 clean.

`cargo fmt --all -- --check` \u2014 clean.
This commit is contained in:
Michael Neale 2026-05-20 17:13:36 +10:00
parent a396ab1ed9
commit 000ae50cf6
2 changed files with 306 additions and 5 deletions

View file

@ -120,15 +120,40 @@ impl Session {
}
/// Classify what kind of turn this is.
///
/// Anything that ends with an unsynthesised tool result is a
/// `ToolResult` turn: the gateway must skip fan-out and go
/// straight to the reducer, so the workers don't re-broadcast
/// the same tool call whose result we already have in context.
///
/// We scan from the end of the conversation backwards. The first
/// message we hit decides:
///
/// * `role: "tool"` first — OpenAI canonical: classify as
/// `ToolResult`.
/// * `role: "assistant"` first — the assistant has already
/// spoken since the last tool result. Hand the next turn
/// to fan-out normally.
/// * `role: "user"` first — keep scanning past it. A user
/// nudge after an unsynthesised tool result is still a
/// tool-result turn; the model needs to consume the tool
/// output and answer the nudge in one synthesis pass. A
/// user message that *predates* any tool result reaches the
/// start of the history and we fall through to the normal
/// Fresh/Continuation classification.
pub fn classify_turn(&self) -> TurnType {
// If the last message is a tool result, this is a tool-result turn
if let Some(last) = self.messages.last() {
if last.get("role").and_then(|r| r.as_str()) == Some("tool") {
return TurnType::ToolResult;
for msg in self.messages.iter().rev() {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
match role {
"tool" => return TurnType::ToolResult,
"assistant" => break,
_ => continue,
}
}
// Also check if we just got tool results back after our tool_call
// Also check the session-state fallback (set by
// record_assistant_response, which the gateway doesn't invoke
// yet but tests do).
if self.last_was_tool_call && self.has_unprocessed_tool_results() {
return TurnType::ToolResult;
}

View file

@ -0,0 +1,276 @@
//! Pin the tool-result turn classification.
//!
//! Background — PR #566 review feedback (Apr 2026):
//!
//! > The tool-result path isn't ready for agent loops:
//! > - A tool-result follow-up was treated like another fanout turn.
//! > - It wasn't handled like a controlled reducer/synthesis turn.
//! > - Tool results should be handled carefully and predictably, not
//! > sprayed back through the whole fanout path.
//!
//! When the conversation has a recent tool result that has not yet
//! been synthesized into an assistant answer, the gateway must take
//! the reducer-only path (`TurnKind::ToolResult`), not fan-out to all
//! workers. Fanning out wastes a round-trip per worker, drowns the
//! reducer in worker outputs that ignore the tool result, and \u2014 most
//! dangerously \u2014 invites a worker to re-propose the same tool call
//! whose result we already have in-context.
//!
//! Two shapes of agent conversation must classify as ToolResult:
//!
//! 1. **OpenAI canonical shape.** The last message has role `tool`
//! (the harness sent the tool result and expects the next assistant
//! turn to interpret it). This is the simplest and most explicit
//! shape. Classifying this is straightforward.
//!
//! 2. **Trailing-user shape.** The conversation ends with assistant
//! tool_calls + tool result + a user message that just nudges
//! ("continue", "what did you find?"). The harness has left the
//! tool result in-context for the model to consume. There is no
//! new tool result in *this* turn, but the previous one was never
//! synthesized into an assistant message. Today this classifies
//! as `Continuation` and fans out \u2014 wrong per the review.
//!
//! This file pins both shapes.
use async_trait::async_trait;
use mesh_mixture_of_agents as moa;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// Backend that records every call it receives so the test can assert
/// fan-out did or did not happen.
struct RecordingBackend {
text: String,
delay: Duration,
calls: AtomicUsize,
}
impl RecordingBackend {
fn new(text: impl Into<String>) -> Arc<Self> {
Arc::new(Self {
text: text.into(),
delay: Duration::from_millis(10),
calls: AtomicUsize::new(0),
})
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl moa::ModelBackend for RecordingBackend {
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);
tokio::time::sleep(self.delay).await;
Ok(json!({
"choices": [{"message": {"content": self.text}}],
}))
}
}
fn config_with_three_recording_workers() -> (
moa::GatewayConfig,
Arc<RecordingBackend>,
Arc<RecordingBackend>,
Arc<RecordingBackend>,
) {
let fast = RecordingBackend::new("synthesised: README says 'Hello World'");
let mid = RecordingBackend::new("synthesised: README says 'Hello World'");
let strong = RecordingBackend::new("synthesised: README says 'Hello World'");
let backends: Vec<Arc<dyn moa::ModelBackend>> = vec![fast.clone(), mid.clone(), strong.clone()];
let models = vec![
moa::ModelEntry {
name: "fast-3b".into(),
backend_index: 0,
},
moa::ModelEntry {
name: "mid-13b".into(),
backend_index: 1,
},
moa::ModelEntry {
name: "strong-32b".into(),
backend_index: 2,
},
];
let config = moa::GatewayConfig {
backends,
models,
worker_timeout: Duration::from_secs(2),
hedge_delay: Duration::from_millis(50),
reducer_timeout: Duration::from_secs(2),
};
(config, fast, mid, strong)
}
fn read_file_tool() -> Value {
json!([{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
}
}
}])
}
#[tokio::test]
async fn last_message_role_tool_classifies_as_tool_result() {
// OpenAI canonical shape. This is already supposed to work today
// and pins the existing behavior so we don't regress when we
// tighten the "trailing user" shape below.
let (config, fast, mid, strong) = config_with_three_recording_workers();
let body = json!({
"model": "mesh",
"tools": read_file_tool(),
"messages": [
{"role": "user", "content": "Read README.md"},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": "{\"path\":\"README.md\"}"}
}]
},
{"role": "tool", "tool_call_id": "call_1", "content": "# Hello World\n"},
],
"max_tokens": 64,
});
let result = moa::handle_turn(&config, &body).await;
assert_eq!(
result.turn_kind,
moa::TurnKind::ToolResult,
"last-msg-role=tool must classify as TurnKind::ToolResult; got {:?}",
result.turn_kind
);
assert!(
result.reducer_used,
"tool-result turn must invoke the reducer"
);
// No fanout: only the reducer (a single backend in the candidate
// ladder) should have been called.
let total_calls = fast.calls() + mid.calls() + strong.calls();
assert_eq!(
total_calls,
1,
"tool-result turn must not fan out — expected 1 reducer call, got {total_calls} \
(fast={}, mid={}, strong={})",
fast.calls(),
mid.calls(),
strong.calls()
);
}
#[tokio::test]
async fn trailing_user_after_unsynthesised_tool_result_classifies_as_tool_result() {
// The bug from the PR review. The harness has appended a `user`
// message AFTER an unsynthesised tool result. The last message is
// now `user`, not `tool`. The gateway today classifies this as
// Continuation and fans out to every worker — wasting a round-trip
// per worker and risking duplicate tool calls. It must instead
// take the reducer-only path: synthesize the tool result, address
// the user nudge, return one coherent response.
let (config, fast, mid, strong) = config_with_three_recording_workers();
let body = json!({
"model": "mesh",
"tools": read_file_tool(),
"messages": [
{"role": "user", "content": "Read README.md and tell me what it says."},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": "{\"path\":\"README.md\"}"}
}]
},
{"role": "tool", "tool_call_id": "call_1", "content": "# Hello World\n"},
// Harness leaves the tool result in-context and asks the
// model to continue. There is NO new assistant message
// synthesizing the tool result yet.
{"role": "user", "content": "Go on."},
],
"max_tokens": 64,
});
let result = moa::handle_turn(&config, &body).await;
assert_eq!(
result.turn_kind,
moa::TurnKind::ToolResult,
"trailing-user after unsynthesised tool result must classify as \
TurnKind::ToolResult to avoid spraying through fanout; got {:?}",
result.turn_kind
);
assert!(
result.reducer_used,
"unsynthesised-tool-result turn must invoke the reducer"
);
let total_calls = fast.calls() + mid.calls() + strong.calls();
assert_eq!(
total_calls,
1,
"tool-result follow-up must not fan out — expected 1 reducer call, got \
{total_calls} (fast={}, mid={}, strong={})",
fast.calls(),
mid.calls(),
strong.calls()
);
}
#[tokio::test]
async fn fresh_user_question_still_classifies_as_fan_out() {
// Counterpart: a fresh conversation with just a user message must
// continue to fan out. We must not over-trigger the tool-result
// path and drop fan-out for normal questions.
let (config, fast, mid, strong) = config_with_three_recording_workers();
let body = json!({
"model": "mesh",
"messages": [
{"role": "user", "content": "What is the capital of Japan? One word only."},
],
"max_tokens": 32,
});
let result = moa::handle_turn(&config, &body).await;
assert!(
matches!(
result.turn_kind,
moa::TurnKind::Fanout | moa::TurnKind::EarlyExit
),
"fresh user question must fan out (Fanout or EarlyExit); got {:?}",
result.turn_kind
);
let total_calls = fast.calls() + mid.calls() + strong.calls();
assert!(
total_calls >= 1,
"fresh user question must reach at least one worker; got 0 calls"
);
}