Skip to content

SimulationEvals

SimulationEvals runs AI-driven end-to-end conversation simulations against your CXAS agent. Instead of scripting exact utterances, you describe goals and success criteria — and a Gemini model figures out what to say at each turn to try to achieve them. This is a great way to test how your agent handles realistic, messy, unpredictable conversations.

Here are the key concepts:

  • Step (Pydantic model) — a single goal within a simulation, with a goal, success_criteria, optional response_guide, and a max_turns limit. Steps can also include a static_utterance for when you want a fixed first message, and inject_variables for seeding session state.
  • StepStatus enum — tracks whether each step is NOT_STARTED, IN_PROGRESS, or COMPLETED.
  • simulate_conversation() — drives the full multi-turn loop, returning an LLMUserConversation object that contains the transcript, step progress, and expectation results.
  • generate_report() — produces a SimulationReport with two DataFrames: goal progress and expectation results. It renders as styled HTML in a Jupyter notebook.

Quick Example

from cxas_scrapi import SimulationEvals
from cxas_scrapi.utils.rate_limiter import RateLimiter

app_name = "projects/my-project/locations/us/apps/my-app-id"

# Optional: configure a rate limiter to pace simulation turns and prevent quota exhaustion
limiter = RateLimiter(requests_per_minute=30.0)
sim = SimulationEvals(app_name=app_name, rate_limiter=limiter)

test_case = {
    "steps": [
        {
            "goal": "User wants to check their account balance",
            "success_criteria": "Agent provides a numeric balance and account status",
            "max_turns": 5,
        },
        {
            "goal": "User asks to dispute a charge",
            "success_criteria": "Agent acknowledges the dispute and provides a reference number",
            "max_turns": 8,
        },
    ],
    "expectations": [
        "The agent should never ask for the full credit card number",
        "The agent should offer to escalate if it cannot resolve the dispute",
    ],
}

# Run the simulation
conversation = sim.simulate_conversation(
    test_case=test_case,
    console_logging=True,
)

# View the report
report = conversation.generate_report()
print(report)  # Colorized in terminal, styled HTML in Jupyter

Naturalness Metric (optional)

SimulationEvals can also grade how human the agent sounds. The metric is opt-in: a test case that does not declare a naturalness_metric block behaves exactly as before, with no extra Gemini call and no extra keys in the results. See the Local Simulations guide for the YAML schema and worked examples.

  • NaturalnessConfig (Pydantic model) — the per-test-case configuration: which model grades, which qualities are scored, the label bands, the turn/conversation blend weight, the perceived-latency bands and weight, and an optional pass_threshold. Unknown keys are tolerated, so newer YAML still loads.
  • NaturalnessResult — the aggregate result for one simulation: overall_score, overall_label, per-turn gradings, conversation-level factors, factor_averages, label_counts, latency_ms_by_turn, failure_reasons, and passed. Exposed as conversation.naturalness_result.
  • TurnNaturalness — the grading for a single agent turn: its label, 1–5 score, justification, and the factors behind it.
  • NaturalnessFactor — one scored quality (e.g. pacing) with its 1–5 score, an optional short value descriptor, and the evidence-citing reason.
  • NaturalnessLabel enum — Bot-like, Transitional, or Human-like.
  • evaluate_naturalness() — grades a simulation trace directly, optionally against the agent's recorded audio and the platform's per-turn perceived latency. Returns None (after logging) when there is nothing to grade or the grading call fails, so the metric can never break a run.

In audio modality the agent's recordings are attached to the grading call automatically, so pacing and emotion are judged from what the caller heard. perceivedLatency is measured from the conversation trace rather than judged, and a turn past latency_failure_threshold_ms fails the simulation outright.

Audio Analysis (optional)

SimulationEvals can run comprehensive perceptual and acoustic voice evaluations by setting audio_analysis=True (off by default):

sim = SimulationEvals(
    app_name=app_name,
    audio_analysis=True,  # Opt-in audio evaluation
)
  • Perceptual Audio Checks: Runs 5 Gemini checks (voice consistency, pauses, looping, cutoff, agent trouble).
  • Acoustic Consistency: Measures ITU-R BS.1770 LUFS loudness, articulation rate, and F0 pitch stability.
  • Voice Drift Detection: Identifies cross-turn timbre and pitch seams, audio truncation, and loudness steps.
  • Multi-Voice Caller Pool (sim_user_voices): Pass sim_user_voices=["en-US-Journey-D", "es-US-Journey-F", ...] to SimulationEvals or run_simulations() to deterministically assign locale-matched (en-US / es-US) TTS voices across scenarios via CRC32 hashing.
  • Zero-Caller-Turn Inconclusive Retry: Sessions where expected_caller_turns(tc) > 0 but actual_caller_turns(row) == 0 are automatically classified as inconclusive ("caller turns never reached agent (0 turns)") and retried during infra_retries waves.

Platform Milestone Evaluations (CXAS 2.0)

SimulationEvals also supports converting simulation test cases into CXAS 2.0 scenario.milestones evaluations and executing them directly on the CX Agent Studio platform:

  • to_milestone_yaml(test_cases) — sanitizes and converts one or more simulation test cases into CXAS 2.0 Milestone YAML (evals: format compatible with CES MilestoneYamlTranslator).
  • push_to_platform(test_cases, conflict_strategy="OVERWRITE") — converts and imports simulation test cases onto the platform as Milestone evaluations via :importEvaluations.
  • run_simulations_on_platform(test_cases, runs=1, timeout_seconds=600, use_tool_fakes=None) — imports, triggers RunEvaluation (with EvaluationConfig.tool_call_behaviour set to FAKE when use_tool_fakes=True), waits for completion, and returns results formatted as standard simulation result dictionaries.
  • requires_local_execution(test_case) — checks whether a test case uses local-only features (modality: "audio", audio_expectations, audio_analysis, voice_config, naturalness_metric, or barge_in_after_ms). Tool fakes (use_tool_fakes) are supported directly on the platform.
  • run_simulations(..., run_on_platform=True, platform_timeout=600, use_tool_fakes=None) — automatically partitions test_cases into platform-compatible cases (executed via run_simulations_on_platform, forwarding use_tool_fakes) and local-only cases (executed locally via simulate_conversation). Run-level audio settings (audio_analysis, capture_agent_audio, background_noise_file, burst_noise_files) automatically force local execution.

Reference

SimulationEvals

SimulationEvals(app_name, rate_limiter=None, expectations_only=False, deployment_id=None, vertex_location='global', naturalness=None, audio_analysis=False, sim_user_voices=None, **kwargs)

Bases: Apps

Wrapper class to simulate entire multi-turn conversations with a CXAS Agent.

