Authoring Scenarios

View as Markdown

A scenario is a Python class that specifies one evaluation run: the simulated user’s goal, the agent’s instructions, each side’s tools, and the scoring contract. Scenario classes live under nemo_voice_agent/evaluation/scenarios/data/.

Every domain has a base class that implements domain-level defaults and is not registered. Concrete scenarios subclass the base, override only what differs, and register themselves with @register_eval_scenario. In the tau2 and eva domains most subclasses are under 20 lines because everything derives from a single tau2_id / eva_id class attribute.

Where Scenarios Live

The following table shows the package shape used by each benchmark-derived domain.

DomainLocationShape
eva_airlinescenarios/data/eva_airline/package: base.py + group_Nx.py shards
tau2_airlinescenarios/data/tau2_airline/package: base.py + group_Nx.py shards
tau2_retailscenarios/data/tau2_retail/package: base.py + group_Nx.py shards
tau2_telecomscenarios/data/tau2_telecom/Package that also emits the parallel tau2_telecom_workflow__ registrations
restaurant, customer_service, qa, fastbite, simple_qascenarios/data/<name>.pysingle file (in-repo smoke sets)

New modules must be side-imported from scenarios/data/__init__.py so the decorators run at import time. Refer to Authoring Domains for the full new-domain checklist.

The Eight Per-Side Properties

Each scenario supplies four dataclasses per side, for both user and agent — eight properties in total. They are defined in nemo_voice_agent/evaluation/scenarios/classes.py and rendered into the system prompt by get_user_prompt() / get_agent_prompt().

PropertyTypeContents
user_persona / agent_personaPersonarole (required), plus optional name, background, personality, language, accent. Rendered as the opening lines of the prompt.
user_task / agent_taskTaskgoal (required) and optional background. One objective per side.
user_actions / agent_actionsActionsinstructions (a numbered, ordered script) and guidelines (always-apply rules).
user_resources / agent_resourcesResourcestools (Dict[str, Dict[str, str]] — tool class name to constructor kwargs), documents, information strings, and optional info_sections for structured ### heading blocks.

Persona also carries behavior_config and voice_config. Prompt rendering and the pipeline do not use these fields, so treat them only as metric-slicing labels.

Scenario-Level Fields

Use these fields to define the scenario identity, runtime limits, scoring contract, and fixture state.

FieldPurpose
nameUnique scenario ID and the registry key. Convention: <domain>__<id>. --domain filters on this name prefix, not on the domain attribute.
domainClassVar keying the per-domain tool registry, the fixture subdirectory, and the tool_domain argument the bridge sends to the bots. Defaults to "default". It can differ from the name prefix — tau2_telecom_workflow__* scenarios keep domain = "tau2_telecom".
descriptionShort human-readable summary.
max_durationPer-scenario cap in seconds. The command-line interface (CLI) --duration defaults to None, so this value applies unless you pass the flag.
success_signalsRequired on every concrete scenario. The following section describes the contract.
reference_answerExpected action list, or the structured payload for legacy summary scenarios. Drives ACTION_MATCH.
expected_scenario_dbOptional cached_property holding the gold end-state database (DB). Drives DB_STATE_MATCH through SHA-256 comparison.
expected_user_dbOptional gold end-state for the user-side DB in dual-side domains.
db_state_assertionsOptional list of records shaped side, func_name, arguments, assert_value, message. Drives DB_STATE_ASSERTION.
nl_assertionsOptional list of natural-language claims judged per-claim by the large language model (LLM) judge. Drives NL_ASSERTION.
initialization_actionsOptional list of side / func_name / arguments records replayed bot-side to seed fixture state before the conversation starts.
ignore_capitalization, ignore_punctuation, clean_textString-matching normalization for the action-match comparator.
disallow_extra_itemsWhen True, the list-of-dicts comparator requires an exact bijection instead of tolerating extra predicted items.
noise_configOptional NoiseConfig (from nemo_voice_agent.utils.audio) that injects background noise into the user-to-agent channel.

