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.
- simulhausen/__init__.py +95 -0
- simulhausen/adapters.py +212 -0
- simulhausen/agents.py +340 -0
- simulhausen/config.py +113 -0
- simulhausen/exceptions.py +38 -0
- simulhausen/prompts.py +105 -0
- simulhausen/py.typed +0 -0
- simulhausen/pytest_plugin.py +117 -0
- simulhausen/reporting.py +475 -0
- simulhausen/runner.py +367 -0
- simulhausen/script.py +165 -0
- simulhausen/types.py +387 -0
- simulhausen-0.1.0.dist-info/METADATA +359 -0
- simulhausen-0.1.0.dist-info/RECORD +18 -0
- simulhausen-0.1.0.dist-info/WHEEL +4 -0
- simulhausen-0.1.0.dist-info/entry_points.txt +3 -0
- simulhausen-0.1.0.dist-info/licenses/LICENSE +202 -0
- simulhausen-0.1.0.dist-info/licenses/NOTICE +22 -0
simulhausen/__init__.py
ADDED
|
@@ -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
|
+
]
|
simulhausen/adapters.py
ADDED
|
@@ -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
|
+
)
|