Source code in src/cxas_scrapi/evals/simulation_evals.py
def __init__(
    self,
    app_name: str,
    rate_limiter: RateLimiter | None = None,
    expectations_only: bool = False,
    deployment_id: str | None = None,
    vertex_location: str = "global",
    naturalness: bool | dict[str, Any] | None = None,
    audio_analysis: bool = False,
    sim_user_voices: Sequence[str] | None = None,
    **kwargs: typing.Any,
) -> None:
    self.expectations_only = expectations_only
    self.vertex_location = vertex_location
    # Run-level override for the optional Naturalness Metric. `None`
    # defers entirely to each test case; True enables it everywhere with
    # defaults; a dict is merged over the test case's own block.
    self.naturalness = naturalness
    self.audio_analysis = audio_analysis
    self.sim_user_voices: list[str] = [
        v.strip() for v in (sim_user_voices or []) if v and v.strip()
    ]
    project_id = app_name.split("/")[1]
    location = app_name.split("/")[3]
    super().__init__(project_id=project_id, location=location, **kwargs)
    # Must follow super().__init__: `Common` assigns `self.app_name`
    # unconditionally, and it is not handed the app name here, so setting
    # this any earlier would be silently overwritten with `None`.
    self.app_name = app_name
    self.sessions_client = Sessions(
        app_name,
        deployment_id=deployment_id,
        rate_limiter=rate_limiter,
        **kwargs,
    )
    self.tools_map = Tools(app_name=app_name, **kwargs).get_tools_map()

    self.genai_client = GeminiGenerate(
        project_id=self.project_id,
        location=self.vertex_location,
        credentials=self.creds,
    )

requires_local_execution staticmethod

requires_local_execution(test_case)

Checks if a test case requires local execution (cannot be run as a platform Milestone eval).

Returns:

Type Description
tuple[bool, str]

(requires_local: bool, reason: str)

Source code in src/cxas_scrapi/evals/simulation_evals.py
@staticmethod
def requires_local_execution(
    test_case: dict[str, Any],
) -> tuple[bool, str]:
    """Checks if a test case requires local execution (cannot be run as a
    platform Milestone eval).

    Returns:
        (requires_local: bool, reason: str)
    """
    if test_case.get("modality") == "audio":
        return True, "Audio modality requires local client simulation"
    if test_case.get("audio_expectations"):
        return True, "audio_expectations requires local client simulation"
    if test_case.get("audio_analysis"):
        return True, "audio_analysis requires local client simulation"
    if test_case.get("voice_config"):
        return True, "voice_config requires local client simulation"
    if test_case.get("naturalness_metric") or test_case.get("naturalness"):
        return True, "naturalness_metric requires local client evaluation"
    if test_case.get("barge_in_after_ms") is not None:
        return True, "barge_in_after_ms requires local audio bidi session"
    for step in test_case.get("steps", []):
        if (
            isinstance(step, dict)
            and step.get("barge_in_after_ms") is not None
        ):
            return (
                True,
                "Step barge_in_after_ms requires local bidi session",
            )
    return False, ""

to_milestone_yaml

to_milestone_yaml(test_cases)

Converts simulation test case(s) into CES Platform Milestone YAML.

Source code in src/cxas_scrapi/evals/simulation_evals.py
def to_milestone_yaml(
    self, test_cases: list[dict[str, Any]] | dict[str, Any]
) -> str:
    """Converts simulation test case(s) into CES Platform Milestone YAML."""
    from cxas_scrapi.utils.eval_utils import (  # noqa: PLC0415
        EvalUtils,
    )

    return EvalUtils.convert_sim_evals_to_milestone_yaml(test_cases)

push_to_platform

push_to_platform(test_cases, conflict_strategy='OVERWRITE')

Pushes simulation test case(s) to CES as Milestone evaluations.

Source code in src/cxas_scrapi/evals/simulation_evals.py
def push_to_platform(
    self,
    test_cases: list[dict[str, Any]] | dict[str, Any],
    conflict_strategy: str = "OVERWRITE",
) -> Any:
    """Pushes simulation test case(s) to CES as Milestone evaluations."""
    from cxas_scrapi.utils.eval_utils import (  # noqa: PLC0415
        EvalUtils,
    )

    eval_utils = EvalUtils(app_name=self.app_name, creds=self.creds)
    return eval_utils.push_milestone_evals_from_yaml(
        source=test_cases,
        app_name=self.app_name,
        conflict_strategy=conflict_strategy,
    )

run_simulations_on_platform

run_simulations_on_platform(test_cases, runs=1, timeout_seconds=600, use_tool_fakes=None)

Pushes and runs simulations on the CES platform via RunEvaluation, returning results formatted as simulation result dictionaries.