The success_signals Contract

success_signals is the whitelist of signals that gate the composite is_successful verdict. It must resolve to a non-empty sequence of SuccessSignal members. Scenario.__init_subclass__ raises TypeError at class-definition time for any class that declares name without one.

MemberMetric KeyUse When
ACTION_MATCHis_action_matchThe domain has one canonical correct payload or trajectory.
DB_STATE_MATCHdb_state_matchThe scenario ships expected_scenario_db and there is a single deterministic end state. Path-independent, so prefer it over ACTION_MATCH.
DB_STATE_ASSERTIONdb_state_assertionThe solution space is open — several valid end states satisfy the same outcome predicates.
NL_ASSERTIONnl_assertionThe scenario carries nl_assertions. Requires the judge to be enabled.
JUDGE_PASSEDjudge_passedNo deterministic check applies at all (free-form QA).
CLEAN_EXITclean_exitAlways. Every shipped domain includes it. The value is True only when the agent voluntarily emitted the <exit> signal by calling EndConversationTool or a terminal transfer tool (TransferToAgentTool or TransferToHumanAgentsTool). This requirement prevents a timed-out conversation from scoring as a win by inaction.

Pass-rate signals (db_state_assertion_pass_rate, nl_assertion_pass_rate) are binarized at a threshold of 1.0, so every assertion must pass. The default verdict is a strict AND over whitelisted signals that produced a non-None value. If none apply, the scenario scores "N/A" and is excluded from the run rate. Signals outside the whitelist are still computed and saved under success_breakdown.excluded in metrics.json. Refer to Scoring and Metrics Reference.

Two declaration patterns cover every shipped domain — a ClassVar tuple when the whitelist is uniform, and a cached_property when it depends on per-task opt-ins. A single outlier scenario can also declare its own tuple, which shadows the base.

1# Uniform across the domain.
2class Tau2AirlineBaseScenario(Tau2BaseScenario):
3 success_signals = (SuccessSignal.DB_STATE_MATCH, SuccessSignal.CLEAN_EXIT)
4
5# Derived from a per-task opt-in, so it cannot drift from the data.
6class Tau2RetailBaseScenario(Tau2BaseScenario):
7 @cached_property
8 def success_signals(self) -> tuple:
9 if self.nl_assertions:
10 return (SuccessSignal.DB_STATE_MATCH, SuccessSignal.NL_ASSERTION, SuccessSignal.CLEAN_EXIT)
11 return (SuccessSignal.DB_STATE_MATCH, SuccessSignal.CLEAN_EXIT)

If strict AND is the wrong combinator for your scenario, override compute_is_successful(self, signals) instead of contorting the whitelist.

Worked Example

A complete scenario for the in-repo restaurant domain. It inherits agent_persona, agent_task, user_resources, max_duration, the text-normalization flags, and success_signals from RestaurantBaseScenario, so only the scenario-specific pieces appear here.

