simulhausen 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ """simulhausen: local simulation testing for agno agents.
2
+
3
+ An LLM plays the user and holds a multi-turn dialogue with the agent under
4
+ test, then an LLM judge grades the outcome criterion by criterion (passed,
5
+ failed or inconclusive, each with its reasoning). No external services: the
6
+ helper agents run on whatever agno model you configure.
7
+
8
+ import simulhausen as sim
9
+
10
+ result = await sim.run(
11
+ name="refund request",
12
+ description="The user wants a refund for a duplicated charge.",
13
+ agents=[
14
+ sim.agno_adapter(lambda: support_agent),
15
+ sim.UserSimulatorAgent(),
16
+ sim.JudgeAgent(criteria=["The agent asks for the order number"]),
17
+ ],
18
+ script=[sim.user("I was charged twice"), sim.agent(), sim.proceed(turns=3), sim.judge()],
19
+ )
20
+ assert result.success, result.failure_summary()
21
+
22
+ The API follows LangWatch Scenario (Apache-2.0); see NOTICE.
23
+ """
24
+
25
+ from importlib.metadata import PackageNotFoundError, version
26
+
27
+ from simulhausen.adapters import agno_adapter, function_adapter
28
+ from simulhausen.agents import JudgeAgent, JudgeVerdict, UserSimulatorAgent, UserTurn
29
+ from simulhausen.config import SimulationConfig, configure, get_config
30
+ from simulhausen.exceptions import (
31
+ SimulationBackendError,
32
+ SimulationHarnessError,
33
+ SimulationStructuredOutputError,
34
+ )
35
+ from simulhausen.reporting import collector
36
+ from simulhausen.runner import run
37
+ from simulhausen.script import agent, fail, judge, message, proceed, succeed, user
38
+ from simulhausen.types import (
39
+ AgentAdapter,
40
+ AgentInput,
41
+ AgentReturnTypes,
42
+ AgentRole,
43
+ AgentTurn,
44
+ CriterionStatus,
45
+ CriterionVerdict,
46
+ JudgeScore,
47
+ Message,
48
+ Role,
49
+ ScenarioResult,
50
+ ScenarioState,
51
+ ScenarioStatus,
52
+ )
53
+
54
+ try:
55
+ __version__ = version("simulhausen")
56
+ except PackageNotFoundError: # pragma: no cover - running from a source tree without metadata
57
+ __version__ = "0.0.0"
58
+
59
+ __all__ = [
60
+ "AgentAdapter",
61
+ "AgentInput",
62
+ "AgentReturnTypes",
63
+ "AgentRole",
64
+ "AgentTurn",
65
+ "CriterionStatus",
66
+ "CriterionVerdict",
67
+ "JudgeAgent",
68
+ "JudgeScore",
69
+ "JudgeVerdict",
70
+ "Message",
71
+ "Role",
72
+ "ScenarioResult",
73
+ "ScenarioState",
74
+ "ScenarioStatus",
75
+ "SimulationBackendError",
76
+ "SimulationConfig",
77
+ "SimulationHarnessError",
78
+ "SimulationStructuredOutputError",
79
+ "UserSimulatorAgent",
80
+ "UserTurn",
81
+ "__version__",
82
+ "agent",
83
+ "agno_adapter",
84
+ "collector",
85
+ "configure",
86
+ "fail",
87
+ "function_adapter",
88
+ "get_config",
89
+ "judge",
90
+ "message",
91
+ "proceed",
92
+ "run",
93
+ "succeed",
94
+ "user",
95
+ ]
@@ -0,0 +1,212 @@
1
+ """Ready-made adapters: agno Agent/Team/Workflow, and plain functions.
2
+
3
+ ``agno_adapter`` builds the usual adapter (``arun`` in one session, an
4
+ ``AgentTurn`` with content, tools and references) in one call, so tests do not
5
+ need a wrapper class each. ``function_adapter`` wraps any sync or async
6
+ callable. Subclass :class:`~simulhausen.types.AgentAdapter` for anything else.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import inspect
13
+ import logging
14
+ from typing import TYPE_CHECKING, Any, Protocol, cast
15
+
16
+ from simulhausen.exceptions import SimulationBackendError
17
+ from simulhausen.types import AgentAdapter, AgentInput, AgentReturnTypes, AgentTurn
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Awaitable, Callable
21
+
22
+ from agno.agent import Agent
23
+ from agno.team import Team
24
+ from agno.workflow import Workflow
25
+
26
+ __all__ = ["agno_adapter", "function_adapter"]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Transient backend failures (a 504 and the like) are retried a few times: runs
31
+ # go through a live inference gateway, and one timeout must not sink a scenario.
32
+ DEFAULT_BACKEND_RETRIES = 2
33
+ DEFAULT_BACKEND_RETRY_DELAY_S = 2.0
34
+
35
+ # agno error types that mean an input/output check refused the request (a
36
+ # guardrail), which is a product answer, not an infrastructure failure. See
37
+ # agno/exceptions.py: InputCheckError.type == "input_check_error".
38
+ _CHECK_ERROR_TYPES = frozenset({"input_check_error", "output_check_error"})
39
+
40
+ _ERROR_PAGE_MARKERS = ("gateway time-out", "bad gateway", "service unavailable", "gateway timeout")
41
+
42
+
43
+ class _Runnable(Protocol):
44
+ async def arun(self, input: Any, **kwargs: Any) -> Any: ...
45
+
46
+
47
+ type AgnoTarget = Agent | Team | Workflow | Callable[[], Agent | Team | Workflow]
48
+
49
+
50
+ def _looks_like_error_page(text: str) -> bool:
51
+ """Whether the content is a gateway's HTML error page rather than a model reply."""
52
+ head = text.lstrip()[:300].lower()
53
+ if head.startswith(("<!doctype", "<html", "<head")):
54
+ return True
55
+ return any(marker in head for marker in _ERROR_PAGE_MARKERS)
56
+
57
+
58
+ def _is_check_refusal(response: Any) -> bool:
59
+ """Did the run end with an input/output check refusing the request?
60
+
61
+ On InputCheckError agno sets RunStatus=error and puts the refusal text in
62
+ ``content``. Formally an error, but for a scenario it is the product's
63
+ answer ("Sorry, I can't help with that"), which is exactly what a guardrail
64
+ scenario wants to check. Without this branch the adapter treated a
65
+ guardrail as a backend failure, retried, and raised SimulationBackendError.
66
+ """
67
+ return any(
68
+ getattr(event, "error_type", None) in _CHECK_ERROR_TYPES for event in getattr(response, "events", None) or []
69
+ )
70
+
71
+
72
+ def _backend_error_reason(response: Any) -> str | None:
73
+ """Why the run counts as a backend failure, or ``None`` when it does not.
74
+
75
+ Two cases: agno reported RunStatus=error (guardrail refusals excluded, see
76
+ ``_is_check_refusal``), or the model returned a raw HTML error page from a
77
+ proxy instead of text or a structured object.
78
+ """
79
+ status = getattr(response, "status", None)
80
+ status_name = getattr(status, "value", None) or getattr(status, "name", None) or str(status)
81
+ if isinstance(status_name, str) and status_name.lower() == "error":
82
+ if _is_check_refusal(response):
83
+ return None
84
+ return "agno RunStatus=error"
85
+ content = getattr(response, "content", None)
86
+ if isinstance(content, str) and _looks_like_error_page(content):
87
+ return "backend returned a gateway HTML error page: " + " ".join(content.split())[:120]
88
+ return None
89
+
90
+
91
+ def _collect_tools(response: Any) -> list[Any] | None:
92
+ """Tool executions of the run, team members' included.
93
+
94
+ For a Team, ``response.tools`` holds only the leader's calls (delegation),
95
+ while the domain tools live in ``member_responses[*].tools``. Without
96
+ merging them a team scenario cannot assert ``state.has_tool_call(...)`` on
97
+ a member tool. Agents have no ``member_responses``, so nothing changes there.
98
+ """
99
+ own = list(getattr(response, "tools", None) or [])
100
+ members = [
101
+ tool
102
+ for member in getattr(response, "member_responses", None) or []
103
+ for tool in getattr(member, "tools", None) or []
104
+ ]
105
+ return (own + members) or None
106
+
107
+
108
+ class _AgnoAdapter(AgentAdapter):
109
+ """Last user message -> ``target.arun`` -> ``AgentTurn``."""
110
+
111
+ def __init__(
112
+ self,
113
+ target: AgnoTarget,
114
+ arun_kwargs: dict[str, Any],
115
+ content_fn: Callable[[Any], str] | None,
116
+ retries: int,
117
+ retry_delay: float,
118
+ ) -> None:
119
+ self._target = target
120
+ self._resolved: _Runnable | None = None
121
+ self._arun_kwargs = arun_kwargs
122
+ self._content_fn = content_fn
123
+ self._retries = retries
124
+ self._retry_delay = retry_delay
125
+
126
+ def _resolve(self) -> _Runnable:
127
+ if self._resolved is None:
128
+ target = self._target
129
+ # A factory (callable without arun) is called once and cached.
130
+ resolved = target() if callable(target) and not hasattr(target, "arun") else target
131
+ self._resolved = cast("_Runnable", resolved)
132
+ return self._resolved
133
+
134
+ async def call(self, input: AgentInput) -> AgentTurn:
135
+ target = self._resolve()
136
+ message = input.last_new_user_message_str()
137
+ attempts = self._retries + 1
138
+ reason: str | None = None
139
+ response: Any = None
140
+ for attempt in range(1, attempts + 1):
141
+ response = await target.arun(message, session_id=input.thread_id, **self._arun_kwargs)
142
+ reason = _backend_error_reason(response)
143
+ if reason is None:
144
+ break
145
+ logger.warning("backend error (attempt %d/%d): %s", attempt, attempts, reason)
146
+ if attempt < attempts:
147
+ await asyncio.sleep(self._retry_delay * attempt)
148
+ else:
149
+ # Every attempt hit the backend failure: raise a typed error instead
150
+ # of passing an HTML page on as the reply and failing a tool assertion.
151
+ raise SimulationBackendError(f"backend failed {attempts} time(s) in a row: {reason}")
152
+
153
+ raw = response.content
154
+ content = self._content_fn(raw) if self._content_fn is not None else str(raw or "")
155
+ return AgentTurn(
156
+ content=content,
157
+ tools=_collect_tools(response),
158
+ references=getattr(response, "references", None),
159
+ )
160
+
161
+
162
+ def agno_adapter(
163
+ target: AgnoTarget,
164
+ *,
165
+ content_fn: Callable[[Any], str] | None = None,
166
+ retries: int = DEFAULT_BACKEND_RETRIES,
167
+ retry_delay: float = DEFAULT_BACKEND_RETRY_DELAY_S,
168
+ **arun_kwargs: Any,
169
+ ) -> AgentAdapter:
170
+ """Adapter for an agno Agent, Team or Workflow.
171
+
172
+ ``target`` is the object itself or a lazy factory (``lambda: my_agent``
173
+ with the import inside, so collecting tests does not build the agent).
174
+ Extra kwargs go to ``arun`` unchanged: ``session_state=...``,
175
+ ``user_id=...`` and so on. ``session_id`` is always the simulation's
176
+ ``thread_id``, so the whole scenario runs in one session.
177
+
178
+ ``content_fn`` renders ``response.content`` into the reply text. Agents
179
+ with ``output_schema`` need it, since their content is a Pydantic model;
180
+ otherwise the content goes through ``str(...)``.
181
+
182
+ ``retries``/``retry_delay`` control how backend failures (RunStatus=error,
183
+ a gateway HTML error page) are retried before ``SimulationBackendError``.
184
+
185
+ >>> adapter = agno_adapter(lambda: support_agent, session_state={"plan": "pro"})
186
+ """
187
+ if retries < 0:
188
+ raise ValueError("retries must be >= 0")
189
+ return _AgnoAdapter(target, arun_kwargs, content_fn=content_fn, retries=retries, retry_delay=retry_delay)
190
+
191
+
192
+ class _FunctionAdapter(AgentAdapter):
193
+ def __init__(self, fn: Callable[[AgentInput], AgentReturnTypes | Awaitable[AgentReturnTypes]]) -> None:
194
+ self._fn = fn
195
+
196
+ async def call(self, input: AgentInput) -> AgentReturnTypes:
197
+ result = self._fn(input)
198
+ if inspect.isawaitable(result):
199
+ return await result
200
+ return result
201
+
202
+
203
+ def function_adapter(
204
+ fn: Callable[[AgentInput], AgentReturnTypes | Awaitable[AgentReturnTypes]],
205
+ ) -> AgentAdapter:
206
+ """Adapter around a sync or async function ``AgentInput -> str | AgentTurn``.
207
+
208
+ >>> async def echo(input: AgentInput) -> str:
209
+ ... return input.last_new_user_message_str()
210
+ >>> adapter = function_adapter(echo)
211
+ """
212
+ return _FunctionAdapter(fn)
simulhausen/agents.py ADDED
@@ -0,0 +1,340 @@
1
+ """Helper LLM agents: the user simulator and the per-criterion judge.
2
+
3
+ Both are plain agno agents with an ``output_schema``. Prompts are adapted from
4
+ LangWatch Scenario's simulator and judge (Apache-2.0, see NOTICE).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import secrets
11
+ from typing import TYPE_CHECKING, Literal
12
+
13
+ from agno.agent import Agent
14
+ from agno.run.agent import RunOutput
15
+ from agno.scorer.judge import JudgeScorer
16
+ from pydantic import BaseModel, Field
17
+
18
+ from simulhausen.config import require_model, resolve_parser_model
19
+ from simulhausen.exceptions import SimulationStructuredOutputError
20
+ from simulhausen.prompts import (
21
+ JUDGE_DECISION_PROMPT,
22
+ JUDGE_VERDICT_PROMPT,
23
+ PER_CRITERION_RULES,
24
+ STRUCTURED_RETRY_NUDGE,
25
+ USER_SIMULATOR_PROMPT,
26
+ )
27
+ from simulhausen.types import AgentRole, CriterionVerdict, JudgeScore
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Sequence
31
+
32
+ from agno.models.base import Model
33
+
34
+ from simulhausen.types import ScenarioState
35
+
36
+ __all__ = ["JudgeAgent", "JudgeVerdict", "UserSimulatorAgent", "UserTurn"]
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Models now and then answer in free text instead of the schema. A parser model
41
+ # fixes most of it (see SimulationConfig.parser_model); the retry covers what
42
+ # remains of the nondeterminism.
43
+ STRUCTURED_ATTEMPTS = 3
44
+
45
+
46
+ def _fence(label: str, text: str) -> str:
47
+ """Wrap untrusted data in a tag with a random per-call suffix.
48
+
49
+ User text, RAG hits and tool results may contain instructions or closing
50
+ XML tags. A random suffix makes the closing tag unguessable from inside the
51
+ data, the same idea as agno's hardened judge prompt.
52
+ """
53
+ tag = f"untrusted_{label}_{secrets.token_hex(16)}"
54
+ return f"<{tag}>\n{text}\n</{tag}>"
55
+
56
+
57
+ async def _arun_structured[T](agent: Agent, prompt: str, schema: type[T], *, scenario: str, what: str) -> T:
58
+ """Run the agent and demand a schema object; retry with a format nudge if it is not one."""
59
+ for attempt in range(1, STRUCTURED_ATTEMPTS + 1):
60
+ attempt_prompt = prompt if attempt == 1 else prompt + STRUCTURED_RETRY_NUDGE
61
+ response = await agent.arun(attempt_prompt)
62
+ content = response.content
63
+ if isinstance(content, schema):
64
+ return content
65
+ logger.warning(
66
+ "%s returned %s instead of %s (scenario %r, attempt %d/%d)",
67
+ what,
68
+ type(content).__name__,
69
+ schema.__name__,
70
+ scenario,
71
+ attempt,
72
+ STRUCTURED_ATTEMPTS,
73
+ )
74
+ raise SimulationStructuredOutputError(
75
+ f"{what} returned no structured output in {STRUCTURED_ATTEMPTS} attempts (scenario {scenario!r})"
76
+ )
77
+
78
+
79
+ def _numbered(criteria: Sequence[str]) -> str:
80
+ return "\n".join(f"{index}. {criterion}" for index, criterion in enumerate(criteria, 1))
81
+
82
+
83
+ # -- user simulator ------------------------------------------------------------
84
+
85
+
86
+ class UserTurn(BaseModel):
87
+ """One simulated user turn: the message plus a "goal reached" signal."""
88
+
89
+ message: str = Field(description="The user's next message, without quotes or commentary")
90
+ goal_achieved: bool = Field(
91
+ description="true when the scenario's goal is already reached and the dialogue should stop"
92
+ )
93
+
94
+
95
+ class UserSimulatorAgent:
96
+ """An LLM playing the user: writes the next user message from the scenario and transcript.
97
+
98
+ ``next_turn`` also returns ``goal_achieved``, so unscripted runs stop as
99
+ soon as the scenario's goal is reached instead of padding the dialogue
100
+ with polite filler up to ``max_turns``.
101
+
102
+ ``system_prompt`` replaces the built-in prompt entirely (the scenario
103
+ description is then up to you to include); ``persona`` is appended to
104
+ whichever prompt is used.
105
+ """
106
+
107
+ role: AgentRole = AgentRole.USER
108
+
109
+ def __init__(
110
+ self,
111
+ model: Model | None = None,
112
+ *,
113
+ parser_model: Model | None = None,
114
+ system_prompt: str | None = None,
115
+ persona: str | None = None,
116
+ ) -> None:
117
+ self._model = model
118
+ self._parser_model = parser_model
119
+ self.system_prompt = system_prompt
120
+ self.persona = persona
121
+
122
+ def instructions(self, state: ScenarioState) -> str:
123
+ prompt = self.system_prompt or USER_SIMULATOR_PROMPT.format(description=state.description)
124
+ if self.persona:
125
+ prompt += f"\n<persona>\n{self.persona}\n</persona>\n"
126
+ return prompt
127
+
128
+ async def next_turn(self, state: ScenarioState) -> UserTurn:
129
+ agent = Agent(
130
+ model=require_model(self._model),
131
+ instructions=self.instructions(state),
132
+ output_schema=UserTurn,
133
+ parser_model=resolve_parser_model(self._parser_model),
134
+ telemetry=False,
135
+ )
136
+ transcript = state.transcript() or "(the dialogue has not started yet)"
137
+ turn = await _arun_structured(
138
+ agent,
139
+ f"Dialogue so far:\n{transcript}\n\nThe user's next message:",
140
+ UserTurn,
141
+ scenario=state.name,
142
+ what="user simulator",
143
+ )
144
+ message = turn.message.strip()
145
+ if not (message or turn.goal_achieved):
146
+ raise SimulationStructuredOutputError(f"user simulator returned an empty turn (scenario {state.name!r})")
147
+ return UserTurn(message=message, goal_achieved=turn.goal_achieved)
148
+
149
+ async def next_message(self, state: ScenarioState) -> str:
150
+ """A message for a script ``user()`` step without content."""
151
+ turn = await self.next_turn(state)
152
+ if not turn.message:
153
+ raise SimulationStructuredOutputError(f"user simulator returned an empty message (scenario {state.name!r})")
154
+ return turn.message
155
+
156
+
157
+ # -- judge ---------------------------------------------------------------------
158
+
159
+
160
+ class JudgeVerdict(BaseModel):
161
+ """The judge's structured answer: one verdict per criterion plus a summary."""
162
+
163
+ verdicts: list[CriterionVerdict] = Field(description="Exactly one verdict per criterion, in the given order")
164
+ summary: str = Field(description="One or two sentences on the overall outcome")
165
+
166
+
167
+ class _TurnDecision(BaseModel):
168
+ """Mid-dialogue decision: keep talking or conclude.
169
+
170
+ No reasoning field on purpose: writing one pushes the judge to commit to a
171
+ pass or fail before the evidence is in, and the text is wasted tokens for a
172
+ binary transition (the same choice LangWatch Scenario made).
173
+ """
174
+
175
+ decision: Literal["continue", "conclude"] = Field(
176
+ description="continue: the dialogue needs another user turn; conclude: there is enough to judge"
177
+ )
178
+
179
+
180
+ class JudgeAgent:
181
+ """LLM judge with per-criterion verdicts and an optional numeric grade.
182
+
183
+ Each criterion gets ``passed``, ``failed`` or ``inconclusive`` with its own
184
+ reasoning. ``criteria`` may be empty when the scenario only uses inline
185
+ ``judge(criteria=[...])`` checkpoints.
186
+
187
+ ``score_threshold`` (1-10) additionally runs agno's ``JudgeScorer`` over the
188
+ whole dialogue and fails the scenario below the threshold. It is opt-in so a
189
+ numeric cut-off never changes results before it is calibrated.
190
+
191
+ ``system_prompt`` replaces the built-in verdict prompt; the dialogue and the
192
+ evidence are still sent as the message, fenced as untrusted data.
193
+ """
194
+
195
+ role: AgentRole = AgentRole.JUDGE
196
+
197
+ def __init__(
198
+ self,
199
+ criteria: Sequence[str] = (),
200
+ model: Model | None = None,
201
+ *,
202
+ parser_model: Model | None = None,
203
+ system_prompt: str | None = None,
204
+ score_threshold: int | None = None,
205
+ ) -> None:
206
+ if score_threshold is not None and not 1 <= score_threshold <= 10:
207
+ raise ValueError("score_threshold must be between 1 and 10")
208
+ self.criteria = list(criteria)
209
+ self._model = model
210
+ self._parser_model = parser_model
211
+ self.system_prompt = system_prompt
212
+ self.score_threshold = score_threshold
213
+
214
+ def _agent(self, instructions: str, schema: type[BaseModel]) -> Agent:
215
+ return Agent(
216
+ model=require_model(self._model),
217
+ instructions=instructions,
218
+ output_schema=schema,
219
+ parser_model=resolve_parser_model(self._parser_model),
220
+ telemetry=False,
221
+ )
222
+
223
+ @staticmethod
224
+ def evidence(state: ScenarioState, additional_context: str | None = None) -> str:
225
+ """The dialogue plus what the agent saw and did, each fenced as untrusted data.
226
+
227
+ The judge sees the same reference material (RAG) as the agent, so
228
+ grounding criteria are checked against the real retrieval rather than
229
+ a hand-written answer key. Tool executions let it tell facts that came
230
+ from a tool apart from hallucinations.
231
+ """
232
+ prompt = "Dialogue:\n" + _fence("transcript", state.transcript() or "(empty)")
233
+ if references := state.references_text():
234
+ prompt += "\n\nReference material the agent saw:\n" + _fence("references", references)
235
+ if tool_evidence := state.tool_evidence_text():
236
+ prompt += "\n\nTool executions:\n" + _fence("tools", tool_evidence)
237
+ if additional_context:
238
+ prompt += "\n\nAdditional context from the test:\n" + _fence("context", additional_context)
239
+ return prompt
240
+
241
+ async def judge(
242
+ self,
243
+ state: ScenarioState,
244
+ criteria: Sequence[str] | None = None,
245
+ *,
246
+ additional_context: str | None = None,
247
+ ) -> JudgeVerdict:
248
+ """Verdict on ``criteria`` (the judge's own when ``None``), one per criterion.
249
+
250
+ ``additional_context`` is extra evidence the dialogue does not show, for
251
+ example the state of a database or a file the agent should have changed.
252
+ """
253
+ criteria = list(self.criteria if criteria is None else criteria)
254
+ if not criteria:
255
+ raise ValueError("the judge has no criteria: pass JudgeAgent(criteria=[...]) or judge(criteria=[...])")
256
+ instructions = self.system_prompt or JUDGE_VERDICT_PROMPT.format(
257
+ description=state.description,
258
+ criteria=_numbered(criteria),
259
+ per_criterion_rules=PER_CRITERION_RULES,
260
+ )
261
+ judged = await _arun_structured(
262
+ self._agent(instructions, JudgeVerdict),
263
+ self.evidence(state, additional_context),
264
+ JudgeVerdict,
265
+ scenario=state.name,
266
+ what="judge",
267
+ )
268
+ return JudgeVerdict(verdicts=self._align(state, criteria, judged.verdicts), summary=judged.summary)
269
+
270
+ async def evaluate(
271
+ self,
272
+ state: ScenarioState,
273
+ criteria: Sequence[str] | None = None,
274
+ *,
275
+ additional_context: str | None = None,
276
+ ) -> list[CriterionVerdict]:
277
+ """Just the per-criterion verdicts of :meth:`judge`."""
278
+ return (await self.judge(state, criteria, additional_context=additional_context)).verdicts
279
+
280
+ @staticmethod
281
+ def _align(state: ScenarioState, criteria: list[str], verdicts: list[CriterionVerdict]) -> list[CriterionVerdict]:
282
+ """One verdict per criterion, in order, with the criterion text restored verbatim.
283
+
284
+ Models paraphrase, drop or duplicate criteria. With a matching count the
285
+ order is trusted; otherwise verdicts are matched by exact text and a
286
+ criterion left without one becomes inconclusive.
287
+ """
288
+ if len(verdicts) == len(criteria):
289
+ return [v.model_copy(update={"criterion": c}) for c, v in zip(criteria, verdicts, strict=True)]
290
+ logger.warning(
291
+ "judge returned %d verdicts for %d criteria (scenario %r)", len(verdicts), len(criteria), state.name
292
+ )
293
+ by_text = {v.criterion: v for v in verdicts}
294
+ return [
295
+ by_text.get(c)
296
+ or CriterionVerdict(criterion=c, status="inconclusive", reasoning="the judge returned no verdict for it")
297
+ for c in criteria
298
+ ]
299
+
300
+ async def decide(self, state: ScenarioState) -> bool:
301
+ """Unscripted runs: ``True`` when the dialogue holds enough for a verdict."""
302
+ if not self.criteria:
303
+ return False
304
+ agent = self._agent(
305
+ JUDGE_DECISION_PROMPT.format(description=state.description, criteria=_numbered(self.criteria)),
306
+ _TurnDecision,
307
+ )
308
+ response = await agent.arun(self.evidence(state))
309
+ decision = response.content
310
+ if not isinstance(decision, _TurnDecision):
311
+ # Unparsed decision: keep talking until max_turns, the final verdict decides.
312
+ logger.warning("judge decision could not be parsed (scenario %r)", state.name)
313
+ return False
314
+ return decision.decision == "conclude"
315
+
316
+ async def score(self, state: ScenarioState) -> JudgeScore | None:
317
+ """Grade the whole dialogue 1-10 with agno's hardened ``JudgeScorer`` (when enabled)."""
318
+ if self.score_threshold is None:
319
+ return None
320
+ scorer = JudgeScorer(
321
+ model=require_model(self._model),
322
+ criteria=(
323
+ f"Scenario:\n{state.description}\n\n"
324
+ "The dialogue must satisfy all of the following criteria:\n"
325
+ f"{_numbered(self.criteria)}"
326
+ ),
327
+ mode="numeric",
328
+ threshold=self.score_threshold,
329
+ )
330
+ # agno fences both the run output and the reference answer with nonce
331
+ # blocks, so dialogue text and RAG data cannot rewrite the judge's task.
332
+ score = await scorer.ascore(RunOutput(content=state.transcript()), expected=state.references_text() or None)
333
+ raw_score = int((score.detail or {}).get("raw_score", round(score.value * 9 + 1)))
334
+ return JudgeScore(
335
+ value=score.value,
336
+ raw_score=raw_score,
337
+ threshold=self.score_threshold,
338
+ passed=score.passed,
339
+ reasoning=score.reason,
340
+ )