Source code in src/cxas_scrapi/evals/simulation_evals.py
def run_simulations_on_platform(
    self,
    test_cases: list[dict[str, Any]],
    runs: int = 1,
    timeout_seconds: int = 600,
    use_tool_fakes: bool | None = None,
) -> list[dict[str, Any]]:
    """Pushes and runs simulations on the CES platform via RunEvaluation,
    returning results formatted as simulation result dictionaries.
    """
    from cxas_scrapi.core.evaluations import (  # noqa: PLC0415
        Evaluations,
    )
    from cxas_scrapi.utils.eval_utils import (  # noqa: PLC0415
        EvalUtils,
    )

    eval_client = Evaluations(app_name=self.app_name, creds=self.creds)
    eval_utils = EvalUtils(app_name=self.app_name, creds=self.creds)

    import_op = self.push_to_platform(test_cases)

    eval_names: list[str] = []
    if isinstance(import_op, dict):
        imported_evals = import_op.get("response", {}).get(
            "evaluations", []
        )
        if isinstance(imported_evals, list):
            eval_names = [
                e["name"]
                for e in imported_evals
                if isinstance(e, dict) and "name" in e
            ]

    if not eval_names:
        eval_names = [
            tc["name"]
            for tc in test_cases
            if isinstance(tc, dict) and "name" in tc
        ]
    if not eval_names:
        return []

    run_eval_kwargs: dict[str, Any] = {
        "evaluations": eval_names,
        "app_name": self.app_name,
        "modality": "text",
        "run_count": runs,
    }
    if use_tool_fakes is not None:
        run_eval_kwargs["use_tool_fakes"] = use_tool_fakes

    try:
        run_op = eval_client.run_evaluation(**run_eval_kwargs)
    except Exception:
        resolved_names = []
        evals_map = None
        for e_name in eval_names:
            if e_name.startswith("projects/") and "/evaluations/" in e_name:
                resolved_names.append(e_name)
            else:
                if evals_map is None:
                    evals_map = eval_client._get_or_load_evals_map(
                        self.app_name
                    )
                r_name = evals_map.get("scenarios", {}).get(
                    e_name
                ) or evals_map.get("goldens", {}).get(e_name)
                if r_name:
                    resolved_names.append(r_name)
        rest_kwargs: dict[str, Any] = {
            "evaluations": resolved_names or eval_names,
            "app_name": self.app_name,
            "modality": "text",
            "run_count": runs,
        }
        if use_tool_fakes is not None:
            rest_kwargs["use_tool_fakes"] = use_tool_fakes
        run_op = eval_client.run_evaluation_rest(**rest_kwargs)

    meta_run = getattr(
        getattr(run_op, "metadata", None), "evaluation_run", None
    )
    run_id = (
        meta_run
        if isinstance(meta_run, str) and meta_run
        else getattr(run_op, "name", None)
    )
    if not isinstance(run_id, str) or not run_id:
        run_id = None
    if not run_id and hasattr(run_op, "operation"):
        op_name_attr = getattr(run_op.operation, "name", None)
        if isinstance(op_name_attr, str) and op_name_attr:
            run_id = op_name_attr
    if not run_id and isinstance(run_op, dict):
        run_id = (
            run_op.get("evaluationRun")
            or run_op.get("metadata", {}).get("evaluationRun")
            or run_op.get("response", {}).get("evaluationRun")
            or run_op.get("name")
        )

    if (
        run_id
        and "/evaluationRuns/" not in run_id
        and hasattr(run_op, "_refresh")
    ):
        from google.cloud.ces_v1beta.types import (  # noqa: PLC0415
            RunEvaluationOperationMetadata,
        )

        for _ in range(12):
            with contextlib.suppress(Exception):
                refreshed = run_op._refresh(None)
                meta = RunEvaluationOperationMetadata()
                meta._pb.ParseFromString(refreshed.metadata.value)
                if meta.evaluation_run:
                    run_id = meta.evaluation_run
                    break
            time.sleep(5)

    if (
        run_id
        and "/evaluationRuns/" not in run_id
        and "/operations/" in run_id
    ):
        op_name = run_id
        for _ in range(12):
            with contextlib.suppress(Exception):
                op_state = eval_client.get_operation_rest(op_name)
                candidate = op_state.get("metadata", {}).get(
                    "evaluationRun"
                ) or op_state.get("response", {}).get("evaluationRun")
                if candidate:
                    run_id = candidate
                    break
            time.sleep(5)

    raw_results = []
    if run_id and "/evaluationRuns/" in run_id:
        raw_results = eval_utils.wait_for_run_and_get_results(
            run_id, timeout_seconds=timeout_seconds
        )

    df_dict = eval_utils.evals_to_dataframe(results=raw_results)
    df_summary = df_dict.get("summary", pd.DataFrame())
    df_metadata = df_dict.get("metadata", pd.DataFrame())
    df_failures = df_dict.get("failures", pd.DataFrame())

    sim_results: list[dict[str, Any]] = []
    tc_map = {
        tc.get("name"): tc for tc in test_cases if isinstance(tc, dict)
    }

    for tc_name, tc_def in tc_map.items():
        tc_runs = (
            df_summary[df_summary["display_name"] == tc_name]
            if not df_summary.empty
            else pd.DataFrame()
        )
        if tc_runs.empty:
            sim_results.append(
                {
                    "name": tc_name,
                    "passed": False,
                    "status": "FAIL",
                    "goals": "0/0",
                    "expectations": "0/0",
                    "duration_s": 0.0,
                    "run": 1,
                    "runs": runs,
                    "platform": True,
                    "step_details": [],
                    "expectation_details": [],
                    "session_parameters": tc_def.get(
                        "session_parameters", {}
                    ),
                }
            )
            continue

        tc_steps = (
            tc_def.get("steps", [])
            if isinstance(tc_def.get("steps"), list)
            else []
        )

        for run_idx, (_, row) in enumerate(tc_runs.iterrows()):
            res_id = row.get("eval_result_id", "")
            res_meta = (
                df_metadata[df_metadata["eval_result_id"] == res_id]
                if not df_metadata.empty
                else pd.DataFrame()
            )
            res_fails = (
                df_failures[df_failures["eval_result_id"] == res_id]
                if not df_failures.empty
                else pd.DataFrame()
            )

            milestone_rows = (
                res_meta[res_meta["type"] == "Milestone"]
                if not res_meta.empty and "type" in res_meta.columns
                else pd.DataFrame()
            )
            exp_rows = (
                res_meta[res_meta["type"] == "Custom Expectation"]
                if not res_meta.empty and "type" in res_meta.columns
                else pd.DataFrame()
            )

            milestones_met = (
                sum(milestone_rows["outcome"] == "PASS")
                if not milestone_rows.empty
                else 0
            )
            total_milestones = len(milestone_rows)
            exps_met = (
                sum(exp_rows["outcome"] == "PASS")
                if not exp_rows.empty
                else 0
            )
            total_exps = len(exp_rows)

            step_details = []
            for step_idx, (_, m_row) in enumerate(
                milestone_rows.iterrows()
            ):
                raw_score = str(m_row.get("score", "")).upper()
                if (
                    raw_score == "COMPLETED"
                    or m_row.get("outcome") == "PASS"
                ):
                    step_status = "Completed"
                elif raw_score == "IN_PROGRESS":
                    step_status = "In Progress"
                else:
                    step_status = "Not Started"
                criteria = str(m_row.get("success_criteria") or "")
                if (
                    not criteria
                    and step_idx < len(tc_steps)
                    and isinstance(tc_steps[step_idx], dict)
                ):
                    criteria = str(
                        tc_steps[step_idx].get("success_criteria")
                        or tc_steps[step_idx].get("successCriteria")
                        or ""
                    )
                step_details.append(
                    {
                        "goal": str(m_row.get("expected", "")),
                        "success_criteria": criteria,
                        "status": step_status,
                        "justification": str(m_row.get("actual", "")),
                    }
                )

            expectation_details = []
            for _, e_row in exp_rows.iterrows():
                expectation_details.append(
                    {
                        "expectation": str(e_row.get("expected", "")),
                        "status": (
                            "Met"
                            if e_row.get("outcome") == "PASS"
                            else "Not Met"
                        ),
                        "justification": str(e_row.get("actual", "")),
                    }
                )

            status_val = str(row.get("evaluation_status", "")).upper()
            exec_state_val = str(row.get("execution_state", "")).upper()
            passed = (
                status_val == "PASS"
                and exec_state_val not in ("ERROR", "ERRORED", "CANCELLED")
                and res_fails.empty
            )
            conv_name = str(row.get("conversation", ""))
            sess_id = (
                conv_name.rsplit("/", 1)[-1]
                if "/" in conv_name
                else conv_name
            )

            result_entry: dict[str, Any] = {
                "name": tc_name,
                "passed": passed,
                "status": "PASS" if passed else "FAIL",
                "goals": f"{milestones_met}/{total_milestones}",
                "expectations": f"{exps_met}/{total_exps}",
                "duration_s": 0.0,
                "run": run_idx + 1,
                "runs": runs,
                "platform": True,
                "eval_result_id": res_id,
                "evaluation_run": str(
                    row.get("evaluation_run", "") or run_id or ""
                ),
                "conversation_name": conv_name,
                "session_id": sess_id,
                "step_details": step_details,
                "expectation_details": expectation_details,
                "session_parameters": tc_def.get("session_parameters", {}),
            }
            if exec_state_val in (
                "ERROR",
                "ERRORED",
            ) or status_val in ("ERROR", "ERRORED"):
                result_entry["error"] = str(
                    row.get("error_message") or "Unknown Agent Exception"
                )

            sim_results.append(result_entry)

    return sim_results