1from nemo_voice_agent.evaluation.scenarios import register_eval_scenario
2from nemo_voice_agent.evaluation.scenarios.classes import Actions, Persona, Resources, Task
3from nemo_voice_agent.evaluation.scenarios.data.restaurant import (
4 PIZZA_PALACE_MENU,
5 RestaurantBaseScenario,
6)
7
8
9@register_eval_scenario
10class PizzaPepperoni(RestaurantBaseScenario):
11 """Order a pepperoni pizza with extra cheese at Pizza Palace."""
12
13 name = "restaurant__pizza_pepperoni"
14 description = "Order a pepperoni pizza with extra cheese at Pizza Palace"
15 reference_answer = {
16 "items": [
17 {"name": "Pepperoni Pizza", "unit_price": "9.99", "quantity": "1"},
18 {"name": "Extra Cheese", "unit_price": "1.50", "quantity": "1"},
19 ],
20 "customer_name": "Charlie",
21 "customer_phone": "314-527-8960",
22 "total_price": "11.49",
23 }
24
25 @property
26 def user_persona(self) -> Persona:
27 return Persona(
28 role="human user",
29 name="Charlie",
30 background="You are a graphic designer. Your phone number is 314-527-8960.",
31 personality="Communicative and positive, with clear needs and prompt decision-making.",
32 )
33
34 @property
35 def user_task(self) -> Task:
36 return Task(
37 goal="Order a pepperoni pizza with extra cheese.",
38 background="You are hungry after work and just walked into Pizza Palace.",
39 )
40
41 @property
42 def user_actions(self) -> Actions:
43 return Actions(
44 instructions=[
45 "Ask the agent what pizza options are available.",
46 "Order one pepperoni pizza.",
47 "Ask if you can add extra cheese, and add it to the order.",
48 "Confirm the order and ask for the total price.",
49 ],
50 guidelines=["Provide your name and phone number when asked."],
51 )
52
53 @property
54 def agent_actions(self) -> Actions:
55 return Actions(
56 instructions=[
57 "Greet the user and ask what they would like to order.",
58 "Summarize the order and confirm it is correct.",
59 "Ask for the user's name and phone number.",
60 "Place the order with the `PlaceOrderTool` tool and confirm it succeeded.",
61 "Say goodbye and call the `EndConversationTool` tool.",
62 ],
63 guidelines=["Do not make up any items that are not on the menu."],
64 )
65
66 @property
67 def agent_resources(self) -> Resources:
68 return Resources(
69 tools={
70 "GetMenuTool": {"menu": PIZZA_PALACE_MENU},
71 "PlaceOrderTool": {"auto_validate": "False"},
72 "EndConversationTool": {},
73 },
74 information=["You can use the `GetMenuTool` tool to retrieve the restaurant menu."],
75 )

EndConversationTool is mandatory in every domain: it emits the exit signal the bridge waits for, and it is what makes the CLEAN_EXIT signal pass. Without it the bridge idles until max_duration expires. Tool base classes and registration are covered in Authoring Tools.

Seeding Fixture Data

Scenarios that need a database override setup_shared_state(self, state, side). The runner calls it one time per side. The resulting dictionary is JSON-serialized into the shared_state_init argument of the apply_initialization real-time voice interface (RTVI) action. The bot handler merges the data into its own shared_state before tools are instantiated.

1def setup_shared_state(self, state: dict, side: str) -> None:
2 if side == "agent":
3 state["db_path"] = f"{self.domain}/db.json"

Any db_path value is resolved bot-side against get_eval_data_root() and replaced with the loaded db key. Fixtures live in nemo_voice_agent/evaluation/data/, overridable with the EVAL_DATA_ROOT environment variable. Send a path rather than inline content for anything large — the tau2 databases exceed the WebSocket frame limit if inlined. At end of scenario the bridge pulls get_scenario_summary from each bot. The response contains the recorded actions and a db_hash. The inline DB returns only when the bridge opts in with include_db, which it does for scenarios that declare db_state_assertions. Dual-side domains that must propagate state between the two DBs also override sync_state. Refer to tau2-telecom.

Verify

Run from the evaluation/ directory, since SERVER_CONFIG_PATH and the scenario runner resolve paths against the current working directory.

$cd evaluation
$
$# The new scenario should appear under its domain heading.
$python run_evaluation.py --list
$
$# Run it alone against a live user bot and agent bot.
$python run_evaluation.py --scenarios restaurant__pizza_pepperoni

Scenarios that produce fewer than --min-agent-turns agent turns (default 3) count as failures in the composite success rate. The per-signal rates skip them because a scenario that does not start is a defect, not an exclusion. Pass --min-agent-turns 0 to disable the filter. To start the two bot servers, refer to the Evaluation Quickstart. To interpret the output, refer to Results and the Eval CLI reference.