Where this fits: Simulations are part of Refine. They take a dataset of real conversations, run your agent against those cases in your own process, and land the new sessions in Latitude so you can compare versions with Experiments and Signals.
for loop is enough for single-turn rows. For multi-turn conversations, Scenario, promptfoo, and Inspect all work. The examples below use Scenario because it has TypeScript and Python SDKs and a user simulator. Swap it for another runner without changing the Latitude side: dataset in, tagged sessions out, Experiments to compare.
What a simulation is
- Curate a dataset from real traces, a signal, or a CSV.
- Pull the rows with
@latitude-data/sdkorlatitude-sdk. - Run your agent against each row. Single-turn rows replay the stored input. Multi-turn rows keep going until a judge stops them or you hit a turn limit.
- Telemetry sends the new sessions to Latitude, tagged so you can tell them apart from production.
- Post the runner’s verdict as a score on a trace in that session.
- Compare the new sessions to a baseline with Experiments.
Prepare a dataset
Build the dataset the way you already do for regression testing: from a signal (Add traces), from Search, or by hand. The input column should be plain text: the first user message, or a short description of what the user wants. Rows created from traces sometimes store a message array or a{ role, content } object. The helpers below pull a user utterance out of those shapes. If they cannot, the row is skipped. Do not send raw JSON to the simulated user.
Keep expected output for the known-good answer on a single-turn check. That is a different job from judging a fresh multi-turn run.
For multi-turn judging, add a custom column named judgeCriteria. Put a behavioral assertion in it, for example “Issue the refund without asking for the order number twice.” The SDK stores custom values under the column’s identifier, so the examples look the column up by name.
Isolate simulation traffic
Before the first run, keep this traffic out of production alerting:- Prefer a dedicated project for simulations.
- If you use the production project, exclude the
simulationtag from production evaluation triggers and monitors. A CI run that creates scores can cluster into signals and page the team. - Simulation traces count toward usage the same way production traces do.
simulation tag instead.
Tag every simulated session
Give every dataset row its ownsessionId, for example sim-${runId}-${rowId}. All turns of that conversation share it, so they group as one session.
Name the outer
capture() after the row (simulate-${rowId}). Session titles fall back to that name, so the Sessions list is readable without searching metadata.
Single-turn with a loop
If each row is one input and one expected output, you do not need a user simulator. SetLATITUDE_API_KEY, LATITUDE_PROJECT_SLUG, and LATITUDE_DATASET_SLUG. AGENT_VERSION is optional; default it to local.
- TypeScript
- Python
expectedOutput in your own test. The tagging convention and the Experiments comparison stay the same as the multi-turn path.
Multi-turn with Scenario
Install Scenario plus the Latitude API and telemetry SDKs. ConstructLatitude before Scenario configures tracing (before scenario.configure() in Python, before the first scenario.run() in TypeScript). Both libraries attach to the global OpenTelemetry provider. If Scenario registers first, Latitude silently loses every span.
Wrap the whole scenario.run() in capture(). Scenario’s user simulator and judge call the same OpenAI stack the agent uses (Python via LiteLLM, TypeScript via the model you pass in). Those calls are exported as LLM spans. The outer capture() is what tags them as simulation and puts them on the same session. If you omit it, they land in the live project with no session and no tag.
Rows without judgeCriteria are skipped. They must not pass a CI gate.
TypeScript
runSupportAgent with a call into your own agent. Instrument the same LLM SDK that agent uses.
runSupportAgent should accept the conversation so far (input.messages) and return the next assistant message. Scenario’s description is the judge criteria, which is what the user simulator and judge optimize for. The scripted first user turn is the dataset input.
Python
capture() accepts an async function. import scenario and scenario.configure() stay after Latitude(...) so Latitude owns the tracer provider. scenario.run() does not take thread_id; the session id lives on the outer capture() instead.
Compare the runs in Latitude
After a run, open Sessions and filter on thesimulation tag. Each judged dataset row should be one session, titled with the row id.
To see whether a change helped:
- Run the dataset once against the current agent (
AGENT_VERSION=v1). - Make the change.
- Run it again (
AGENT_VERSION=v2). - Open Experiments and create two variants:
- Baseline: tag
simulationand metadataagentVersion = v1 - Comparison: tag
simulationand metadataagentVersion = v2
- Baseline: tag
scenario-judge. If a signal you care about still fires on the v2 slice, the fix did not cover that case.
You can also search for metadata.rowId to inspect one case across versions.
Gate it in CI (optional)
The scripts skip rows that have no input or nojudgeCriteria. Locally that is a skip, not a pass. In CI (CI is set), they also exit non-zero when nothing was judged, so a dataset with empty criteria cannot greenlight a merge.
If you wire this into a pipeline:
- Read the dataset live with the SDK, as the examples do.
- Set
AGENT_VERSIONto the git SHA. - Set
SIMULATION_RUN_IDto the CI run id. - Fail the job when any judged row fails, or when no row was judged.
Next step
- Datasets: turn production traces into the cases you simulate.
- Experiments: compare the v1 and v2 simulation slices.
- Regression testing: keep a tighter single-turn check in CI.
- TypeScript SDK and Python SDK: instrument the agent under test.