simulate_conversation

simulate_conversation(test_case, sim_user_model=_DEFAULT_SIM_USER_MODEL, eval_model=_DEFAULT_EVAL_MODEL, session_id=None, console_logging=True, modality='text', capture_agent_audio=False, background_noise_file=None, bg_noise_snr=None, burst_noise_files=None, burst_noise_snr=None, use_tool_fakes=False, voice_config=None, initial_utterance=_FIRST_UTTERANCE, skip_playback_wait=False, single_bidi_stream=False, max_turns=None, naturalness=None, audio_analysis=None, **kwargs)

Runs the simulated conversation loop.

Parameters:

Name Type Description Default
test_case dict[str, Any]

The test case dictionary defining evaluation steps.

required
sim_user_model str | None

The Gemini model used for the simulated user.

_DEFAULT_SIM_USER_MODEL
eval_model str | None

The Gemini model used for evaluating expectations.

_DEFAULT_EVAL_MODEL
console_logging bool

Whether to print interaction transcript to the console.

True
single_bidi_stream bool

For audio modality, keep one persistent bidi WebSocket open for the whole conversation instead of opening a new connection per turn (the default).

False
max_turns int | None

Maximum number of conversation turns. Defaults to the test_case's max_turns setting, or 30 if unspecified.

None
naturalness bool | dict[str, Any] | None

Overrides the test case's naturalness_metric block. None (the default) defers to the test case, so simulations that never declare the metric are unaffected.

None
audio_analysis bool | None

Run multimodal audio checks, acoustic metrics, and voice drift analysis. Defaults to instance setting.

