roadtest 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.
roadtest/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """roadtest: road-test your AI agent before it ships."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .agents import Reply, Session, ToolCall # noqa: E402
6
+ from .checks import CheckContext # noqa: E402
7
+
8
+ __all__ = ["CheckContext", "Reply", "Session", "ToolCall", "__version__"]
roadtest/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
roadtest/agents.py ADDED
@@ -0,0 +1,535 @@
1
+ """Adapters: the three ways roadtest can talk to an agent (HTTP, a Python function, a command)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+ import importlib.util
8
+ import inspect
9
+ import json
10
+ import random
11
+ import shlex
12
+ import sys
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ import httpx
19
+
20
+ from .config import AgentSpec
21
+ from .errors import AgentError, ConfigError
22
+ from .template import extract, render
23
+
24
+ RETRYABLE_STATUSES = {429, 502, 503, 504}
25
+
26
+ # Where the answer usually is when roadtest.yaml does not say. First match wins.
27
+ TEXT_GUESSES = (
28
+ "choices[0].message.content",
29
+ "output_text",
30
+ "output",
31
+ "answer",
32
+ "response",
33
+ "reply",
34
+ "text",
35
+ "message.content",
36
+ "message",
37
+ "content",
38
+ "result",
39
+ "data.output",
40
+ "data.answer",
41
+ "data.text",
42
+ )
43
+ STREAM_TEXT_GUESSES = ("choices[0].delta.content", "delta.content", "delta.text", "delta", "text", "content", "token")
44
+ TOOL_GUESSES = ("choices[0].message.tool_calls", "tool_calls")
45
+ STREAM_TOOL_GUESSES = ("choices[0].delta.tool_calls", "tool_calls")
46
+ _TOOL_NAME_KEYS = ("name", "tool", "tool_name", "function")
47
+ _TOOL_ARGUMENT_KEYS = ("arguments", "args", "input", "parameters", "tool_input")
48
+
49
+
50
+ @dataclass
51
+ class ToolCall:
52
+ name: str
53
+ arguments: Any = None
54
+
55
+
56
+ @dataclass
57
+ class Reply:
58
+ """What the agent said. `tool_calls` is None when the agent does not report its tool calls."""
59
+
60
+ text: str
61
+ tool_calls: list[ToolCall] | None = None
62
+ session_id: str | None = None
63
+ raw: Any = None
64
+
65
+
66
+ @dataclass
67
+ class Session:
68
+ """One conversation. `id` is None until the server issues it, when the server owns the ids."""
69
+
70
+ id: str | None
71
+ scenario_id: str = ""
72
+ vars: dict[str, Any] = field(default_factory=dict)
73
+ messages: list[dict[str, str]] = field(default_factory=list)
74
+ state: dict[str, Any] = field(default_factory=dict)
75
+
76
+ def variables(self, message: str) -> dict[str, Any]:
77
+ return {
78
+ "message": message,
79
+ "messages": [*self.messages, {"role": "user", "content": message}],
80
+ "history": list(self.messages),
81
+ "session_id": self.id,
82
+ "scenario_id": self.scenario_id,
83
+ "vars": self.vars,
84
+ }
85
+
86
+
87
+ @dataclass
88
+ class Exchange:
89
+ """The last request and response, kept so `roadtest probe` can show them."""
90
+
91
+ request: dict[str, Any] = field(default_factory=dict)
92
+ status: int | None = None
93
+ content_type: str = ""
94
+ body: str = ""
95
+ notes: list[str] = field(default_factory=list)
96
+
97
+
98
+ class Agent:
99
+ """Base adapter. Subclasses implement `send`."""
100
+
101
+ server_issues_session_ids = False
102
+
103
+ def __init__(self, spec: AgentSpec, base_dir: Path):
104
+ self.spec = spec
105
+ self.base_dir = base_dir
106
+ self.last_exchange = Exchange()
107
+
108
+ def new_session(self, scenario_id: str = "", variables: dict[str, Any] | None = None) -> Session:
109
+ session_id = None if self.server_issues_session_ids else str(uuid.uuid4())
110
+ return Session(id=session_id, scenario_id=scenario_id, vars=dict(variables or {}))
111
+
112
+ async def send(self, message: str, session: Session) -> Reply:
113
+ raise NotImplementedError
114
+
115
+ async def aclose(self) -> None:
116
+ return None
117
+
118
+ def describe(self) -> dict[str, str]:
119
+ return {"type": self.spec.type}
120
+
121
+
122
+ class HttpAgent(Agent):
123
+ def __init__(self, spec: AgentSpec, base_dir: Path):
124
+ super().__init__(spec, base_dir)
125
+ self.server_issues_session_ids = spec.response.session_id is not None
126
+ timeout = httpx.Timeout(spec.timeout, connect=min(10.0, spec.timeout))
127
+ self._client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
128
+
129
+ def describe(self) -> dict[str, str]:
130
+ return {"type": "http", "url": str(self.spec.url).split("?", 1)[0]}
131
+
132
+ async def aclose(self) -> None:
133
+ await self._client.aclose()
134
+
135
+ async def send(self, message: str, session: Session) -> Reply:
136
+ variables = session.variables(message)
137
+ url = render(self.spec.url, variables)
138
+ headers = render(self.spec.headers, variables)
139
+ template = self.spec.body if self.spec.body is not None else {"message": "{{message}}"}
140
+ payload = render(template, variables)
141
+ method = self.spec.method.upper()
142
+ if method == "GET":
143
+ arguments: dict[str, Any] = {"params": payload}
144
+ elif isinstance(payload, str):
145
+ arguments = {"content": payload}
146
+ else:
147
+ arguments = {"json": payload}
148
+ self.last_exchange = Exchange(request={"method": method, "url": url, "body": payload})
149
+
150
+ for attempt in range(self.spec.retries + 1):
151
+ last_try = attempt == self.spec.retries
152
+ try:
153
+ async with self._client.stream(method, url, headers=headers, **arguments) as response:
154
+ self.last_exchange.status = response.status_code
155
+ self.last_exchange.content_type = response.headers.get("content-type", "")
156
+ if response.status_code in RETRYABLE_STATUSES and not last_try:
157
+ await response.aread()
158
+ await asyncio.sleep(_backoff(attempt, response.headers.get("retry-after")))
159
+ continue
160
+ if response.status_code >= 400:
161
+ body = (await response.aread()).decode("utf-8", errors="replace")
162
+ self.last_exchange.body = body
163
+ raise AgentError(f"HTTP {response.status_code} from {url}: {_shorten(body, 300)}")
164
+ reply = await self._read(response)
165
+ except httpx.ConnectError:
166
+ if last_try:
167
+ raise AgentError(f"could not connect to {url}. Is the agent running?") from None
168
+ await asyncio.sleep(_backoff(attempt))
169
+ continue
170
+ except httpx.TimeoutException:
171
+ raise AgentError(f"no reply from {url} within {self.spec.timeout:g}s") from None
172
+ except httpx.HTTPError as error:
173
+ raise AgentError(f"request to {url} failed: {error}") from None
174
+ if reply.session_id and self.server_issues_session_ids:
175
+ session.id = reply.session_id
176
+ return reply
177
+ raise AgentError(f"no reply from {url}") # pragma: no cover - the loop always returns or raises
178
+
179
+ async def _read(self, response: httpx.Response) -> Reply:
180
+ wanted = self.spec.response.format
181
+ content_type = response.headers.get("content-type", "").lower()
182
+ if wanted == "auto":
183
+ if "text/event-stream" in content_type:
184
+ wanted = "sse"
185
+ elif "ndjson" in content_type or "jsonl" in content_type:
186
+ wanted = "jsonl"
187
+ elif "json" in content_type:
188
+ wanted = "json"
189
+ else:
190
+ wanted = "text"
191
+ if wanted in ("sse", "jsonl"):
192
+ lines = [line async for line in response.aiter_lines()]
193
+ self.last_exchange.body = "\n".join(lines)
194
+ events = _sse_events(lines) if wanted == "sse" else _json_lines(lines)
195
+ return self._from_events(events)
196
+ body = (await response.aread()).decode("utf-8", errors="replace")
197
+ self.last_exchange.body = body
198
+ if wanted == "text" and not self.spec.response.text:
199
+ return Reply(text=body.strip(), raw=body)
200
+ try:
201
+ data = json.loads(body)
202
+ except json.JSONDecodeError:
203
+ if wanted == "json" or self.spec.response.text:
204
+ raise AgentError(f"expected a JSON reply but got: {_shorten(body, 200)}") from None
205
+ return Reply(text=body.strip(), raw=body)
206
+ return self._from_json(data)
207
+
208
+ def _from_json(self, data: Any) -> Reply:
209
+ spec = self.spec.response
210
+ if spec.text:
211
+ text = extract(data, spec.text)
212
+ if text is None:
213
+ raise AgentError(f"`{spec.text}` is not in the agent's reply. {_shape(data)}")
214
+ else:
215
+ path, text = guess_text(data)
216
+ if text is None:
217
+ raise AgentError(
218
+ f"could not tell where the answer is in the agent's reply. {_shape(data)} "
219
+ "Set `agent.response.text` in roadtest.yaml to its path."
220
+ )
221
+ self.last_exchange.notes.append(f"answer found at `{path}` (set agent.response.text to pin it)")
222
+ if spec.tool_calls:
223
+ found = extract(data, spec.tool_calls)
224
+ tool_calls = normalize_tool_calls(found if isinstance(found, list) else [] if found is None else [found])
225
+ else:
226
+ path, found = _first_list(data, TOOL_GUESSES)
227
+ tool_calls = normalize_tool_calls(found) if found is not None else None
228
+ if path:
229
+ self.last_exchange.notes.append(f"tool calls found at `{path}[*]` (set agent.response.tool_calls to pin it)")
230
+ session_id = extract(data, spec.session_id) if spec.session_id else None
231
+ return Reply(text=_text(text), tool_calls=tool_calls, session_id=_optional_text(session_id), raw=data)
232
+
233
+ def _from_events(self, events: list[Any]) -> Reply:
234
+ spec = self.spec.response
235
+ pieces: list[str] = []
236
+ tool_values: list[Any] = []
237
+ saw_tool_field = bool(spec.tool_calls)
238
+ session_id = None
239
+ for event in events:
240
+ if isinstance(event, str):
241
+ if not spec.text:
242
+ pieces.append(event)
243
+ continue
244
+ if spec.text:
245
+ value = extract(event, spec.text)
246
+ else:
247
+ _, value = guess_text(event, STREAM_TEXT_GUESSES)
248
+ if isinstance(value, list):
249
+ pieces.extend(_text(item) for item in value if item not in (None, ""))
250
+ elif value not in (None, ""):
251
+ pieces.append(_text(value))
252
+ if spec.tool_calls:
253
+ found = extract(event, spec.tool_calls)
254
+ tool_values.extend(found if isinstance(found, list) else [] if found is None else [found])
255
+ else:
256
+ _, found = _first_list(event, STREAM_TOOL_GUESSES)
257
+ if found is not None:
258
+ saw_tool_field = True
259
+ tool_values.extend(found)
260
+ if spec.session_id and session_id is None:
261
+ session_id = extract(event, spec.session_id)
262
+ if not pieces:
263
+ where = f"at `{spec.text}`" if spec.text else "in any of the usual places"
264
+ raise AgentError(
265
+ f"the stream had {len(events)} events but no answer text {where}. "
266
+ "Run `roadtest probe --raw` to see the events, then set `agent.response.text`."
267
+ )
268
+ text = "".join(pieces) if spec.join == "concat" else pieces[-1]
269
+ tool_calls = normalize_tool_calls(tool_values) if saw_tool_field else None
270
+ return Reply(text=text, tool_calls=tool_calls, session_id=_optional_text(session_id), raw=events)
271
+
272
+
273
+ class PythonAgent(Agent):
274
+ """Calls `function(message)` or `function(message, session)` in this process."""
275
+
276
+ def __init__(self, spec: AgentSpec, base_dir: Path):
277
+ super().__init__(spec, base_dir)
278
+ self._function = load_callable(str(spec.entry), base_dir)
279
+ parameters = [
280
+ parameter
281
+ for parameter in inspect.signature(self._function).parameters.values()
282
+ if parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD)
283
+ ]
284
+ self._takes_session = len(parameters) >= 2
285
+
286
+ def describe(self) -> dict[str, str]:
287
+ return {"type": "python", "entry": str(self.spec.entry)}
288
+
289
+ async def send(self, message: str, session: Session) -> Reply:
290
+ arguments = (message, session) if self._takes_session else (message,)
291
+ self.last_exchange = Exchange(request={"call": str(self.spec.entry), "message": message})
292
+ try:
293
+ if inspect.iscoroutinefunction(self._function):
294
+ pending = self._function(*arguments)
295
+ else:
296
+ pending = asyncio.to_thread(self._function, *arguments)
297
+ result = await asyncio.wait_for(pending, timeout=self.spec.timeout)
298
+ except asyncio.TimeoutError:
299
+ raise AgentError(f"{self.spec.entry} did not return within {self.spec.timeout:g}s") from None
300
+ except AgentError:
301
+ raise
302
+ except Exception as error: # the user's code can raise anything
303
+ raise AgentError(f"{self.spec.entry} raised {type(error).__name__}: {error}") from error
304
+ reply = to_reply(result, f"{self.spec.entry}")
305
+ self.last_exchange.body = _shorten(repr(result), 4000)
306
+ if reply.session_id:
307
+ session.id = reply.session_id
308
+ return reply
309
+
310
+
311
+ class CommandAgent(Agent):
312
+ """Runs a command per turn. The turn goes in as JSON on stdin; the answer comes back on stdout."""
313
+
314
+ def describe(self) -> dict[str, str]:
315
+ run = self.spec.run
316
+ return {"type": "command", "run": run if isinstance(run, str) else " ".join(run or [])}
317
+
318
+ async def send(self, message: str, session: Session) -> Reply:
319
+ variables = session.variables(message)
320
+ run = self.spec.run
321
+ arguments = [str(render(part, variables)) for part in (shlex.split(run) if isinstance(run, str) else list(run or []))]
322
+ payload = json.dumps(variables, ensure_ascii=False)
323
+ self.last_exchange = Exchange(request={"run": arguments, "stdin": variables})
324
+ try:
325
+ process = await asyncio.create_subprocess_exec(
326
+ *arguments,
327
+ stdin=asyncio.subprocess.PIPE,
328
+ stdout=asyncio.subprocess.PIPE,
329
+ stderr=asyncio.subprocess.PIPE,
330
+ )
331
+ except FileNotFoundError:
332
+ raise AgentError(f"command not found: {arguments[0]}") from None
333
+ try:
334
+ stdout, stderr = await asyncio.wait_for(process.communicate(payload.encode("utf-8")), timeout=self.spec.timeout)
335
+ except asyncio.TimeoutError:
336
+ process.kill()
337
+ raise AgentError(f"`{arguments[0]}` did not finish within {self.spec.timeout:g}s") from None
338
+ output = stdout.decode("utf-8", errors="replace").strip()
339
+ self.last_exchange.body = output
340
+ if process.returncode != 0:
341
+ detail = _shorten(stderr.decode("utf-8", errors="replace").strip() or output, 300)
342
+ raise AgentError(f"`{arguments[0]}` exited with code {process.returncode}: {detail}")
343
+ if output.startswith("{"):
344
+ try:
345
+ reply = to_reply(json.loads(output), "the command's JSON output")
346
+ except json.JSONDecodeError:
347
+ reply = Reply(text=output, raw=output)
348
+ else:
349
+ reply = Reply(text=output, raw=output)
350
+ if not reply.text:
351
+ raise AgentError(f"`{arguments[0]}` printed nothing")
352
+ if reply.session_id:
353
+ session.id = reply.session_id
354
+ return reply
355
+
356
+
357
+ def build_agent(spec: AgentSpec, base_dir: Path) -> Agent:
358
+ return {"http": HttpAgent, "python": PythonAgent, "command": CommandAgent}[spec.type](spec, base_dir)
359
+
360
+
361
+ def load_callable(entry: str, base_dir: Path) -> Callable[..., Any]:
362
+ """Resolve `path/to/file.py:function` or `package.module:function`."""
363
+ target, _, name = entry.rpartition(":")
364
+ if not target or not name:
365
+ raise ConfigError(f"`{entry}` must look like `path/to/file.py:function` or `package.module:function`")
366
+ working_dir = Path.cwd()
367
+ for folder in (str(working_dir), str(base_dir)):
368
+ if folder not in sys.path:
369
+ sys.path.insert(0, folder)
370
+ if target.endswith(".py"):
371
+ candidates = [Path(target)] if Path(target).is_absolute() else [base_dir / target, working_dir / target]
372
+ path = next((candidate for candidate in candidates if candidate.is_file()), None)
373
+ if path is None:
374
+ raise ConfigError(f"cannot find {target} (looked in {base_dir} and {working_dir})")
375
+ module_name = f"_roadtest_{abs(hash(str(path.resolve())))}"
376
+ if module_name in sys.modules:
377
+ module = sys.modules[module_name]
378
+ else:
379
+ specification = importlib.util.spec_from_file_location(module_name, path)
380
+ if specification is None or specification.loader is None:
381
+ raise ConfigError(f"cannot import {path}")
382
+ module = importlib.util.module_from_spec(specification)
383
+ sys.modules[module_name] = module
384
+ try:
385
+ specification.loader.exec_module(module)
386
+ except Exception as error:
387
+ del sys.modules[module_name]
388
+ raise ConfigError(f"importing {path} failed: {type(error).__name__}: {error}") from error
389
+ else:
390
+ try:
391
+ module = importlib.import_module(target)
392
+ except Exception as error:
393
+ raise ConfigError(f"importing `{target}` failed: {type(error).__name__}: {error}") from error
394
+ function = getattr(module, name, None)
395
+ if not callable(function):
396
+ raise ConfigError(f"`{name}` is not a function in {target}")
397
+ return function
398
+
399
+
400
+ def to_reply(value: Any, source: str) -> Reply:
401
+ """Accept the shapes an adapter may return: a string, a dict, or a Reply."""
402
+ if isinstance(value, Reply):
403
+ return value
404
+ if isinstance(value, str):
405
+ return Reply(text=value, raw=value)
406
+ if isinstance(value, dict):
407
+ text = value.get("text")
408
+ if text is None:
409
+ _, text = guess_text(value)
410
+ if text is None:
411
+ raise AgentError(f"{source} returned a dict without a `text` key. {_shape(value)}")
412
+ tool_calls = normalize_tool_calls(value["tool_calls"]) if value.get("tool_calls") is not None else None
413
+ return Reply(text=_text(text), tool_calls=tool_calls, session_id=_optional_text(value.get("session_id")), raw=value)
414
+ raise AgentError(f"{source} returned {type(value).__name__}; return a string, a dict with `text`, or a roadtest.Reply")
415
+
416
+
417
+ def guess_text(data: Any, candidates: tuple[str, ...] = TEXT_GUESSES) -> tuple[str | None, str | None]:
418
+ """The first candidate path that holds a non-empty string."""
419
+ if isinstance(data, str):
420
+ return "", data
421
+ for path in candidates:
422
+ value = extract(data, path)
423
+ if isinstance(value, str) and value:
424
+ return path, value
425
+ return None, None
426
+
427
+
428
+ def normalize_tool_calls(values: list[Any] | None) -> list[ToolCall]:
429
+ """Turn whatever the agent reports (names, or objects in a few common shapes) into ToolCalls."""
430
+ calls = []
431
+ for value in values or []:
432
+ if isinstance(value, ToolCall):
433
+ calls.append(value)
434
+ elif isinstance(value, str):
435
+ calls.append(ToolCall(name=value))
436
+ elif isinstance(value, dict):
437
+ source = value
438
+ name = None
439
+ for key in _TOOL_NAME_KEYS:
440
+ candidate = value.get(key)
441
+ if isinstance(candidate, str) and candidate:
442
+ name = candidate
443
+ break
444
+ if isinstance(candidate, dict) and isinstance(candidate.get("name"), str):
445
+ name, source = candidate["name"], candidate
446
+ break
447
+ if not name:
448
+ continue # for example a streaming delta that only carries more arguments
449
+ arguments = next((source[key] for key in _TOOL_ARGUMENT_KEYS if key in source), None)
450
+ if isinstance(arguments, str):
451
+ try:
452
+ arguments = json.loads(arguments)
453
+ except json.JSONDecodeError:
454
+ pass
455
+ calls.append(ToolCall(name=name, arguments=arguments))
456
+ return calls
457
+
458
+
459
+ def _first_list(data: Any, candidates: tuple[str, ...]) -> tuple[str | None, list[Any] | None]:
460
+ for path in candidates:
461
+ value = extract(data, path)
462
+ if isinstance(value, list):
463
+ return path, value
464
+ return None, None
465
+
466
+
467
+ def _sse_events(lines: list[str]) -> list[Any]:
468
+ events: list[Any] = []
469
+ buffer: list[str] = []
470
+
471
+ def flush() -> None:
472
+ if not buffer:
473
+ return
474
+ data = "\n".join(buffer)
475
+ buffer.clear()
476
+ if data.strip() in ("", "[DONE]"):
477
+ return
478
+ try:
479
+ events.append(json.loads(data))
480
+ except json.JSONDecodeError:
481
+ events.append(data)
482
+
483
+ for line in lines:
484
+ if not line.strip():
485
+ flush()
486
+ elif line.startswith("data:"):
487
+ buffer.append(line[5:].removeprefix(" "))
488
+ flush()
489
+ return events
490
+
491
+
492
+ def _json_lines(lines: list[str]) -> list[Any]:
493
+ events: list[Any] = []
494
+ for line in lines:
495
+ if not line.strip():
496
+ continue
497
+ try:
498
+ events.append(json.loads(line))
499
+ except json.JSONDecodeError:
500
+ events.append(line)
501
+ return events
502
+
503
+
504
+ def _backoff(attempt: int, retry_after: str | None = None) -> float:
505
+ if retry_after:
506
+ try:
507
+ return min(float(retry_after), 30.0)
508
+ except ValueError:
509
+ pass
510
+ return min(0.5 * (2**attempt), 8.0) + random.uniform(0, 0.25)
511
+
512
+
513
+ def _text(value: Any) -> str:
514
+ if isinstance(value, str):
515
+ return value
516
+ if isinstance(value, list) and all(isinstance(item, str) for item in value):
517
+ return "".join(value)
518
+ return json.dumps(value, ensure_ascii=False)
519
+
520
+
521
+ def _optional_text(value: Any) -> str | None:
522
+ return None if value in (None, "") else str(value)
523
+
524
+
525
+ def _shorten(text: str, limit: int) -> str:
526
+ text = " ".join(text.split())
527
+ return text if len(text) <= limit else text[: limit - 1] + "…"
528
+
529
+
530
+ def _shape(data: Any) -> str:
531
+ if isinstance(data, dict):
532
+ return f"Top-level keys: {', '.join(map(str, list(data)[:12])) or '(none)'}."
533
+ if isinstance(data, list):
534
+ return f"The reply is a list of {len(data)} items."
535
+ return f"The reply is a {type(data).__name__}."
roadtest/checks.py ADDED
@@ -0,0 +1,130 @@
1
+ """Code checks: the expectations that need no model. Each one is a plain rule over the reply."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import inspect
7
+ import re
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .agents import ToolCall, load_callable
13
+ from .scenarios import Expect
14
+
15
+ PASS, FAIL, ERROR, SKIP = "pass", "fail", "error", "skip"
16
+
17
+ TOOLS_NOT_VISIBLE = (
18
+ "roadtest cannot see this agent's tool calls. Set `agent.response.tool_calls` in roadtest.yaml "
19
+ "(or return `tool_calls` from your adapter). See `roadtest guide setup`."
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class CheckResult:
25
+ name: str
26
+ kind: str # code | judge | custom
27
+ status: str # pass | fail | error | skip
28
+ detail: str = ""
29
+ criterion: str = "" # the full rubric text, for judge checks
30
+
31
+ def to_dict(self) -> dict[str, str]:
32
+ return asdict(self)
33
+
34
+
35
+ @dataclass
36
+ class CheckContext:
37
+ """What a custom check function receives."""
38
+
39
+ text: str
40
+ tool_calls: list[ToolCall] | None
41
+ seconds: float
42
+ messages: list[dict[str, str]] = field(default_factory=list)
43
+ scenario_id: str = ""
44
+ vars: dict[str, Any] = field(default_factory=dict)
45
+
46
+ @property
47
+ def tool_names(self) -> list[str]:
48
+ return [call.name for call in self.tool_calls or []]
49
+
50
+
51
+ def run_code_checks(expect: Expect, text: str, tool_calls: list[ToolCall] | None, seconds: float) -> list[CheckResult]:
52
+ results: list[CheckResult] = []
53
+ lowered = text.lower()
54
+
55
+ def add(name: str, passed: bool, detail: str = "") -> None:
56
+ results.append(CheckResult(name, "code", PASS if passed else FAIL, "" if passed else detail))
57
+
58
+ for needle in expect.contains:
59
+ add(f"contains {needle!r}", needle.lower() in lowered, "the reply does not contain it")
60
+ if expect.contains_any:
61
+ listed = ", ".join(repr(needle) for needle in expect.contains_any)
62
+ add(f"contains any of {listed}", any(n.lower() in lowered for n in expect.contains_any), "the reply contains none of them")
63
+ for needle in expect.not_contains:
64
+ add(f"does not contain {needle!r}", needle.lower() not in lowered, "the reply contains it")
65
+ for pattern in expect.matches:
66
+ add(f"matches /{pattern}/", re.search(pattern, text, re.IGNORECASE | re.DOTALL) is not None, "no match in the reply")
67
+
68
+ if expect.uses_tools:
69
+ if tool_calls is None:
70
+ for name in _tool_check_names(expect):
71
+ results.append(CheckResult(name, "code", ERROR, TOOLS_NOT_VISIBLE))
72
+ else:
73
+ called = [call.name for call in tool_calls]
74
+ said = f"called: {', '.join(called) or 'no tools'}"
75
+ for tool in expect.tools_called:
76
+ add(f"calls {tool}", _any_match(called, tool), said)
77
+ if expect.tools_any:
78
+ add(
79
+ f"calls any of {', '.join(expect.tools_any)}",
80
+ any(_any_match(called, tool) for tool in expect.tools_any),
81
+ said,
82
+ )
83
+ for tool in expect.tools_not_called:
84
+ label = "calls no tools" if tool == "*" else f"does not call {tool}"
85
+ add(label, not _any_match(called, tool), said)
86
+
87
+ if expect.max_seconds is not None:
88
+ add(f"replies within {expect.max_seconds:g}s", seconds <= expect.max_seconds, f"took {seconds:.1f}s")
89
+ return results
90
+
91
+
92
+ def run_custom_checks(expect: Expect, context: CheckContext, base_dir: Path) -> list[CheckResult]:
93
+ """Run each `checks:` entry. A check returns True/False, or a string that explains a failure."""
94
+ results = []
95
+ for entry in expect.checks:
96
+ name = entry.rpartition(":")[2]
97
+ try:
98
+ function = load_callable(entry, base_dir)
99
+ outcome = function(context)
100
+ if inspect.isawaitable(outcome):
101
+ raise TypeError("custom checks must be plain functions, not async")
102
+ except Exception as error:
103
+ results.append(CheckResult(name, "custom", ERROR, f"{type(error).__name__}: {error}"))
104
+ continue
105
+ detail = ""
106
+ if isinstance(outcome, tuple) and len(outcome) == 2:
107
+ outcome, detail = outcome
108
+ if isinstance(outcome, str):
109
+ results.append(CheckResult(name, "custom", FAIL, outcome))
110
+ elif outcome is None or outcome is True:
111
+ results.append(CheckResult(name, "custom", PASS))
112
+ elif outcome is False:
113
+ results.append(CheckResult(name, "custom", FAIL, str(detail or "the check returned False")))
114
+ else:
115
+ results.append(
116
+ CheckResult(name, "custom", ERROR, f"returned {type(outcome).__name__}; return True, False or a message")
117
+ )
118
+ return results
119
+
120
+
121
+ def _any_match(called: list[str], pattern: str) -> bool:
122
+ return any(fnmatch.fnmatchcase(name, pattern) for name in called)
123
+
124
+
125
+ def _tool_check_names(expect: Expect) -> list[str]:
126
+ names = [f"calls {tool}" for tool in expect.tools_called]
127
+ if expect.tools_any:
128
+ names.append(f"calls any of {', '.join(expect.tools_any)}")
129
+ names.extend("calls no tools" if tool == "*" else f"does not call {tool}" for tool in expect.tools_not_called)
130
+ return names