A research story about voice agents, background supervisors, and the unromantic moments that change a research direction.
A real-time voice agent has to make a choice on every turn that doesn't actually have a good answer:
Use a small fast model, and you keep the response latency within the few hundred milliseconds that voice conversations demand — but the agent fumbles whenever it needs to reason, retrieve a document, or follow a multi-step procedure.
Use a large smart model, and the agent is competent — but the user spends five to fifteen seconds in awkward silence between every utterance. People hang up.
The standard production response is to compromise: a medium-sized model, a thin layer of retrieval-augmented generation, perhaps some routing logic that escalates to a stronger model for hard cases. It works adequately for simple flows. It does not scale to the kind of regulated, multi-step conversations that real businesses actually need handled — credit-card activation, insurance renewal, medical scheduling, customer support over policy documents.
The research project this post is about started from a sharper version of that observation. What if we treated the latency–intelligence trade-off as a two-system problem rather than a one-model problem?
The pattern itself isn't new. It comes from robotics. In Asynchronous Multi-Level Decomposition (AsyncMLD), the fast low-level controller acts in real time while a slow high-level planner refines the plan asynchronously, with a shared blackboard mediating between them. The robot's body never has to wait for the brain.
That maps very cleanly onto voice agents if you let it:
The voice agent stays on its real-time budget because it never waits on the planner or on external I/O. Slow work — search, reasoning, retrieval — happens during the natural gap between user utterances. When the supervisor's predictions are correct, the response feels instant. When wrong, the agent falls back to its built-in behaviour — no worse than today.
Our research question was straightforward to ask and considerably less straightforward to answer:
Given the conversation history and all available signal — declared SOPs, classified user disposition, retrieved precedents — can the supervisor predict the next one to three turns of the conversation well enough that it can usefully pre-fetch the data and pre-stage the instructions that the weak agent will need?
We implemented the supervisor around a planner inspired by the PCA-M paper — Planning-based Conversational Agents with Monte Carlo Tree Search. The core idea is to treat the agent's next action selection as a planning problem: enumerate candidate actions, simulate forward via rollouts, pick the one whose imagined-future trajectory looks best.
The pieces we shipped over the first few weeks:
Everything ran against three seed scenarios: credit-card activation, car insurance renewal, and medical appointment booking. Each scenario has 7-13 declared agent actions, 7 cohorts (each with 3-4 mood variants), 6 data dependencies with realistic 0.4-7 second mocked latencies.
The first end-to-end measurement was encouraging in a way that turned out to be slightly misleading:
| Session | Outcome | Off+1 hit rate | Off+2 hit rate | Latency hidden |
|---|---|---|---|---|
| Baseline (no mood) | abandoned | 50% | 50% | 16.8 s |
| Mood-aware | success | 100% | 100% | 16.8 s |
| Mood + temperature 1.05 | success | — | 100% | 21.1 s |
We pushed to a five-session replication across all three SOPs. The data-prefetch result — confirming that speculative fetching worked at a production-relevant rate — came in at 60-72% offset+2 hit rate across SOPs, with a mean of 25-110 seconds of latency hidden per session.
By that measurement, the architecture was working. The supervisor was earning its keep.
The other half of milestone (A) — could the supervisor predict the user's state at the next turn well enough to drive top-K hedging — produced one of those results that's intellectually annoying because it's correct.
We measured prediction accuracy as: at each turn, take the modal user state across 8 parallel rollouts; check if it matches what the simulated user actually said next.
| Category | Definition | Accuracy |
|---|---|---|
| Stable turns | actual state = prior turn's state | 100% (13/13) |
| Transition turns | actual state ≠ prior turn's state | 0% (0/8) |
| Blended | all turns | 62% |
The headline 78% accuracy from an earlier session, it turned out, was an artifact of having mostly stable phases. The predictor was perfect when the conversation didn't change, and useless when it did. Modal aggregation of MCTS rollouts is a great predictor of inertia and a catastrophic predictor of change.
This is exactly the moment in conversational AI that matters most. Users transition — they shift from informational to anxious, from comparison-shopping to ready-to-commit. Transition turns are where the supervisor's predictions have the most leverage and where ours had none.
We tried two interventions to fix it: temperature increases on the user-simulator (more sampling diversity) and per-rollout mood injection (sample a disposition at rollout start, fix it for that rollout's duration so different parallel rollouts get different moods). Neither moved the metric.
Investigating why, we found a subtler pattern: mood diversity was working, but the state classifier was washing it out at depth 1. The rollouts were producing visibly different user text per mood ("I'm really anxious about the premium" vs "I'm ready to finalize the renewal"), but the state classifier compressed those into the same state label because the SOP's state vocabulary was descriptive, not affective. Mood-driven divergence only became visible at depth 2 — once the previous user-sim's text had had a chance to shift the agent's action, which the user then reacted to differently.
That meant the offset+1 mispredictions weren't a bug to fix. They were an inherent property of the rollout structure with the state vocabulary we'd designed. The honest research conclusion was disappointing in its precision: offset+1 will keep showing low transition accuracy; the work the architecture actually pays off on lives at offset+2 and +3, where mood-driven divergence has had time to compound.
Even though mood diversity didn't fix offset+1, it did something we hadn't expected.
We had given each cohort a small vocabulary of 3-4 "moods" — sample-able dispositions within the cohort's identity. A PriceShopper cohort could be in mood calm_negotiator (40% prior), frustrated_with_quotes (30%), indifferent_to_brand (20%), or skeptical_of_value (10%). At rollout start we'd draw one mood from the prior, fix it for that rollout, and inject it into the user-sim prompt.
The intended effect was diversity at depth 1. The actual effect was much more interesting at depth 2 and 3:
| Intervention | Distinct states predicted at offset+2 | Distinct states at offset+3 |
|---|---|---|
| Baseline (no mood) | 1.59 | 1.88 |
| Mood diversity | 3.00 (+89%) | 3.31 (+76%) |
Almost a doubling. Mood didn't fix immediate transitions, but it produced genuinely diverse multi-turn future predictions where the supervisor had been emitting near-deterministic ones. And that diversity translated directly into prefetch wins at deeper offsets, where the supervisor has more time to overlap external-data work with the user's natural conversation pauses.
We added the runtime side too: a classifier emits a (cohort, mood, state) tuple for each real turn, persisted to the precedent database, used to sharpen retrieval and conditioning over time. Zero added LLM calls — we folded mood into the existing cohort/state classifier prompt.
By this point the data layer had a respectable track record on action-keyed prefetch. "At turn N, prefetch the data dependencies of the action we predict at turn N+1." Works well for static lookups: customer records, policy documents, the kind of data that doesn't depend on what the user is asking.
But real production voice agents — actual ones, in production — don't make static lookups. They make questions. The user says, "Is windshield damage covered if I park on the street?" and the agent needs a RAG hit on policy docs that answer that specific question. The same SOP action covers a thousand different user questions; predicting only the action doesn't tell you what to query.
We extended the architecture: every data dependency could optionally declare a query template with Python str.format placeholders:
{
"name": "claims_history_rag",
"kind": "rag",
"query_template": "{mood} customer in cohort {cohort} (state {state}) considering
action {action} just said: {user_text}. What claims-history facts would help
the agent respond?"
}
The {user_text} slot is where it got interesting. The MCTS rollouts had been simulating user replies all along — generating natural-language text the imagined user would say in each rollout. We had been discarding that text after each rollout finished. The query-aware design made it load-bearing: the rollout's predicted user utterance becomes the query that drives the RAG retrieval.
We hand-curated a 25-document insurance corpus, built a real RAG fetcher with semantic search, ran the verification. The plumbing worked end-to-end. The numbers were honest and mixed:
| Criterion | Committed threshold | Observed | Verdict |
|---|---|---|---|
| Predicted-vs-live query embedding cosine | ≥ 0.70 mean | 0.547 mean | Fail |
| Top-3 doc Jaccard overlap | ≥ 0.50 mean | 0.157 mean | Fail |
| Mechanism functional end-to-end | working | working | Pass |
| ≥ 1 doc shared between predicted and live RAG | (not committed) | 60% of queries | Partial |
We had committed those thresholds before seeing the data. The honest read: the supervisor's predicted queries are related to what the user actually asks (60% of the time the predicted RAG and live RAG both returned at least one of the same documents), but not identical enough for an exact-match cache to dominate.
For about a day we treated this as a failure. Then someone — credit to the right person — asked the obvious question that, in retrospect, should have been obvious sooner.
The question was: "If the problem is that we can't predict exact cache keys, why are we using exact cache keys at all? The supervisor is smart. Why not give it all the cached data and ask it which items are useful for the current turn?"
This is the kind of question that makes you realize the architecture you've been building is shaped by an unexamined assumption. We had been treating the cache as a lookup table — predict the key precisely, look up the value. But the supervisor was already doing per-turn LLM work for classification. Adding one more small LLM call to curate the cache contents was cheap. And it solved the entire matching problem in one move.
Failed predictions don't waste cache entries any more, because there's no "wrong key" — every fetched item stays in the pool as a candidate. The supervisor at turn N+1 sees the pool, sees the live user message, and picks. The mispredicted action that fetched claims_history_rag at turn 3 stays available at turn 8 when the live user actually does ask about claims, even though the original prediction said it would be needed at turn 5.
We measured this against the previous key-lookup baseline on the same five sessions:
| Pre-committed criterion | Threshold | Observed | Verdict |
|---|---|---|---|
| Effective hit rate vs key-lookup | > +20 pp | +84 pp (96% vs 11%) | Pass |
| Rerank p95 latency | < 800 ms | 2441 ms | Fail |
96% effective hit rate. Sixty-five of sixty-eight rerank calls picked at least one useful item. The query-aware prefetch's "60% partial overlap" signal had been telling us this all along — the data was useful; the cache architecture just couldn't surface it.
The latency miss was structural: the pool grew to its 30-item cap and the rerank prompt carried all 30 summaries, which the fast model took about 2.4 seconds to process. So we set out to fix it.
The hardest research moment of the project wasn't a measurement that failed. It was the realization that the question we'd been measuring was the wrong question.
The intended fix was straightforward. Embedding similarity is cheap. Compute the cosine between the live user message and each pool summary, keep the top 8, send only those to the rerank LLM. Input tokens fall from 6-8K to ~1.8K. Latency should follow.
The first measurement said otherwise.
| Metric | Pre-fix (all 30 items) | Input filter (top-8 by cosine) |
|---|---|---|
| Input tokens to rerank | ~6-8K | ~1.8K |
| Mean rerank latency | ~1900 ms | 1698 ms |
| p95 rerank latency | 2441 ms | 2452 ms |
Input-token reduction did almost nothing for tail latency. The fast model's call cost is dominated by the LLM call itself, not by the size of the prompt — at this scale the prompt processing is already fast and the generation+RTT bucket isn't sensitive to a ~4× input shrink. We had been optimizing the wrong term.
The reframe: if pre-filtering the pool reduces the rerank to a routine top-K selection, the LLM has nothing meaningful left to decide on the easy turns — we should just skip it. So we built a second intervention. After computing the cosines, check whether the embedding ranking is decisive enough that the LLM rerank would be a no-op: K-th item must clear an absolute floor (0.50), and the gap between K-th and (K+1)-th must exceed a confidence margin (0.05). When both clear, return the top-K by cosine and skip the LLM call. We expected 60-80% adoption on a 5-session re-run.
The adoption rate was 0%.
So we instrumented one level deeper. For every rerank event in the run, reconstruct the pool, embed everything, and look at the distribution of cosines and gaps that the LLM was actually choosing among:
| Quantity | Required by gate | p50 | p90 | Max |
|---|---|---|---|---|
| K-th cosine (K=3) | ≥ 0.50 | 0.336 | 0.398 | 0.421 |
| Gap K-th vs (K+1)-th | ≥ 0.05 | 0.013 | 0.078 | 0.101 |
| Pool top-8 cosines | — | 0.303 | 0.414 | 0.641 |
Pool items are conversationally adjacent — they're all about the same renewal, the same customer, the same SOP. Cosines bunch in a low band; there are no clear winners. The gate concept assumed the supervisor would face the occasional "obvious top-3" turn that didn't need an LLM. The data says every turn is a close call.
But there's a more interesting finding hiding in the same data. We compared what the LLM actually picked against what the embedding ranking would have picked:
A concrete example from the run — turn 6 of session 42f9e1b2afda, where the LLM and the pure-cosine ranking diverged:
The right intervention isn't the gate we built. It's the opposite shape: always skip the LLM, take top-3 by cosine after a dedup pass; only fall back to the LLM in narrow conditions (e.g., when the top-3 contains near-duplicates the dedup couldn't resolve). The original "skip when confident" framing was answering is the signal good enough. The data says the signal is good enough on average; the question is where does the LLM add unique value, and the honest answer turns out to be a smaller surface than we'd assumed.
The input-filter pre-pass did deliver a real but modest latency reduction (mean 1900 → 1523 ms, p95 2441 → 2167 ms across the confidence-gate sessions, with the gate firing zero times). That ~10% comes from the smaller prompts and variance — the gate logic itself was inert.
The final design removed the rerank LLM from the critical path entirely. Compute cosines, dedup near-duplicates, take top-3, return. Five new sessions, same SOP, same MCTS config:
| Metric | LLM rerank | Cosine + dedup | Delta |
|---|---|---|---|
| Mean rerank latency | 1523 ms | 378 ms | −75% |
| p95 rerank latency | 2167 ms | 940 ms | −57% |
| Effective hit rate | 95% | 100% | +5 pp |
| Session success rate | 5/5 | 3/5 | regression |
The latency win was clean. The success rate dropped from five out of five to three. Two abandoned sessions, both fitting the same shape: the user winds down with pleasantries — "Thank you for your help, I'll review the options and get back to you" — and the agent, instead of closing, keeps offering discounts and reviewing coverage. The conversation loops politely until the turn cap.
The cause was visible in the data once we looked at the right thing. On those closing turns, the top pool cosine fell to 0.10–0.18 — there was nothing in the pool actually about what the user just said, because pleasantries don't match prefetched insurance data. But the cosine+dedup rerank still picked three items and injected them into the response prompt. The agent dutifully responded about the items it was given.
The reflex was to add a cosine floor: if top-cos < 0.20, return zero picks. It would have worked. But it was the wrong shape of intervention — a brittle heuristic on top of another brittle heuristic, with a threshold to tune.
The actual problem was that the response-generation prompt was lying to the agent. The label on the prefetched context block read PREFETCHED CONTEXT (curated by supervisor — high relevance to current turn). That's how it had been framed since the pool's first day. The label was reasonable when there was an LLM rerank deciding which items to attach; it stopped being reasonable when the cosine-only rerank began attaching three items by similarity alone. The label kept claiming the items were curated and high-relevance. Sometimes they were neither.
An async architecture's predictions are speculative by definition — the supervisor decides what to fetch before knowing what the user will actually say. The blackboard surface is a suggestion, not a directive. The response prompt should say so:
PREFETCHED CONTEXT (speculatively pre-staged — may or may not fit this turn):
The supervisor pre-fetched these items based on predictions about what the user
might ask. They may help your reply, or they may be off-topic — especially when
the user is wrapping up, making small talk, or asking about something
unanticipated.
Evaluate each item against what the user just said. Use what is relevant; ignore
what isn't. If nothing fits the current message, reply naturally as if no context
was provided — do NOT force irrelevant data into the response.
· [POLICY — VerifyIdentity] #INS-882431 expires 2026-06-15…
· [DISCOUNTS — PitchDiscount] multi-policy 8% bundle eligible…
· [MARKET — DiscussPricing] peer_avg $1580/yr · Geico $1510…
Five more sessions. Same SOP. Same cosine + dedup rerank. Only the prompt changed.
| Metric | Curated framing | Speculative framing | Delta |
|---|---|---|---|
| Mean rerank latency | 378 ms | 320 ms | −15% (noise) |
| p95 rerank latency | 940 ms | 429 ms | −54% |
| Effective hit rate | 100% | 100% | — |
| Session success rate | 3/5 | 4/5 + 1 cap-hit | recovered |
4 of 5 closed with success. The fifth was making productive progress through coverage adjustments and a loyalty bonus and hit the 20-turn cap on the literal turn the user said "I'm ready to renew." Unrelated to rerank — an action-proposer / turn-budget question, on the list for a separate look.
The p95 latency dropped further too, which we don't fully understand at N=5 — possibly OpenAI embedding RTT variance, possibly real signal. The headline is that cosine + dedup, with the speculative-context prompt, clears the pre-committed 800 ms target at 429 ms p95, with no quality regression.
What we're taking from this stretch:
By this point the whole architecture leaned on a quietly expensive component: background MCTS pondering. Between turns, the supervisor was running Monte Carlo tree search over the SOP — simulating future user states, mood-diverse rollouts, the works — to predict what the user would need next and prefetch it. It's the most intellectually satisfying part of the system. So naturally, it was the part we'd never actually measured the value of.
The uncomfortable question: prefetching is fundamentally a prediction problem — predict the next agent action (which names the data to fetch), predict the query. Does an MCTS planner predict those better than something dumb and cheap? Or were we paying ~25,000 tokens and ten seconds a turn for a clever method that a database query beats?
We split "retrieval prediction" into the three things it actually is:
Then we measured each, head-to-head, against three predictors: MCTS, a cheap single LLM call, and the boring empirical predictor — a SQL count over past sessions: "last time the agent was in this exact (cohort, state, action), what did it do next?"
Sub-task one — predicting the next action. Scored against ground truth on 50 real turn-transitions:
| Predictor | recall@3 | latency | tokens/turn |
|---|---|---|---|
| empirical (count over traces) | 88% | ~1 ms | 0 |
| naive LLM (one call) | 38% | 1.6 s | ~1.6K |
| MCTS pondering | 33% | 10.6 s | ~25.6K |
Sub-task three — the generative query. Here counting genuinely can't help; you need to predict the user's next sentence. So this should be MCTS's moment — its rollouts do generate simulated user utterances. We rendered the RAG query three ways and measured retrieved-document overlap against the query built from the user's actual next utterance. And because we suspected the answer depends on how fine-grained the knowledge base is, we ran it across three corpus sizes:
| {user_text} source | coarse (25 docs) | fine (44) | large (63) | tokens |
|---|---|---|---|---|
| structured stub (no prediction) | 70% | 48% | 43% | 0 |
| cheap LLM (one call) | 73% | 62% | 54% | ~300 |
| MCTS rollouts | 60% | ~60% | ~60% | ~25.6K |
Two things jumped out. First, on a coarse corpus — one doc per topic — even the no-prediction stub hits 70%, because the structured slots (cohort, state, action) already pick the right document. The generative query barely matters. But as the corpus gets finer — many documents sharing a topic, differing only by the specific situation ("teen driver" vs "spouse" vs "elderly parent") — the stub collapses (70 → 43%) while the cheap LLM holds an 11–14 point lead. The value of generative query prediction scales with how fine-grained your knowledge base is.
Second, and this is the punchline: the cheap single LLM call matched MCTS's rollout-based prediction — at roughly 1/85th the token cost. MCTS never won. Not on actions, not on queries.
There's a nice external echo here: a concurrent paper, PASTE, independently found that pattern-mined speculation from execution traces beats search-based lookahead for deciding what to pre-execute. Different domain (tool calls during reasoning vs data prefetch during speech), same lesson: for "what will this system do next," remembering beats simulating.
The honest caveats: the fine and large corpora are constructed stress-tests (realistic situation-variants, but we built them, not production data). And MCTS isn't dead — it keeps a role for cold start (when no traces have accumulated yet, there's nothing to count) and for its cached-decision payoff. But for warm-state retrieval prediction, the clever planner lost to a SQL query and a 300-token nudge. We kept it in the architecture and pointed the recommended config away from it.
We built the supervisor to guess what the user will need next and fetch it early. Good guesses feel like magic — the answer is already sitting in the pool when the agent reaches for it. But guessing means sometimes guessing wrong, and a wrong guess shows up as a stall: the agent reaches into the pool, finds nothing, and has to stop and fetch the data live while the user waits.
So the fair question is: across a whole run, how much waiting did our guessing fail to prevent?
There's a clean way to measure that, borrowed from a classic idea called regret (a reviewer nudged us toward it — it comes from the multi-armed bandit literature). Imagine a perfect oracle that knows the future and prefetches everything correctly — it never stalls. Regret is just the gap between that oracle and us: the total seconds of "oops, fetch it live" stalls we didn't manage to hide.
We added up every one of those live-stall fetches across the cross-SOP run — three procedures (car insurance, credit card, medical booking), five conversations each. Here's what came out:
| Across 15 conversations, 3 SOPs | |
|---|---|
| Dead air hidden by prefetch (the magic working) | 738.1 s |
| Times we guessed wrong (live stalls) | 9 |
| Cost of those misses — our realized regret | 34.7 s |
| Share of the waiting we erased | 95.5% |
Put together: we erased 95.5% of the waiting. The 4.5% we left on the table is the price of guessing wrong.
And here's the part that surprised us. We worried that counting misses might be misleading — surely a big document search costs more to miss than a quick database lookup, so a simple miss-count would hide the real pain. We checked. In these procedures, every fetch costs about the same — 3.86 seconds whether we caught it or missed it. A hit saves 3.86 s; a miss costs 3.86 s. So our simple "what fraction of turns had the data ready?" number isn't cutting any corners — it lines up with the seconds-of-stall number almost exactly (4.5% either way).
So: the guessing paid off. Out of roughly twelve and a half minutes of fetching that could have been dead air, we hid all but 35 seconds of it.
The last section ended on a promise: the natural next step is a guesser that learns as it goes — and that's where the textbook "slot machine" idea (multi-armed bandits, the thing a reviewer nudged us toward) is supposed to earn its keep. So we built it. And it taught us something we didn't expect.
There are really two different things tangled up in "learn as you go," and pulling them apart is the whole story:
To measure honestly we did the obvious thing: train on the past, test on the future. Order every conversation by time, hide the most recent fifth as a test set, and feed the guesser more and more history — then watch how well it predicts the held-out future as it learns. Here's the curve:
Two things to read off it. First, the learning pieces are a real win exactly when you'd hope — at the start. With only 10% of the history, decay+shrinkage lifts the guesser from 37% to 49% — a 12-point head start, because leaning on the SOP-wide average beats trusting almost no data. That lead shrinks as evidence piles up, and by the time the guesser is warmed up the two lines sit on top of each other (~73%). It helps when it's cold and gets out of the way when it's warm. That's exactly the behavior that lets us turn it on by default and forget about it.
Second — the punchline — the "try things" part added nothing. The dashed line (with Thompson exploration switched on) sits exactly on the shipped line, even at the cold-start left edge where exploration is supposed to matter most: 49.4% either way.
Which is the same lesson MCTS taught us, one rung up the ladder: the clever, expensive mechanism — this time deliberate exploration — lost to the boring one. We shipped the boring one (decay + shrinkage), kept the clever one built-but-off, and have a graph that says exactly why.
The data-prefetch half of the architecture is now confirmed across all three seed SOPs at five sessions each:
Instruction prefetch — pre-generating the agent's actual response text for predicted future turns — is the next big test. The mechanism exists (the same response-generation call we use today); the question is whether the supervisor's lookahead is sharp enough that the pre-staged response is close enough to what the live agent would produce that the voice agent can use it verbatim. The architecture supports it naturally: pre-staged responses simply join the pool as kind=instruction items alongside kind=data items, and the same rerank decides between them.
The honest open items:
tier3_enabled=True config; re-verification under the locked no-live-MCTS configuration is the next measurement.What we set out to ask is now answerable with measurements rather than intuition:
Can the supervisor predict the next 1-N turns of conversation well enough to pre-process the data and instructions that the weak voice agent will need?
For data: yes, at production-relevant rates, validated across three scenarios.
For instructions: the mechanism is built and the architecture supports it; we haven't tested it end-to-end yet.
This work was built standing on a few people's shoulders:
The pieces that we think are our own contribution rather than borrowed:
The full dated research notes, including the failed interventions and the unflattering data, are part of the project's archive. We left them in because the path matters as much as the destination, and the path here had a lot of useful wrong turns.