None
Source code in src/cxas_scrapi/evals/simulation_evals.py
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
@cleanup_session_dir
def simulate_conversation(
    self,
    test_case: dict[str, Any],
    sim_user_model: str | None = _DEFAULT_SIM_USER_MODEL,
    eval_model: str | None = _DEFAULT_EVAL_MODEL,
    session_id: str | None = None,
    console_logging: bool = True,
    modality: str = "text",
    capture_agent_audio: bool = False,
    background_noise_file: str | None = None,
    bg_noise_snr: float | None = None,
    burst_noise_files: list[str] | None = None,
    burst_noise_snr: float | None = None,
    use_tool_fakes: bool = False,
    voice_config: dict[str, Any] | None = None,
    initial_utterance: str = _FIRST_UTTERANCE,
    skip_playback_wait: bool = False,
    single_bidi_stream: bool = False,
    max_turns: int | None = None,
    naturalness: bool | dict[str, Any] | None = None,
    audio_analysis: bool | None = None,
    **kwargs: Any,
) -> LLMUserConversation:
    """Runs the simulated conversation loop.

    Args:
        test_case: The test case dictionary defining evaluation steps.
        sim_user_model: The Gemini model used for the simulated user.
        eval_model: The Gemini model used for evaluating expectations.
        console_logging: Whether to print interaction transcript to
            the console.
        single_bidi_stream: For audio modality, keep one persistent
            bidi WebSocket open for the whole conversation instead of
            opening a new connection per turn (the default).
        max_turns: Maximum number of conversation turns. Defaults to
            the test_case's max_turns setting, or 30 if unspecified.
        naturalness: Overrides the test case's `naturalness_metric`
            block. `None` (the default) defers to the test case, so
            simulations that never declare the metric are unaffected.
        audio_analysis: Run multimodal audio checks, acoustic metrics,
            and voice drift analysis. Defaults to instance setting.
    """
    sim_user_model = sim_user_model or _DEFAULT_SIM_USER_MODEL
    eval_model = eval_model or _DEFAULT_EVAL_MODEL
    if session_id is None:
        session_id = str(uuid.uuid4())
    effective_audio_analysis = (
        audio_analysis
        if audio_analysis is not None
        else getattr(self, "audio_analysis", False)
    )
    if (
        effective_audio_analysis
        and naturalness is None
        and getattr(self, "naturalness", None) is None
    ):
        effective_naturalness = True
    else:
        effective_naturalness = (
            naturalness if naturalness is not None else self.naturalness
        )
    naturalness_config = parse_naturalness_config(
        test_case,
        effective_naturalness,
    )
    voice_config = voice_config or test_case.get("voice_config")
    eval_conv = LLMUserConversation(
        genai_client=self.genai_client,
        genai_model=sim_user_model,
        test_case=test_case,
        max_turns=max_turns,
        initial_utterance=initial_utterance,
    )

    # Initialize audio paths tracking
    eval_conv.agent_audio_paths = {}
    current_sim_turn = 0

    has_barge_in_step = any(
        isinstance(s, dict) and s.get("barge_in_after_ms") is not None
        for s in test_case.get("steps", [])
    )
    if has_barge_in_step and modality != "audio":
        logging.warning(
            "Test case has barge_in_after_ms configured, but modality is "
            f"'{modality}'. Barge-in evaluation requires audio modality."
        )
    if has_barge_in_step and modality == "audio":
        single_bidi_stream = True
        skip_playback_wait = False

    resolved_noise = resolve_noise_config(
        config_or_case=test_case,
        background_noise_file=background_noise_file,
        bg_noise_snr=bg_noise_snr,
        burst_noise_files=burst_noise_files,
        burst_noise_snr=burst_noise_snr,
    )
    effective_bg_file = resolved_noise["background_noise_file"]
    effective_bg_snr = resolved_noise["bg_noise_snr"]
    effective_burst_files = resolved_noise["burst_noise_files"]
    effective_burst_snr = resolved_noise["burst_noise_snr"]

    interactive_session = None
    if modality == "audio" and single_bidi_stream:
        client = self.sessions_client
        create_kwargs: dict[str, Any] = {
            "session_id": session_id,
            "capture_agent_audio": capture_agent_audio,
            "background_noise_file": effective_bg_file,
            "use_tool_fakes": use_tool_fakes,
            "skip_playback_wait": skip_playback_wait,
            "voice_config": voice_config,
        }
        if effective_bg_file is not None:
            create_kwargs["bg_noise_snr"] = effective_bg_snr
        if effective_burst_files is not None:
            create_kwargs["burst_noise_files"] = effective_burst_files
            create_kwargs["burst_noise_snr"] = effective_burst_snr
        interactive_session = client.create_interactive_session(
            **create_kwargs
        )
        interactive_session.start()

    try:
        if console_logging:
            print(
                f"Starting simulated conversation with session ID: "
                f"{session_id}"
            )

        # Initialize the first turn manually
        user_utterance, variables = eval_conv.next_user_utterance()
        accumulated_variables = {}
        if variables:
            accumulated_variables.update(variables)

        detailed_trace = []
        detailed_trace.append(f"User: {user_utterance}")
        turn_traces: list[dict[str, Any]] = []

        while user_utterance:
            barge_in_offset = (
                eval_conv.peek_barge_in_after_ms()
                if hasattr(eval_conv, "peek_barge_in_after_ms")
                else None
            )
            if (
                modality == "audio"
                and interactive_session
                and isinstance(barge_in_offset, int)
            ):
                barge_in_utterance, barge_in_vars = (
                    eval_conv.generate_barge_in_utterance()
                )
                barge_vars = accumulated_variables.copy()
                if barge_in_vars:
                    barge_vars.update(barge_in_vars)

                resp_n, response = (
                    interactive_session.send_turn_with_barge_in(
                        text=user_utterance,
                        barge_in_text=barge_in_utterance,
                        barge_in_after_ms=barge_in_offset,
                        variables=accumulated_variables,
                        barge_in_variables=barge_vars,
                    )
                )

                if isinstance(resp_n, dict) and resp_n.get("session_ended"):
                    if resp_n.get("connection_error"):
                        err_msg = (
                            "Interactive session WebSocket error: "
                            f"{resp_n['connection_error']}"
                        )
                        raise BidiSessionError(err_msg)
                    break

                if resp_n and getattr(resp_n, "agent_audio_paths", None):
                    audio_path_n = resp_n.agent_audio_paths.get(0)
                    if audio_path_n:
                        eval_conv.agent_audio_paths[current_sim_turn] = (
                            audio_path_n
                        )

                agent_text_n, trace_chunks_n, _, tool_calls_n = (
                    self._parse_agent_response(resp_n)
                )
                parsed_n = ParsedSessionResponse(resp_n)
                if (
                    parsed_n.barge_in_metadata
                    and parsed_n.barge_in_metadata.interrupted
                ):
                    heard = parsed_n.barge_in_metadata.estimated_heard_text
                    eval_conv._add_agent_response(
                        f"{heard}... [interrupted]"
                    )
                elif agent_text_n:
                    eval_conv._add_agent_response(agent_text_n)

                eval_conv._add_agent_tool_calls(tool_calls_n)
                detailed_trace.append("\n".join(trace_chunks_n))
                turn_root_span_n = ParsedSessionResponse.extract_root_span(
                    resp_n
                )
                if isinstance(turn_root_span_n, dict) and turn_root_span_n:
                    turn_traces.append(
                        {
                            "turn_index": current_sim_turn,
                            "user_utterance": user_utterance,
                            "agent_text": agent_text_n,
                            "root_span": turn_root_span_n,
                        }
                    )
                current_sim_turn += 1

                eval_conv.record_user_utterance(barge_in_utterance)
                detailed_trace.append(
                    f"User: [Barge-In] {barge_in_utterance}"
                )

                if isinstance(response, dict) and response.get(
                    "session_ended"
                ):
                    if response.get("connection_error"):
                        err_msg = (
                            "Interactive session WebSocket error: "
                            f"{response['connection_error']}"
                        )
                        raise BidiSessionError(err_msg)
                    break

                user_utterance = barge_in_utterance
                accumulated_variables = barge_vars
            elif modality == "audio" and interactive_session:
                response = interactive_session.send_turn(
                    user_utterance,
                    accumulated_variables,
                )
                # Check if session ended via WebSocket endSession
                if isinstance(response, dict) and response.get(
                    "session_ended"
                ):
                    if response.get("connection_error"):
                        err_msg = (
                            f"Interactive session WebSocket error: "
                            f"{response['connection_error']}"
                        )
                        raise BidiSessionError(err_msg)
                    break
            else:
                response = self._send_request_with_retry(
                    session_id=session_id,
                    user_utterance=user_utterance,
                    variables=accumulated_variables,
                    modality=modality,
                    console_logging=console_logging,
                    turn_num=current_sim_turn,
                    capture_agent_audio=capture_agent_audio,
                    background_noise_file=effective_bg_file,
                    bg_noise_snr=effective_bg_snr,
                    burst_noise_files=effective_burst_files,
                    burst_noise_snr=effective_burst_snr,
                    use_tool_fakes=use_tool_fakes,
                    voice_config=voice_config,
                )
            if not response:
                break

            # Extract and save the agent turn audio WAV if present
            # in response.
            if response and getattr(response, "agent_audio_paths", None):
                audio_path = response.agent_audio_paths.get(0)
                if audio_path:
                    paths = eval_conv.agent_audio_paths
                    paths[current_sim_turn] = audio_path

            if console_logging:
                self.sessions_client.parse_result(response)

            agent_text, trace_chunks, session_ended, tool_calls = (
                self._parse_agent_response(response)
            )
            detailed_trace.append("\n".join(trace_chunks))

            turn_root_span = ParsedSessionResponse.extract_root_span(
                response
            )
            if isinstance(turn_root_span, dict) and turn_root_span:
                turn_traces.append(
                    {
                        "turn_index": current_sim_turn,
                        "user_utterance": user_utterance,
                        "agent_text": agent_text,
                        "root_span": turn_root_span,
                    }
                )

            if session_ended:
                if agent_text:
                    eval_conv._add_agent_response(agent_text)
                eval_conv._add_agent_tool_calls(tool_calls)
                # Ensure the final agent response is evaluated
                # so that steps_progress is updated on session end.
                eval_conv._next_user_utterance()
                if console_logging:
                    print(
                        "\nSession has been closed by the Agent via "
                        "end_session tool."
                    )
                # Mark steps as completed if session ending matches
                # criteria (escalation/transfer) or if goal was primed as
                # ready for completion.
                self._reconcile_completed_steps(
                    eval_conv, session_ended=True
                )
                break

            # Get the next simulated user utterance based on the agent's
            # response
            eval_conv._add_agent_tool_calls(tool_calls)
            user_utterance, variables = eval_conv.next_user_utterance(
                agent_text
            )
            if variables:
                accumulated_variables.update(variables)
            if user_utterance:
                detailed_trace.append(f"User: {user_utterance}")

            current_sim_turn += 1

        self._reconcile_completed_steps(eval_conv)

        if console_logging:
            self._print_completion_status(eval_conv)

        self._evaluate_expectations(
            eval_conv,
            detailed_trace,
            eval_model,
            console_logging,
            capture_agent_audio=capture_agent_audio,
        )
        self._evaluate_naturalness(
            eval_conv,
            detailed_trace,
            eval_model,
            console_logging,
            naturalness_config,
            session_id=session_id,
            modality=modality,
        )
        if effective_audio_analysis:
            self._evaluate_audio_analysis(
                eval_conv=eval_conv,
                detailed_trace=detailed_trace,
                turn_traces=turn_traces,
                eval_model=eval_model,
                console_logging=console_logging,
                session_id=session_id,
                modality=modality,
            )
        eval_conv._session_id = session_id
        eval_conv.session_id = session_id
        eval_conv._detailed_trace = detailed_trace
        eval_conv.detailed_trace = detailed_trace
        eval_conv._turn_traces = turn_traces
        eval_conv.turn_traces = turn_traces
        if turn_traces and session_id:
            with contextlib.suppress(Exception):
                cache_dir = "/tmp/scrapi_traces"
                os.makedirs(cache_dir, exist_ok=True)
                cache_path = os.path.join(cache_dir, f"{session_id}.json")
                with open(cache_path, "w") as f:
                    json.dump(
                        {"session_id": session_id, "turns": turn_traces}, f
                    )
        return eval_conv
    finally:
        if interactive_session:
            interactive_session.close()

