Agent Simulation¶
Simulation stress-tests multi-turn agent behavior. A Scenario drives a
conversation between a simulated user (an LLM persona or a fixed script) and
the agent under test, then a judge scores the whole transcript against
natural-language success criteria (and optional failure criteria).
Where it fits among the testing tools:
| Tool | Scores | Shape |
|---|---|---|
evaluate() |
fixed input → output pairs |
single-turn, dataset |
simulate() |
a multi-turn conversation vs. criteria | synthetic dialogue |
| Replay | a past trace, fork-and-rerun | debugging |
It reuses existing primitives — your Agent, LLMClient, and LLMJudge — so
there's no new infrastructure to learn, and runs land in the same Local UI.
Quickstart¶
from fastaiagent import Agent, LLMClient, Scenario, SimulatedUser, simulate
llm = LLMClient(provider="openai", model="gpt-4o-mini")
agent = Agent(
name="support",
system_prompt="You are a friendly support agent. Explain the 30-day refund policy when asked.",
llm=llm,
)
scenario = Scenario(
name="refund-request",
user=SimulatedUser(persona="A frustrated customer who wants a refund for shoes bought 10 days ago."),
success_criteria=["The agent explains the refund policy clearly and politely."],
failure_criteria=["The agent is rude or refuses to help."],
max_turns=6,
)
results = simulate(scenario, agent)
print(results.summary())
simulate() persists each run to the Local UI by default — open the
Simulations page to read the transcript and per-criterion
verdicts.
The pieces¶
Scenario¶
@dataclass
class Scenario:
name: str
user: SimulatedUser
success_criteria: list[str] # all must hold to pass
failure_criteria: list[str] = () # if any holds → fail
max_turns: int = 6 # hard cap on total user + agent turns
SimulatedUser¶
Provide exactly one of persona or script:
# Persona — an LLM role-plays the user, generating each turn from the
# transcript so far. It can end the conversation early by replying "END".
SimulatedUser(persona="A confused first-time user.", llm=llm)
# Script — fixed user turns, returned one per turn; the conversation ends
# when the script is exhausted.
SimulatedUser(script=["Hi, I need help", "How do I reset my password?", "Thanks"])
Judge¶
By default each criterion is judged by a LLMJudge call over the full
transcript: the scenario passes when every success_criteria holds and no
failure_criteria holds. Pass your own judge=LLMJudge(llm=...) to control the
model used for judging.
Native agents and adapters¶
simulate() accepts either a native Agent or any callable adapter — so you
can simulate LangChain / CrewAI / custom agents too:
# Native Agent — auto-wrapped to use the additive messages= param
simulate(scenario, agent)
# Callable adapter: (messages: list[Message]) -> str | AgentResult
def my_agent(messages):
return my_framework.respond(messages[-1].content)
simulate(scenario, my_agent)
Native agents are driven via the additive
messages= parameter on
Agent.arun, so prior turns flow into the model exactly as a real conversation
would.
Determinism in tests¶
Inject TestModel / FunctionModel (real LLMClient subclasses) as the llm
for the agent, the simulated user, and the judge — the whole multi-turn run
becomes fully deterministic with no network and no mocks:
from fastaiagent.testing.models import TestModel
from fastaiagent.eval import LLMJudge
import json
agent = Agent(name="bot", llm=TestModel(response="canned reply"))
judge = LLMJudge(llm=TestModel(response=json.dumps({"score": 1.0, "reasoning": "ok"})))
scenario = Scenario(
name="smoke",
user=SimulatedUser(script=["hi", "thanks"]),
success_criteria=["The agent replied."],
)
results = simulate(scenario, agent, judge=judge, persist=False)
assert results.results[0].passed
Results¶
simulate() returns SimulationResults:
.results— aSimulationResultper scenario (transcript,passed, per-criterionverdicts, roottrace_id)..summary()— a printable pass/fail table..persist_local() -> run_id— writes onesim_runsrow + onesim_casesrow per scenario to the Local UI DB (done automatically whenpersist=True)..export(path)— dump the full results (transcripts + verdicts) to JSON.
asimulate(...) is the async entrypoint with the same signature; both run
scenarios concurrently (bounded by concurrency, default 4).