Shadow Evals¶
Shadow Evals replay a past conversation against the current version of your agent. The simulated caller streams the original recorded caller audio turn by turn, so the agent hears the same audio (accents, pauses, background noise) as it did the first time. That lets you check a new agent version against real calls, not just scripted or synthesized ones.
When the new agent goes off script (asks something new, or asks in a different order), a Gemini-driven simulated caller decides each turn whether to:
- replay a recorded caller turn (
use_past_audio), which is the default, or - synthesize a new reply with TTS (
generate_tts), only when no recording contains the needed information.
After the replay, Gemini judges the new conversation against your natural-language expectations.
Prerequisites¶
Check these before writing a Shadow Eval. ShadowEvals.preflight (run automatically, see Preflight checks) checks most of them for you.
- The original call was recorded. The source app must have had audio recording (
loggingSettings.audioRecordingConfig) enabled when the call happened. Recordings are stored asgs://<bucket>/<project>/<location>/<app>/<date>/<conversation_id>/user-turn-N.wav. - You can read the recording bucket. Your credentials need
storage.objects.get/liston it. - You know the session state the original call ran with. If the original session relied on seeded session variables or tool fakes (for example a mocked caller or account lookup keyed on a session variable), set the same
session_parameters/use_tool_fakes. If you don't, the agent can take a different path from turn one, and the replay can't follow the original call.
YAML format¶
Put Shadow Eval files under evals/shadows/ (subdirectories are fine; .yaml and .yml are both picked up):
config: # defaults inherited by every case below
project_id: my-project # where the *past* conversation lives
location: us
app_id: my-app
gcs_bucket: gs://my-recordings-bucket
use_tool_fakes: true # run with the same tool fakes as the original
session_parameters:
customer_tier: gold # same seeded state as the original session
max_turns: 15
shadow_evals:
- name: order_never_arrived
conversation_id: 7f3c9a52-... # the past conversation to replay
replay_mode: hybrid # or "exact"
tags: [P0]
expectations:
- "The agent recognizes the returning customer without asking."
- "The agent finds the order and explains the delivery status."
- "The agent offers a replacement or refund."
Case fields¶
| Field | Type | Description |
|---|---|---|
conversation_id | string | Required. ID of the past conversation to replay |
expectations | list | Required. Natural-language pass criteria judged by Gemini |
name | string | Case name (defaults to shadow_<conversation_id>) |
replay_mode | hybrid | exact | See Replay modes. Default hybrid |
session_parameters | dict | Session variables seeded at session start. Merged over config.session_parameters |
use_tool_fakes | bool | Run with tool fakes. Overrides the run-level flag for this case |
project_id, location, app_id | string | Where the past conversation lives (defaults to the target app) |
gcs_bucket | string | Recording bucket. Inferred from the trace / source app if omitted |
max_turns | int | Upper bound on replayed turns |
goal, response_guide, steps | Optional guidance for the simulated caller (inferred from the past call if omitted) | |
audio_expectations | list | Extra expectations judged together with the captured agent audio (enabled by agent audio capture / artifacts_dir) |
voice_config | dict | TTS voice used for synthesized turns |
naturalness_metric | bool | dict | Optional Naturalness Metric |
initial_utterance | string | First event/utterance sent to start the session |
tags | list | Tags for filtering |
Everything in config: except session_parameters is a default that a case can override; session_parameters are merged, with case values winning.
Typos are reported, not silently dropped
Unknown keys (for example session_params or use_toolfakes) produce a warning with a did you mean hint when the file is loaded, and a warning from cxas lint (rule E012). An invalid replay_mode is an error.
Replay modes¶
| Mode | How the caller turns are chosen | Use it when |
|---|---|---|
hybrid (default) | Gemini matches each new agent prompt to an unused recorded caller turn and replays it; synthesizes TTS only for information no recording contains. If the generated text just restates a recorded turn, the recording is replayed instead. If the agent re-asks the same question, the same recording is played again. | The agent may have changed: new questions, different order, re-prompts |
exact | Replays the recorded caller turns strictly in order, with no LLM. | You want an exact audio regression and expect the same dialog flow |
Tip
Speech recognition can transcribe the same recording slightly differently from run to run (for example a short "four" heard as "for"), which makes the agent re-prompt. hybrid recovers by replaying the recording again; exact keeps going in order, so every later answer lands one question late. Prefer hybrid unless you really need a strict in-order replay.
Running Shadow Evals¶
CLI¶
cxas evals report --run \
--app-name projects/my-project/locations/us/apps/my-app \
--include shadows --modality audio \
--output-dir eval_results/
Shadow Evals always run in audio modality. With --output-dir, each run's audio and a side-by-side HTML report are written under eval_results/shadow_artifacts/<case>_run<N>/ (with a timestamp suffix on shadow_artifacts when timestamped output is enabled).
Python¶
from cxas_scrapi.evals import ShadowEvals
shadow = ShadowEvals(app_name="projects/my-project/locations/us/apps/my-app")
cases = shadow.load_shadow_test_cases_from_file("evals/shadows/orders.yaml")
results = shadow.run_shadow_evals(
cases,
modality="audio",
artifacts_dir="shadow_artifacts/", # keep audio + HTML report per run
)
for r in results:
print(r["name"], r["passed"], r["expectations"],
"past audio:", r.get("past_audio_turns_used"),
"tts:", r.get("tts_turns_generated"),
r.get("report_path"))
For a single case, run_shadow_conversation(test_case, artifacts_dir=...) returns the ShadowUserConversation; conversation.generate_report() gives per-turn decision, goal and expectation DataFrames, and conversation.report_path points to the HTML report.
Preflight checks¶
run_shadow_evals calls ShadowEvals.preflight for every case before opening any session (preflight=False turns this off). You can also call it on its own:
check = shadow.preflight(cases[0])
print(check.ok, check.past_user_turns, check.past_user_turns_with_audio)
for issue in check.issues:
print(issue.level, issue.message)
| Level | Check |
|---|---|
| error | The past conversation can't be loaded (wrong project_id / location / app_id / ID) |
| error | The past conversation has no caller turns |
| error | None of the caller turns has a recording, so the replay would be pure TTS |
| warning | Only some caller turns have recordings |
| info | The target app doesn't record audio, so new calls won't be in GCS (use artifacts_dir) |
| info | No session_parameters / use_tool_fakes set |
Cases with an error are reported as failed with error: "Preflight failed: ..." and are not run. Other issues are printed and attached to the result as preflight_issues.
Side-by-side report and audio artifacts¶
Downloaded and captured audio normally lives in a temporary directory that is deleted after each run. Pass artifacts_dir (or --output-dir on the CLI) to keep it, which makes it possible to audit the replay:
<artifacts_dir>/<case>_run1/
shadow_report.html # past call vs. new call, turn by turn, with audio
shadow_result.json # the same data, machine-readable
audio/past_user_turn_N.wav # recorded caller audio (exactly what was streamed)
audio/past_agent_turn_N.wav # the original agent reply
audio/new_agent_turn_N.wav # the new agent reply
Each row of the report pairs a new turn with the past turn it replayed. You can play the recorded caller audio, the original agent reply and the new agent reply next to each other, alongside the simulated caller's decision and reasoning. Recorded turns that were never replayed are listed at the end. Agent audio is captured automatically when artifacts_dir is set. The directory uses relative paths, so it can be zipped or served statically as is.
Troubleshooting¶
| Symptom | Likely cause / fix |
|---|---|
Most caller turns are generate_tts | Recordings weren't found. Check gcs_bucket and that recording was enabled for the original call (see preflight) |
| The agent takes a different path from the first turn (e.g. doesn't recognize the caller, lookups fail) | The original session relied on seeded state. Set session_parameters and/or use_tool_fakes |
| The agent re-prompts for a short answer that was clearly spoken | Speech recognition varies from run to run; use hybrid mode |
| A key in your YAML seems to have no effect | Check the load-time warnings or run cxas lint (E012) for typos |