run_simulations

run_simulations(test_cases, runs=1, parallel=1, sim_user_model=_DEFAULT_SIM_USER_MODEL, eval_model=_DEFAULT_EVAL_MODEL, modality='text', verbose=False, capture_agent_audio=False, background_noise_file=None, bg_noise_snr=None, burst_noise_files=None, burst_noise_snr=None, use_tool_fakes=False, expectations_only=None, skip_playback_wait=False, single_bidi_stream=False, progress_callback=None, naturalness=None, audio_analysis=None, infra_retries=2, retry_parallel=None, retry_cooldown=60.0, run_on_platform=False, platform_timeout=600, sim_user_voices=None)

Runs multiple simulations, optionally in parallel.

Parameters:

Name Type Description Default
test_cases list[dict[str, Any]]

List of test case dictionaries.

required
runs int

Number of runs per test case.

1
parallel int

Number of parallel workers (capped at 25).

1
sim_user_model str | None

Gemini model to use for simulated user.

_DEFAULT_SIM_USER_MODEL
eval_model str | None

Gemini model to use for evaluating expectations.

_DEFAULT_EVAL_MODEL
modality str

'text' or 'audio'.

'text'
verbose bool

Whether to log to console (only active if parallel=1).

False
use_tool_fakes bool

Use fake tools for the session if available.

False
capture_agent_audio bool

If True, capture real-time agent audio WAVs.

False
naturalness bool | dict[str, Any] | None

Run-level override for the optional Naturalness Metric. Leave as None to honour each test case's own naturalness_metric block.

None
audio_analysis bool | None

Run multimodal audio checks, acoustic metrics, and voice drift analysis. Defaults to instance setting.

None
run_on_platform bool

If True, executes platform-compatible cases via CES Milestone evals.

False
platform_timeout int

Max seconds to wait for platform evaluation run.

