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 +8 -0
- roadtest/__main__.py +3 -0
- roadtest/agents.py +535 -0
- roadtest/checks.py +130 -0
- roadtest/cli.py +523 -0
- roadtest/config.py +246 -0
- roadtest/demo/__init__.py +1 -0
- roadtest/demo/agent.py +147 -0
- roadtest/demo/scenarios/support.yaml +126 -0
- roadtest/errors.py +27 -0
- roadtest/judge.py +113 -0
- roadtest/llm.py +167 -0
- roadtest/report.py +204 -0
- roadtest/results.py +151 -0
- roadtest/runner.py +248 -0
- roadtest/scaffold.py +149 -0
- roadtest/scenarios.py +277 -0
- roadtest/simulate.py +52 -0
- roadtest/skill/SKILL.md +65 -0
- roadtest/skill/references/ci.md +114 -0
- roadtest/skill/references/scenarios.md +188 -0
- roadtest/skill/references/setup.md +237 -0
- roadtest/skill/references/triage.md +65 -0
- roadtest/template.py +122 -0
- roadtest/templates/report.html +620 -0
- roadtest-0.1.0.dist-info/METADATA +331 -0
- roadtest-0.1.0.dist-info/RECORD +30 -0
- roadtest-0.1.0.dist-info/WHEEL +4 -0
- roadtest-0.1.0.dist-info/entry_points.txt +2 -0
- roadtest-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|