Concepts & Mental Model¶
This page is the mental model for simulation — why it exists, how a simulated conversation actually runs turn by turn, how scenarios are written or generated, how transcripts are judged, and how simulation closes the loop into a better agent. Read it first, then use the Simulation reference for the API.
Why simulate¶
Single-turn evaluation answers "given this input, is the output good?" But real
agents hold conversations — they ask follow-ups, handle a user who changes
their mind, and can go wrong on turn three even when turn one looked fine. You
can't capture that with a fixed input → output dataset, and you can't wait for
real users to hit every edge case (adversarial, confused, impatient) before you
ship.
Simulation fills that gap: an LLM role-plays a user, holds a multi-turn conversation with your agent, and a judge scores the whole transcript against natural-language criteria. It's how you stress-test behavior over a dialogue, with coverage you generate rather than wait for.
Where it fits¶
Simulation is one of three testing tools, each for a different shape of test:
| Tool | Scores | Shape |
|---|---|---|
evaluate() |
fixed input → output pairs |
single-turn, over a dataset |
simulate() |
a multi-turn conversation vs. criteria | synthetic dialogue |
Replay |
a past trace, fork-and-rerun | debugging a real run |
Reach for evaluate when you have golden input/output cases, simulate when
the behavior only emerges over a conversation, and replay when you're
debugging something that already happened. Simulation reuses your existing
Agent, LLMClient, and LLMJudge — there's no new infrastructure.
The simulated-conversation model¶
A scenario is a conversation between a simulated user and your agent,
judged at the end. The loop (_run_scenario in fastaiagent/eval/simulate.py):
- The user opens. The simulated user speaks first (turn 0) — agents are reactive, so someone has to start.
- The agent replies. Your agent responds to the transcript so far (fed via
the additive
messages=param, so multi-turn context is preserved). - The user responds. The simulated user produces the next message from the full transcript — or ends the conversation.
- Repeat until the user stops or
max_turnsis hit. - Judge once. An
LLMJudgescores the whole transcript against the scenario's criteria — one judge call per criterion.
user (turn 0) ─▶ agent (1) ─▶ user (2) ─▶ agent (3) ─▶ … ─▶ [max_turns]
│
judge full transcript vs criteria
Verified against a live run
A persona scenario (max_turns=4) produced exactly user → agent → user →
agent and a scripted scenario ran its 3 fixed user messages across 6 turns
— user on the even turns, agent on the odd. The judge returned one verdict
per criterion, and the overall pass was "every success criterion holds AND
no failure criterion holds."
Two kinds of simulated user¶
A SimulatedUser takes exactly one of:
persona— a natural-language description; an LLM role-plays the user, generating each turn from the transcript so far. It's prompted to write only the next user message, and to replyENDwhen its goal is met — which stops the conversation early.script— a fixed list of user messages, returned one per turn; the conversation ends when the script is exhausted.
Use a persona for realistic, open-ended, adversarial behavior; use a
script for a deterministic, repeatable test (pair it with a TestModel for
fully offline runs).
How judging works¶
The judge scores the transcript once at the end, one LLMJudge call per
criterion:
- Success criteria — things the agent should do; each must hold.
- Failure criteria — things the agent must not do; the criterion is inverted (the judge scores "did this bad thing happen?"), so absence is a pass.
Overall pass = all success criteria hold and no failure criterion occurred.
Writing vs. generating scenarios¶
- Hand-write a
Scenariowhen you have a specific behavior in mind — a known edge case, a regression you want to lock down. - Generate with
generate_scenarios(agent, n=..., focus=...)when you want breadth: it introspects the agent's name, system prompt, and tools and asks an LLM to propose diverse multi-turn scenarios (each with a persona and success/failure criteria), ready to pass straight tosimulate().focussteers it (e.g."adversarial users","edge cases").
Verified against a live run
generate_scenarios(agent, n=2, focus="adversarial users") returned two
named scenarios, each with success criteria, built from the agent's own
system prompt and tools.
Closing the loop¶
Simulation is a producer in the evaluation improve step, not an island:
generate_scenarios ─▶ simulate ─▶ SimulationResults ─▶ Scorecard.from_simulation
│
└─▶ harden() ─▶ recommended fixes ─▶ re-simulate
SimulationResultscarries per-scenario transcripts, pass/fail, and per-criterion verdicts;.summary(),.export(),.persist_local()land it in the Local UI (persist=Trueby default).Scorecard.from_simulation(results)rolls scenarios up into a pass-rate.harden(agent, results, llm)reads the failing transcripts and proposes concrete fixes to instructions/tools/guardrails/memory (recommend-only — you apply and re-simulate to confirm).
Runs are traced like everything else, so each scenario's turns and judge calls nest under one trace in the UI for debugging.
A guided path¶
examples/agent-simulation/scenario_test.py— deterministic scripted + persona scenarios with offline models.examples/74_agent_hardening.py— the full loop:generate_scenarios → simulate → Scorecard → harden.
Next steps¶
- Simulation reference — the full API:
Scenario,SimulatedUser,simulate/asimulate, results, determinism - Evaluation — where simulation sits in the improve loop
- Agent Hardening — turn failures into fixes
- Replay — debug a specific past run instead of a synthetic one