600
Source code in src/cxas_scrapi/evals/simulation_evals.py
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
def run_simulations(
    self,
    test_cases: list[dict[str, Any]],
    runs: int = 1,
    parallel: int = 1,
    sim_user_model: str | None = _DEFAULT_SIM_USER_MODEL,
    eval_model: str | None = _DEFAULT_EVAL_MODEL,
    modality: str = "text",
    verbose: bool = False,
    capture_agent_audio: bool = False,
    background_noise_file: str | None = None,
    bg_noise_snr: float | None = None,
    burst_noise_files: list[str] | None = None,
    burst_noise_snr: float | None = None,
    use_tool_fakes: bool = False,
    expectations_only: bool | None = None,
    skip_playback_wait: bool = False,
    single_bidi_stream: bool = False,
    progress_callback: Callable[[int, int], None] | None = None,
    naturalness: bool | dict[str, Any] | None = None,
    audio_analysis: bool | None = None,
    infra_retries: int = 2,
    retry_parallel: int | None = None,
    retry_cooldown: float = 60.0,
    run_on_platform: bool = False,
    platform_timeout: int = 600,
    sim_user_voices: Sequence[str] | None = None,
) -> list[dict[str, Any]]:
    """Runs multiple simulations, optionally in parallel.

    Args:
        test_cases: List of test case dictionaries.
        runs: Number of runs per test case.
        parallel: Number of parallel workers (capped at 25).
        sim_user_model: Gemini model to use for simulated user.
        eval_model: Gemini model to use for evaluating expectations.
        modality: 'text' or 'audio'.
        verbose: Whether to log to console (only active if parallel=1).
        use_tool_fakes: Use fake tools for the session if available.
        capture_agent_audio: If True, capture real-time agent audio WAVs.
        naturalness: Run-level override for the optional Naturalness
            Metric. Leave as None to honour each test case's own
            `naturalness_metric` block.
        audio_analysis: Run multimodal audio checks, acoustic metrics,
            and voice drift analysis. Defaults to instance setting.
        run_on_platform: If True, executes platform-compatible cases via
            CES Milestone evals.
        platform_timeout: Max seconds to wait for platform evaluation run.
    """
    if expectations_only is not None:
        self.expectations_only = expectations_only
    if sim_user_voices is not None:
        self.sim_user_voices = [
            v.strip() for v in sim_user_voices if v and v.strip()
        ]
    if run_on_platform:
        platform_cases: list[dict[str, Any]] = []
        local_cases: list[dict[str, Any]] = []
        effective_naturalness = (
            naturalness
            if naturalness is not None
            else getattr(self, "naturalness", False)
        )
        effective_audio_analysis = (
            audio_analysis
            if audio_analysis is not None
            else getattr(self, "audio_analysis", False)
        )
        has_audio_capture_or_noise = bool(
            capture_agent_audio
            or background_noise_file
            or burst_noise_files
        )
        for tc in test_cases:
            effective_tc = dict(tc) if isinstance(tc, dict) else {}
            if (
                modality
                and modality.lower() != "text"
                and "modality" not in effective_tc
            ):
                effective_tc["modality"] = modality
            if (
                effective_audio_analysis or has_audio_capture_or_noise
            ) and not effective_tc.get("audio_analysis"):
                effective_tc["audio_analysis"] = True
            if effective_naturalness and not effective_tc.get(
                "naturalness_metric"
            ):
                effective_tc["naturalness_metric"] = True
            requires_local, reason = self.requires_local_execution(
                effective_tc
            )
            if requires_local:
                logger.info(
                    "Test case '%s' routed to local simulation (%s)",
                    tc.get("name", "unnamed"),
                    reason,
                )
                local_cases.append(tc)
            else:
                platform_cases.append(tc)

        results: list[dict[str, Any]] = []
        if platform_cases:
            fakes_cases: list[dict[str, Any]] = []
            non_fakes_cases: list[dict[str, Any]] = []
            for ptc in platform_cases:
                case_fakes = (
                    ptc.get("use_tool_fakes")
                    if ptc.get("use_tool_fakes") is not None
                    else use_tool_fakes
                )
                if case_fakes:
                    fakes_cases.append(ptc)
                else:
                    non_fakes_cases.append(ptc)

            if fakes_cases:
                results.extend(
                    self.run_simulations_on_platform(
                        fakes_cases,
                        runs=runs,
                        timeout_seconds=platform_timeout,
                        use_tool_fakes=True,
                    )
                )
            if non_fakes_cases:
                results.extend(
                    self.run_simulations_on_platform(
                        non_fakes_cases,
                        runs=runs,
                        timeout_seconds=platform_timeout,
                    )
                )
        if local_cases:
            results.extend(
                self.run_simulations(
                    test_cases=local_cases,
                    runs=runs,
                    parallel=parallel,
                    sim_user_model=sim_user_model,
                    eval_model=eval_model,
                    modality=modality,
                    verbose=verbose,
                    capture_agent_audio=capture_agent_audio,
                    background_noise_file=background_noise_file,
                    burst_noise_files=burst_noise_files,
                    use_tool_fakes=use_tool_fakes,
                    expectations_only=expectations_only,
                    skip_playback_wait=skip_playback_wait,
                    single_bidi_stream=single_bidi_stream,
                    progress_callback=progress_callback,
                    naturalness=naturalness,
                    audio_analysis=audio_analysis,
                    infra_retries=infra_retries,
                    retry_parallel=retry_parallel,
                    retry_cooldown=retry_cooldown,
                    run_on_platform=False,
                    sim_user_voices=sim_user_voices,
                )
            )
        return results

    sim_user_model = sim_user_model or _DEFAULT_SIM_USER_MODEL
    eval_model = eval_model or _DEFAULT_EVAL_MODEL
    if naturalness is not None:
        # Persist on the instance so wrappers that reimplement the
        # aggregation loop still see the override.
        self.naturalness = naturalness
    if audio_analysis is not None:
        self.audio_analysis = audio_analysis
    jobs = self._prepare_simulation_jobs(test_cases, runs)
    results = self._aggregate_simulation_results(
        jobs,
        runs,
        parallel,
        sim_user_model,
        eval_model,
        modality,
        verbose,
        capture_agent_audio=capture_agent_audio,
        background_noise_file=background_noise_file,
        bg_noise_snr=bg_noise_snr,
        burst_noise_files=burst_noise_files,
        burst_noise_snr=burst_noise_snr,
        use_tool_fakes=use_tool_fakes,
        skip_playback_wait=skip_playback_wait,
        single_bidi_stream=single_bidi_stream,
        progress_callback=progress_callback,
    )

    declared_by_name = {
        tc.get("name", ""): self._declared_expectations_count(tc)
        for tc in test_cases
        if isinstance(tc, dict) and tc.get("name")
    }
    expected_turns_by_name = {
        tc.get("name", ""): expected_caller_turns(tc)
        for tc in test_cases
        if isinstance(tc, dict) and tc.get("name")
    }
    tc_by_name = {
        tc.get("name", ""): tc
        for tc in test_cases
        if isinstance(tc, dict) and tc.get("name")
    }

    conv_lookup = (
        self._ces_conversation_lookup()
        if modality.lower() == "audio"
        else None
    )

    for attempt in range(1, max(0, infra_retries) + 1):
        bad = [
            (idx, why)
            for idx, row in enumerate(results)
            if isinstance(row, dict)
            and (
                why := self._inconclusive_reason(
                    row,
                    declared_by_name,
                    lookup=conv_lookup,
                    expected_turns_by_name=expected_turns_by_name,
                )
            )
        ]
        if not bad:
            break
        need_names = {results[idx].get("name") for idx, _ in bad}
        print(
            f"[infra] retry {attempt}/{infra_retries}: {len(bad)} "
            f"inconclusive run(s) across {len(need_names)} case(s); "
            f"cooling down {retry_cooldown}s"
        )
        if retry_cooldown > 0:
            time.sleep(retry_cooldown)

        retry_jobs = [
            (
                tc_by_name[results[idx]["name"]],
                max(0, int(results[idx].get("run", 1)) - 1),
            )
            for idx, _ in bad
            if results[idx].get("name") in tc_by_name
        ]
        if not retry_jobs:
            break
        effective_retry_parallel = max(
            1,
            min(
                retry_parallel
                if retry_parallel is not None
                else max(1, parallel // 2),
                len(retry_jobs),
            ),
        )
        fresh_rows = self._aggregate_simulation_results(
            retry_jobs,
            runs,
            effective_retry_parallel,
            sim_user_model,
            eval_model,
            modality,
            verbose,
            capture_agent_audio=capture_agent_audio,
            background_noise_file=background_noise_file,
            bg_noise_snr=bg_noise_snr,
            burst_noise_files=burst_noise_files,
            burst_noise_snr=burst_noise_snr,
            use_tool_fakes=use_tool_fakes,
            skip_playback_wait=skip_playback_wait,
            single_bidi_stream=single_bidi_stream,
        )
        fresh_by_key: dict[tuple[Any, Any], list[dict[str, Any]]] = {}
        for r in fresh_rows or []:
            if isinstance(r, dict):
                fresh_by_key.setdefault(
                    (r.get("name"), r.get("run")), []
                ).append(r)

        for idx, why in bad:
            orig = results[idx]
            key = (orig.get("name"), orig.get("run"))
            pool = fresh_by_key.get(key)
            if pool:
                new_row = pool.pop(0)
                new_row.update(
                    run=orig.get("run"),
                    infra_retries=attempt,
                    retried_because=why,
                )
                results[idx] = new_row

    still = [
        (row, why)
        for row in results
        if isinstance(row, dict)
        and (
            why := self._inconclusive_reason(
                row,
                declared_by_name,
                lookup=conv_lookup,
                expected_turns_by_name=expected_turns_by_name,
            )
        )
    ]
    for row, _ in still:
        if row.get("passed"):
            row["passed"] = False
    if still and infra_retries > 0:
        print(
            f"[infra] {len(still)} run(s) still inconclusive after "
            f"{infra_retries} retries -- counted as FAIL:"
        )
        for row, why in still:
            print(
                f"        - {row.get('name')} run {row.get('run')}: {why}"
            )

    return results

export_results_to_golden

export_results_to_golden(results, output_path=None)

Exports simulation results to a Golden Evaluation YAML file.

Fetches the full conversation trace for each simulation from the platform to ensure accuracy.

Parameters:

Name Type Description Default
results list[dict[str, Any]]

The list of results returned by run_simulations.

required
output_path str | None

Optional local path to save the generated YAML.

None

Returns:

Type Description
str

The generated YAML string.

Source code in src/cxas_scrapi/evals/simulation_evals.py
def export_results_to_golden(
    self,
    results: list[dict[str, Any]],
    output_path: str | None = None,
) -> str:
    """Exports simulation results to a Golden Evaluation YAML file.

    Fetches the full conversation trace for each simulation from the
    platform to ensure accuracy.

    Args:
        results: The list of results returned by run_simulations.
        output_path: Optional local path to save the generated YAML.

    Returns:
        The generated YAML string.
    """
    conversations_list = []

    for res in results:
        turns = self._get_turns(res)
        if not turns:
            continue

        expectations = [
            e["expectation"] for e in res.get("expectation_details", [])
        ]
        params = res.get("session_parameters", {})

        conversations_list.append(
            GoldenConversation(
                conversation=res.get("name", "Simulated_Conversation"),
                turns=turns,
                expectations=expectations,
                session_parameters=params,
            )
        )

    dataset = GoldenConversations(conversations=conversations_list)
    yaml_content = yaml.dump(
        dataset.model_dump(exclude_none=True),
        sort_keys=False,
        allow_unicode=True,
    )

    if output_path:
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(yaml_content)

    return yaml_content

Step

Bases: BaseModel

StepStatus

Bases: str, Enum

SimulationReport

SimulationReport(goals_df, expectations_df=None, naturalness_df=None, naturalness_headline='')

A report containing both Goals and Expectations DataFrames.

Source code in src/cxas_scrapi/evals/simulation_evals.py
def __init__(
    self,
    goals_df: pd.DataFrame,
    expectations_df: pd.DataFrame | None = None,
    naturalness_df: pd.DataFrame | None = None,
    naturalness_headline: str = "",
) -> None:
    self.goals_df = goals_df
    self.expectations_df = expectations_df
    self.naturalness_df = naturalness_df
    self.naturalness_headline = naturalness_headline

NaturalnessConfig

Bases: BaseModel

Per-test-case configuration for the Naturalness Metric.

Unknown keys are preserved rather than rejected so that a YAML file written against a newer version of the library still loads here.

NaturalnessResult

Bases: BaseModel

Aggregated Naturalness Metric result for a whole simulation.

TurnNaturalness

Bases: BaseModel

Naturalness grading for one agent turn.

NaturalnessFactor

Bases: BaseModel

A single scored conversational quality, e.g. pacing or grammarStyle.

NaturalnessLabel

Bases: str, Enum

Graded label assigned to an agent turn and to the whole call.

evaluate_naturalness

evaluate_naturalness(gemini_client, model_name, trace, config, audio_paths=None, latency_ms_by_turn=None)

Grades the naturalness of every agent turn in a simulation trace.

Parameters:

Name Type Description Default
gemini_client Any

A GeminiGenerate instance.

required
model_name str

Fallback model, used when the config does not pin one.

required
trace list[str]

The simulation's detailed_trace.

required
config NaturalnessConfig

Resolved metric configuration.

required
audio_paths dict[int, str] | None

Optional map of turn index to the agent's recorded audio, as either a gs:// URI or a local WAV path. Used only when config.use_audio is set.

None
latency_ms_by_turn dict[int, float] | None

Optional map of turn index to the platform's reported perceived latency in ms. When supplied, each turn gains a computed perceivedLatency factor.

None

Returns:

Name Type Description
A NaturalnessResult | None

class:NaturalnessResult, or None when there was nothing to

NaturalnessResult | None

grade or the grading call failed. Failures are logged rather than

NaturalnessResult | None

raised so the metric can never break a simulation run.

Source code in src/cxas_scrapi/evals/naturalness.py
def evaluate_naturalness(
    gemini_client: Any,
    model_name: str,
    trace: list[str],
    config: NaturalnessConfig,
    audio_paths: dict[int, str] | None = None,
    latency_ms_by_turn: dict[int, float] | None = None,
) -> NaturalnessResult | None:
    """Grades the naturalness of every agent turn in a simulation trace.

    Args:
        gemini_client: A ``GeminiGenerate`` instance.
        model_name: Fallback model, used when the config does not pin one.
        trace: The simulation's ``detailed_trace``.
        config: Resolved metric configuration.
        audio_paths: Optional map of turn index to the agent's recorded audio,
            as either a `gs://` URI or a local WAV path. Used only when
            ``config.use_audio`` is set.
        latency_ms_by_turn: Optional map of turn index to the platform's
            reported perceived latency in ms. When supplied, each turn gains a
            computed ``perceivedLatency`` factor.

    Returns:
        A :class:`NaturalnessResult`, or ``None`` when there was nothing to
        grade or the grading call failed. Failures are logged rather than
        raised so the metric can never break a simulation run.
    """
    turns = extract_agent_turns(trace)
    if not turns:
        logger.info("Naturalness metric skipped: no agent turns in trace.")
        return None

    target_model = config.model or model_name
    prompt: Any = _build_prompt(turns, config)

    if config.use_audio and audio_paths:
        prompt = _build_audio_contents(prompt, turns, audio_paths)

    try:
        output: NaturalnessOutput | None = gemini_client.generate(
            prompt=prompt,
            model_name=target_model,
            response_mime_type="application/json",
            response_schema=NaturalnessOutput,
        )
    except Exception as exc:  # noqa: BLE001 - metric must never break a run
        logger.error("Naturalness grading failed: %s", exc)
        return None

    if not output or not output.turns:
        logger.warning("Naturalness grading returned no turn gradings.")
        return None

    latencies = latency_ms_by_turn or {}
    # Injected before scoring so the weighted turn mean includes latency.
    _attach_latency_factors(
        output.turns,
        latencies,
        config,
        speech_index_by_turn={t.turn_index: t.speech_index for t in turns},
    )

    graded_turns = _normalise_turns(output.turns, turns, config)
    for factor in output.conversation_factors:
        factor.score = int(min(max(factor.score, 1), 5))

    overall = _aggregate(graded_turns, output.conversation_factors, config)
    label_counts: dict[str, int] = {
        label.value: 0 for label in NaturalnessLabel
    }
    for turn in graded_turns:
        label_counts[turn.label.value] += 1

    passed = None
    if config.pass_threshold is not None:
        passed = overall >= config.pass_threshold

    # A turn slow enough to break the conversation fails the run regardless of
    # how well it scored on everything else.
    failure_reasons = _latency_failures(latencies, config)
    if failure_reasons:
        passed = False

    return NaturalnessResult(
        overall_score=overall,
        overall_label=_label_for_score(overall, config),
        turn_count=len(graded_turns),
        turns=graded_turns,
        conversation_factors=output.conversation_factors,
        factor_averages=_factor_averages(graded_turns),
        label_counts=label_counts,
        summary=output.summary,
        model=target_model,
        pass_threshold=config.pass_threshold,
        passed=passed,
        latency_ms_by_turn=latencies,
        failure_reasons=failure_reasons,
    )