Adding a Domain
A domain in the NeMo Labs Voice Agent evaluation harness is a namespace that binds fixtures, a tool registry bucket, a base scenario class, and a scoring configuration. Refer to Authoring Scenarios for individual scenario classes and Authoring Tools for tool internals.
Pieces of a Domain
A complete domain can include the following fixture, runtime, and scoring components.
The harness uses two distinct domain strings:
Scenario.domain— the registry namespace. Tools, predicates, init functions, and sync appliers are all keyed by it. The bridge ships it to both bots as thetool_domainargument ofupdate_system_prompt.- The
--domaincommand-line interface (CLI) filter — a scenario-name prefix match on<domain>__.run_evaluation.pycomputes the available list from scenario names, not fromScenario.domain. Keep them identical to avoid confusion (tau2_telecomis the one deliberate exception:tau2_telecom_workflow__*scenarios carrydomain = "tau2_telecom"so they share one tool bucket).
1. Add Fixtures
Fixtures live inside the installed package, under nemo_voice_agent/evaluation/data/<domain>/.
get_eval_data_root() (in nemo_voice_agent/evaluation/__init__.py) resolves that directory, with the
EVAL_DATA_ROOT environment variable as an override. Because the bridge and the bot servers can be
different processes with different roots, every fixture reference stored in shared state is a
relative path.
load_db_artifact() accepts either <name>.json or a <name>/ directory of per-table shards and
returns the same in-memory dictionary, so DB hashes match across layouts. Shard when a single file exceeds a
hosting size cap. tau2_airline therefore ships its DB as one file per table.
Record the upstream source, version, and license in nemo_voice_agent/evaluation/data/README.md under
## Sources & Licenses. Refer to Data Provenance.
2. Register Tools in the Domain Bucket
The registry maps domain → tool name → class. The same short class name can exist in two domains,
but a duplicate inside one domain raises an error at import time.
Read-only tools subclass StandardSchemaTool directly and record nothing. _record_action appends to
shared_state["actions"] (the bridge pulls that list at end of scenario) and pushes an action-applied
real-time voice interface (RTVI) server message that drives cross-side sync. Set a class-level name
attribute if you want snake_case large language model (LLM)-visible function names instead of the class name.
Lookup falls back to the "default"
bucket for shared harness tools such as EndConversationTool, so your scenarios receive them automatically.
List them under their registered PascalCase key.
3. Write the Base Scenario
Define a base scenario that binds the registry namespace and derives shared behavior for the domain.
success_signals is validated at class-definition time: any concrete scenario (one that declares
name) must resolve a non-empty tuple of SuccessSignal members from itself or an ancestor. Declare it on
the base class. When it depends on per-task opt-ins, use a cached_property. tau2_retail and
tau2_telecom use this pattern to add NL_ASSERTION only when the task carries assertions. Refer to
Scoring for what each signal means.
setup_shared_state(state, side) is called one time per side. Use one of these seeding styles:
Path seeding is mandatory above roughly 1 MB — Pipecat’s WebSocket frame cap closes the connection with code 1009 when a bigger payload is inlined.
4. Wire the Imports
Decorators run only if the modules are imported. Add your tool module to the import block at the bottom
of nemo_voice_agent/evaluation/tools/__init__.py, and your scenario package to
nemo_voice_agent/evaluation/scenarios/data/__init__.py. A domain package’s own __init__.py should
side-import each group_Nx.py shard so every scaffolded scenario registers.
5. Understand the Runtime State Flow
The runtime initializes domain state and tools in the following order.
apply_initialization is the scenario-state initializer. Its handler merges shared_state_init into
the bot’s shared_state (preserving runtime sentinels), resolves db_path into db if present, then
dispatches initialization functions. The bridge calls it on both bots for every scenario, even when
there are no init actions, because the merge and DB load must always run. Any failure aborts the
scenario rather than running it half-seeded. update_system_prompt does no DB loading.
Each bot owns exactly one DB at shared_state["db"] and is side-agnostic. The bridge labels the pulls:
the agent bot’s response becomes db_hash / db, the user bot’s becomes user_db_hash / user_db.
db_hash is computed with nemo_voice_agent/evaluation/db_hash.py, which both the bot and the runner
import, so canonicalization is identical on both ends. Refer to
RTVI Messages for full payload shapes.
6. Optional: DB-State Assertion Predicates
Use these when the domain has an open solution space and several valid action sequences produce
different whole-DB states that all satisfy the same outcome. Predicates are pure, side-agnostic, and
dispatched runner-side through evaluate_db_state_assertion(domain, assertion, db, user_db).
Each entry in Scenario.db_state_assertions has the shape
side, func_name, arguments, assert_value, and optional message. The bridge sets
include_db=True on the summary pull whenever db_state_assertions is truthy, so the actual dicts
come back for the predicates to read.
7. Optional: Initialization Functions
These functions mutate the live DB before the conversation starts. The signature is
(db: dict, **arguments) -> None, and dispatch occurs bot-side. The mutation must affect the same
dictionary instance that the live tools use.
Populate Scenario.initialization_actions with side, func_name, arguments records. The bridge
filters by side before sending, and rejects any record whose side is neither "user" nor
"agent".
8. Optional: Dual-Side Domains
A dual-side domain gives the user simulator its own tools and DB. tau2_telecom is the only current
example. Refer to tau2-telecom. Complete these requirements:
- Set
has_user_state = Trueon the base scenario so gold replay seeds the user side and the bridge dual-pulls at end of scenario. - Seed both sides from
setup_shared_state(side="agent"andside="user"branches). - Override
sync_state(agent_db, user_db)to return per-side deltas, shapedagentanduserkeys mapping to delta dicts. Returning empty dicts is the base-class no-op that keeps single-side domains out of the pipeline entirely. - Provide
_build_tool_map(state)returning name-to-instance pairs where each tool has a syncinvoke(**kwargs)method. The bridge replays fired actions onto in-process shadow DBs through this map before callingsync_state. Tau2 tools get this from_Tau2InvokeMixin. EVA tools have only the async_executeand would need a sync wrapper first. - Register a sync applier if your deltas are not plain dotted paths:
The default applier handles dotted-path assignment such as surroundings.roaming_allowed. Anything
richer — list-by-id lookups, post-apply re-derivation — needs your own applier.
Sync runs at two points: after apply_initialization (so the conversation starts from coherent
cross-side state) and after every action-applied event from either bot. Both bots need
llm.enable_tool_calling: true in their server config for a dual-side domain.
9. Verify
Run the focused registry, scoring, and documentation checks after wiring the new domain.
Both bot servers must be running first, and SERVER_CONFIG_PATH resolves against the current working
directory. Refer to the Evaluation Quickstart and the
Evaluation CLI reference.