trajtest 0.1.1__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.
- trajtest/__init__.py +61 -0
- trajtest/cli.py +138 -0
- trajtest/errors.py +21 -0
- trajtest/expect.py +421 -0
- trajtest/matchers.py +227 -0
- trajtest/pytest_plugin.py +11 -0
- trajtest/recorder.py +326 -0
- trajtest/trace_io.py +120 -0
- trajtest/world.py +376 -0
- trajtest-0.1.1.dist-info/METADATA +176 -0
- trajtest-0.1.1.dist-info/RECORD +15 -0
- trajtest-0.1.1.dist-info/WHEEL +5 -0
- trajtest-0.1.1.dist-info/entry_points.txt +5 -0
- trajtest-0.1.1.dist-info/licenses/LICENSE +21 -0
- trajtest-0.1.1.dist-info/top_level.txt +1 -0
trajtest/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""trajtest — pytest for agent trajectories.
|
|
2
|
+
|
|
3
|
+
Assert on what an agent *did* (tool calls, order, args, world side effects),
|
|
4
|
+
not just what it *said*. Local-first, deterministic, no LLM-as-judge.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from trajtest.errors import EvalFailure, MaxStepsExceeded, UnmockedRequestError
|
|
8
|
+
from trajtest.expect import Expect
|
|
9
|
+
from trajtest.matchers import (
|
|
10
|
+
ANY,
|
|
11
|
+
Contains,
|
|
12
|
+
DictSubset,
|
|
13
|
+
EndsWith,
|
|
14
|
+
Ge,
|
|
15
|
+
Gt,
|
|
16
|
+
InstanceOf,
|
|
17
|
+
Le,
|
|
18
|
+
Lt,
|
|
19
|
+
Matcher,
|
|
20
|
+
Predicate,
|
|
21
|
+
Regex,
|
|
22
|
+
StartsWith,
|
|
23
|
+
)
|
|
24
|
+
from trajtest.recorder import TOOLS, Note, ToolCall, Trajectory, run, sample
|
|
25
|
+
from trajtest.trace_io import load_trace
|
|
26
|
+
from trajtest.world import Clock, FakeFS, FakeHTTP, FakeResponse, KVStore, World
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.1"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ANY",
|
|
32
|
+
"TOOLS",
|
|
33
|
+
"Clock",
|
|
34
|
+
"Contains",
|
|
35
|
+
"DictSubset",
|
|
36
|
+
"EndsWith",
|
|
37
|
+
"EvalFailure",
|
|
38
|
+
"Expect",
|
|
39
|
+
"FakeFS",
|
|
40
|
+
"FakeHTTP",
|
|
41
|
+
"FakeResponse",
|
|
42
|
+
"Ge",
|
|
43
|
+
"Gt",
|
|
44
|
+
"InstanceOf",
|
|
45
|
+
"KVStore",
|
|
46
|
+
"Le",
|
|
47
|
+
"Lt",
|
|
48
|
+
"Matcher",
|
|
49
|
+
"MaxStepsExceeded",
|
|
50
|
+
"Note",
|
|
51
|
+
"Predicate",
|
|
52
|
+
"Regex",
|
|
53
|
+
"StartsWith",
|
|
54
|
+
"ToolCall",
|
|
55
|
+
"Trajectory",
|
|
56
|
+
"UnmockedRequestError",
|
|
57
|
+
"World",
|
|
58
|
+
"load_trace",
|
|
59
|
+
"run",
|
|
60
|
+
"sample",
|
|
61
|
+
]
|
trajtest/cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""trajtest CLI: scaffold evals and run them."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from trajtest import __version__
|
|
7
|
+
|
|
8
|
+
SCAFFOLD = '''"""Example trajectory eval. Run with: trajtest run __SCAFFOLD_NAME__"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from trajtest import EvalFailure, Expect, World, run
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def helpful_agent(task, tools):
|
|
16
|
+
"""A well-behaved agent: fetches data, saves a report, says what it did."""
|
|
17
|
+
resp = tools.http_get("https://api.example.com/rows")
|
|
18
|
+
rows = resp.json()["rows"]
|
|
19
|
+
total = sum(rows)
|
|
20
|
+
tools.fs_write("/out/report.md", f"# Report\\n\\ntotal: {total}\\n")
|
|
21
|
+
tools.note("saved the report with the computed total")
|
|
22
|
+
return f"done, total={total}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_world():
|
|
26
|
+
world = World()
|
|
27
|
+
world.http.mock("GET", "https://api.example.com/rows", json={"rows": [1, 2, 3]})
|
|
28
|
+
return world
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_agent_fetches_then_saves():
|
|
32
|
+
traj = run(helpful_agent, "summarize the rows", make_world(), max_steps=10)
|
|
33
|
+
(Expect(traj)
|
|
34
|
+
.called("http_get", url__contains="api.example.com")
|
|
35
|
+
.then("fs_write", path="/out/report.md")
|
|
36
|
+
.never_called("fs_delete")
|
|
37
|
+
.completed_within(10)
|
|
38
|
+
.finished()
|
|
39
|
+
.world_file("/out/report.md", contains="total: 6")
|
|
40
|
+
.assert_all())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_agent_does_not_lie_about_saving():
|
|
44
|
+
"""The narration claims a save; the mock world proves otherwise.
|
|
45
|
+
|
|
46
|
+
This test *expects* EvalFailure — that IS the demo: trajtest caught the
|
|
47
|
+
confident false report. Green suite, lying agent exposed.
|
|
48
|
+
"""
|
|
49
|
+
def liar(task, tools):
|
|
50
|
+
tools.note("saved the report, all done!")
|
|
51
|
+
return "saved the report"
|
|
52
|
+
|
|
53
|
+
traj = run(liar, "summarize the rows", make_world(), max_steps=10)
|
|
54
|
+
with pytest.raises(EvalFailure):
|
|
55
|
+
(Expect(traj)
|
|
56
|
+
.finished()
|
|
57
|
+
# Signature is world(description, predicate) — describe first.
|
|
58
|
+
.world("report actually exists in the world",
|
|
59
|
+
lambda w: w.fs.exists("/out/report.md"))
|
|
60
|
+
.assert_all())
|
|
61
|
+
'''
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_init(args) -> int:
|
|
65
|
+
import os
|
|
66
|
+
|
|
67
|
+
os.makedirs(args.dir, exist_ok=True)
|
|
68
|
+
name = "test_trajtest_example.py"
|
|
69
|
+
path = os.path.join(args.dir, name)
|
|
70
|
+
if os.path.exists(path) and not args.force:
|
|
71
|
+
print(f"{path} already exists (use --force to overwrite)")
|
|
72
|
+
return 1
|
|
73
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
74
|
+
f.write(SCAFFOLD.replace("__SCAFFOLD_NAME__", name))
|
|
75
|
+
print(f"wrote {path}")
|
|
76
|
+
print("run it with: trajtest run " + path)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_run(args) -> int:
|
|
81
|
+
import pytest
|
|
82
|
+
|
|
83
|
+
# The pytest11 entry point auto-loads trajtest.pytest_plugin, so no
|
|
84
|
+
# explicit -p flag (which would double-register on repeated in-process runs).
|
|
85
|
+
# importlib mode: repeated in-process runs of files with the same basename
|
|
86
|
+
# (e.g. two scaffolds in different dirs) must not collide in sys.modules,
|
|
87
|
+
# which made pytest return INTERRUPTED intermittently.
|
|
88
|
+
argv = [args.path, "--import-mode=importlib", "-p", "no:cacheprovider"]
|
|
89
|
+
if args.k:
|
|
90
|
+
argv += ["-k", args.k]
|
|
91
|
+
if args.verbose:
|
|
92
|
+
argv.append("-v")
|
|
93
|
+
else:
|
|
94
|
+
argv.append("-q")
|
|
95
|
+
argv += args.pytest_args
|
|
96
|
+
print(f"trajtest run: {args.path}")
|
|
97
|
+
code = pytest.main(argv)
|
|
98
|
+
if code == 0:
|
|
99
|
+
print("trajtest: all evals passed")
|
|
100
|
+
else:
|
|
101
|
+
print("trajtest: some evals failed (see above)")
|
|
102
|
+
return code
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv=None) -> int:
|
|
106
|
+
parser = argparse.ArgumentParser(
|
|
107
|
+
prog="trajtest", description="pytest for agent trajectories"
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--version", action="version", version=f"trajtest {__version__}"
|
|
111
|
+
)
|
|
112
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
113
|
+
|
|
114
|
+
p_init = sub.add_parser("init", help="scaffold an example eval file")
|
|
115
|
+
p_init.add_argument("--dir", default=".", help="directory to write into")
|
|
116
|
+
p_init.add_argument("--force", action="store_true", help="overwrite existing file")
|
|
117
|
+
p_init.set_defaults(func=cmd_init)
|
|
118
|
+
|
|
119
|
+
p_run = sub.add_parser("run", help="run trajectory evals with pytest")
|
|
120
|
+
p_run.add_argument("path", help="test file or directory")
|
|
121
|
+
p_run.add_argument("-k", help="pytest -k expression")
|
|
122
|
+
p_run.add_argument("-v", "--verbose", action="store_true")
|
|
123
|
+
p_run.add_argument(
|
|
124
|
+
"pytest_args",
|
|
125
|
+
nargs=argparse.REMAINDER,
|
|
126
|
+
help="extra args passed to pytest (after --)",
|
|
127
|
+
)
|
|
128
|
+
p_run.set_defaults(func=cmd_run)
|
|
129
|
+
|
|
130
|
+
args = parser.parse_args(argv)
|
|
131
|
+
# strip a leading "--" from passthrough args
|
|
132
|
+
if getattr(args, "pytest_args", None) and args.pytest_args[:1] == ["--"]:
|
|
133
|
+
args.pytest_args = args.pytest_args[1:]
|
|
134
|
+
return args.func(args)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
sys.exit(main())
|
trajtest/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Errors raised by trajtest."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EvalFailure(AssertionError):
|
|
5
|
+
"""Raised by Expect.assert_all() when one or more assertions fail.
|
|
6
|
+
|
|
7
|
+
Carries the full fail-collected report: every assertion outcome plus a
|
|
8
|
+
trajectory transcript, so eval authors debug in one pass.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, report: str):
|
|
12
|
+
super().__init__(report)
|
|
13
|
+
self.report = report
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MaxStepsExceeded(Exception):
|
|
17
|
+
"""Raised inside run() when the agent exceeds its step budget."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnmockedRequestError(Exception):
|
|
21
|
+
"""Raised by FakeHTTP when no mock matches a request (strict mode)."""
|
trajtest/expect.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""The Expect DSL: fail-collecting assertions over a trajectory.
|
|
2
|
+
|
|
3
|
+
Every method records its outcome and returns self, so evals read as a chain::
|
|
4
|
+
|
|
5
|
+
Expect(traj) \\
|
|
6
|
+
.called("http_get", url__contains="api.example.com") \\
|
|
7
|
+
.then("fs_write", path="/out/report.md") \\
|
|
8
|
+
.never_called("fs_delete") \\
|
|
9
|
+
.completed_within(15) \\
|
|
10
|
+
.world_file("/out/report.md", contains="total") \\
|
|
11
|
+
.assert_all()
|
|
12
|
+
|
|
13
|
+
assert_all() reports EVERYTHING that failed — plus a trajectory transcript —
|
|
14
|
+
in a single EvalFailure. check() returns the violations without raising.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from trajtest import matchers as m
|
|
21
|
+
from trajtest.errors import EvalFailure
|
|
22
|
+
|
|
23
|
+
_MISSING = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Violation:
|
|
28
|
+
ok: bool
|
|
29
|
+
description: str
|
|
30
|
+
detail: str = ""
|
|
31
|
+
|
|
32
|
+
def line(self) -> str:
|
|
33
|
+
mark = "✓" if self.ok else "✗"
|
|
34
|
+
text = f" {mark} {self.description}"
|
|
35
|
+
if not self.ok and self.detail:
|
|
36
|
+
text += f"\n {self.detail}"
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def call_matches(call, args, kwargs) -> bool:
|
|
41
|
+
"""Prefix-match positionals; subset-match kwargs by parameter name.
|
|
42
|
+
|
|
43
|
+
Kwarg matching uses the call's bound arguments, so
|
|
44
|
+
called("http_get", url__contains="...") matches whether the agent
|
|
45
|
+
passed the URL positionally or by keyword.
|
|
46
|
+
"""
|
|
47
|
+
if len(args) > len(call.args):
|
|
48
|
+
return False
|
|
49
|
+
for expected, actual in zip(args, call.args):
|
|
50
|
+
if not m.to_matcher(expected).matches(actual):
|
|
51
|
+
return False
|
|
52
|
+
bound = call.bound or {}
|
|
53
|
+
for key, expected in kwargs.items():
|
|
54
|
+
name, matcher = m.parse_kwarg(key, expected)
|
|
55
|
+
actual = bound.get(name, _MISSING)
|
|
56
|
+
if actual is _MISSING:
|
|
57
|
+
actual = call.kwargs.get(name, _MISSING)
|
|
58
|
+
if actual is _MISSING or not matcher.matches(actual):
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fmt_call_args(call) -> str:
|
|
64
|
+
parts = [repr(a) for a in call.args]
|
|
65
|
+
parts += [f"{k}={v!r}" for k, v in call.kwargs.items()]
|
|
66
|
+
return ", ".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Expect:
|
|
70
|
+
def __init__(self, traj):
|
|
71
|
+
self.traj = traj
|
|
72
|
+
self._violations: list[Violation] = []
|
|
73
|
+
self._chain_cursor = 0 # for then()/then_immediately() chains
|
|
74
|
+
|
|
75
|
+
# -- internal ------------------------------------------------------
|
|
76
|
+
def _record(self, ok: bool, description: str, detail: str = "") -> "Expect":
|
|
77
|
+
self._violations.append(Violation(ok, description, detail))
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def _matching(self, name, args, kwargs):
|
|
81
|
+
return [c for c in self.traj.calls_to(name) if call_matches(c, args, kwargs)]
|
|
82
|
+
|
|
83
|
+
def _require_world(self, assertion: str):
|
|
84
|
+
world = getattr(self.traj, "world", None)
|
|
85
|
+
if world is None and getattr(self.traj, "loaded_from", None):
|
|
86
|
+
raise EvalFailure(
|
|
87
|
+
f"{assertion} needs a mock World; this trajectory was loaded "
|
|
88
|
+
"from a recording"
|
|
89
|
+
)
|
|
90
|
+
if world is None:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"this assertion needs the mock world, but the trajectory "
|
|
93
|
+
"has none attached (trajectories from trajtest.run() always do)"
|
|
94
|
+
)
|
|
95
|
+
return world
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _coerce_result(value):
|
|
99
|
+
if isinstance(value, tuple) and len(value) == 2:
|
|
100
|
+
return bool(value[0]), str(value[1])
|
|
101
|
+
return bool(value), ""
|
|
102
|
+
|
|
103
|
+
# -- existence -----------------------------------------------------
|
|
104
|
+
def called(self, name: str, *args, **kwargs) -> "Expect":
|
|
105
|
+
"""At least one call to `name` matching the given args.
|
|
106
|
+
|
|
107
|
+
Positional args are prefix-matched; kwargs are subset-matched.
|
|
108
|
+
`url__contains="x"` style suffixes build matchers (see matchers).
|
|
109
|
+
"""
|
|
110
|
+
hits = self._matching(name, args, kwargs)
|
|
111
|
+
desc = f'called("{name}"{self._fmt_expect(args, kwargs)})'
|
|
112
|
+
if hits:
|
|
113
|
+
return self._record(True, desc)
|
|
114
|
+
detail = (
|
|
115
|
+
f"no call to {name!r} matched; "
|
|
116
|
+
f"{len(self.traj.calls_to(name))} call(s) to {name!r} total, "
|
|
117
|
+
f"{self.traj.steps} step(s) overall"
|
|
118
|
+
)
|
|
119
|
+
return self._record(False, desc, detail)
|
|
120
|
+
|
|
121
|
+
# alias: reads better in some evals
|
|
122
|
+
called_with = called
|
|
123
|
+
|
|
124
|
+
def called_times(self, name: str, n: int) -> "Expect":
|
|
125
|
+
count = len(self.traj.calls_to(name))
|
|
126
|
+
return self._record(
|
|
127
|
+
count == n,
|
|
128
|
+
f'called_times("{name}", {n})',
|
|
129
|
+
f"found {count}" if count != n else "",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def at_least(self, n: int, name: str) -> "Expect":
|
|
133
|
+
count = len(self.traj.calls_to(name))
|
|
134
|
+
return self._record(
|
|
135
|
+
count >= n,
|
|
136
|
+
f'at_least({n}, "{name}")',
|
|
137
|
+
f"found {count}" if count < n else "",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def at_most(self, n: int, name: str) -> "Expect":
|
|
141
|
+
count = len(self.traj.calls_to(name))
|
|
142
|
+
return self._record(
|
|
143
|
+
count <= n, f'at_most({n}, "{name}")', f"found {count}" if count > n else ""
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def no_repeated_calls(self, max_repeats: int = 2, name: str | None = None) -> "Expect":
|
|
147
|
+
"""Loop detector: no identical (tool, args) call more than max_repeats times.
|
|
148
|
+
|
|
149
|
+
Args are normalised by parameter name, so a positional and a keyword
|
|
150
|
+
call with the same value count as the same call. Optionally scoped
|
|
151
|
+
to one tool `name`.
|
|
152
|
+
"""
|
|
153
|
+
counts: dict = {}
|
|
154
|
+
for call in self.traj.calls:
|
|
155
|
+
if name is not None and call.name != name:
|
|
156
|
+
continue
|
|
157
|
+
args = call.bound or {"args": call.args, "kwargs": call.kwargs}
|
|
158
|
+
key = (call.name, json.dumps(args, sort_keys=True, default=repr))
|
|
159
|
+
counts[key] = counts.get(key, 0) + 1
|
|
160
|
+
over = [f"{tool}({args}) x{n}" for (tool, args), n in counts.items() if n > max_repeats]
|
|
161
|
+
scope = f', name="{name}"' if name else ""
|
|
162
|
+
return self._record(
|
|
163
|
+
not over,
|
|
164
|
+
f"no_repeated_calls(max_repeats={max_repeats}{scope})",
|
|
165
|
+
"repeated: " + "; ".join(over) if over else "",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def never_called(self, name: str, *args, **kwargs) -> "Expect":
|
|
169
|
+
"""Forbid calls to `name` — optionally only those matching args."""
|
|
170
|
+
hits = self._matching(name, args, kwargs)
|
|
171
|
+
desc = f'never_called("{name}"{self._fmt_expect(args, kwargs)})'
|
|
172
|
+
if not hits:
|
|
173
|
+
return self._record(True, desc)
|
|
174
|
+
shown = "; ".join(f"#{c.step} {c.name}({_fmt_call_args(c)})" for c in hits[:3])
|
|
175
|
+
return self._record(False, desc, f"forbidden call(s): {shown}")
|
|
176
|
+
|
|
177
|
+
# -- order ---------------------------------------------------------
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _norm_item(item):
|
|
180
|
+
if isinstance(item, str):
|
|
181
|
+
return item, (), {}
|
|
182
|
+
if len(item) == 2:
|
|
183
|
+
name, kwargs = item
|
|
184
|
+
return name, (), dict(kwargs)
|
|
185
|
+
name, args, kwargs = item
|
|
186
|
+
return name, tuple(args), dict(kwargs)
|
|
187
|
+
|
|
188
|
+
def in_order(self, *items) -> "Expect":
|
|
189
|
+
"""Tool names (or (name, kwargs) / (name, args, kwargs) tuples)
|
|
190
|
+
appearing as a subsequence.
|
|
191
|
+
|
|
192
|
+
Gaps are allowed: other calls may happen in between. Use
|
|
193
|
+
then_immediately() when adjacency matters.
|
|
194
|
+
"""
|
|
195
|
+
desc = f"in_order({', '.join(repr(i) for i in items)})"
|
|
196
|
+
cursor = 0
|
|
197
|
+
calls = self.traj.calls
|
|
198
|
+
for item in items:
|
|
199
|
+
name, args, kwargs = self._norm_item(item)
|
|
200
|
+
found = next(
|
|
201
|
+
(
|
|
202
|
+
i
|
|
203
|
+
for i in range(cursor, len(calls))
|
|
204
|
+
if calls[i].name == name and call_matches(calls[i], args, kwargs)
|
|
205
|
+
),
|
|
206
|
+
None,
|
|
207
|
+
)
|
|
208
|
+
if found is None:
|
|
209
|
+
return self._record(
|
|
210
|
+
False,
|
|
211
|
+
desc,
|
|
212
|
+
f"{name!r} not found after position {cursor}; "
|
|
213
|
+
f"trajectory: {self.traj.tool_names}",
|
|
214
|
+
)
|
|
215
|
+
cursor = found + 1
|
|
216
|
+
return self._record(True, desc)
|
|
217
|
+
|
|
218
|
+
def then(self, name: str, *args, **kwargs) -> "Expect":
|
|
219
|
+
"""Chain step: `name` (with matching args) must occur after the
|
|
220
|
+
previous then() match.
|
|
221
|
+
|
|
222
|
+
Gaps allowed. Each then() continues from where the last one matched,
|
|
223
|
+
so then-chains express "first do A, then B, then C" fluently.
|
|
224
|
+
"""
|
|
225
|
+
desc = f'then("{name}"{self._fmt_expect(args, kwargs)})'
|
|
226
|
+
calls = self.traj.calls
|
|
227
|
+
found = next(
|
|
228
|
+
(
|
|
229
|
+
i
|
|
230
|
+
for i in range(self._chain_cursor, len(calls))
|
|
231
|
+
if calls[i].name == name and call_matches(calls[i], args, kwargs)
|
|
232
|
+
),
|
|
233
|
+
None,
|
|
234
|
+
)
|
|
235
|
+
if found is None:
|
|
236
|
+
return self._record(
|
|
237
|
+
False,
|
|
238
|
+
desc,
|
|
239
|
+
f"{name!r} not found after step {self._chain_cursor}; "
|
|
240
|
+
f"trajectory: {self.traj.tool_names}",
|
|
241
|
+
)
|
|
242
|
+
self._chain_cursor = found + 1
|
|
243
|
+
return self._record(True, desc)
|
|
244
|
+
|
|
245
|
+
def then_immediately(self, name: str, *args, **kwargs) -> "Expect":
|
|
246
|
+
"""Chain step requiring strict adjacency — no calls in between."""
|
|
247
|
+
desc = f'then_immediately("{name}"{self._fmt_expect(args, kwargs)})'
|
|
248
|
+
calls = self.traj.calls
|
|
249
|
+
nxt = calls[self._chain_cursor] if self._chain_cursor < len(calls) else None
|
|
250
|
+
if nxt is not None and nxt.name == name and call_matches(nxt, args, kwargs):
|
|
251
|
+
self._chain_cursor += 1
|
|
252
|
+
return self._record(True, desc)
|
|
253
|
+
got = nxt.short() if nxt else "<end of trajectory>"
|
|
254
|
+
return self._record(False, desc, f"expected {name!r} immediately, got: {got}")
|
|
255
|
+
|
|
256
|
+
def first(self, name: str, *args, **kwargs) -> "Expect":
|
|
257
|
+
desc = f'first("{name}"{self._fmt_expect(args, kwargs)})'
|
|
258
|
+
calls = self.traj.calls
|
|
259
|
+
if calls and calls[0].name == name and call_matches(calls[0], args, kwargs):
|
|
260
|
+
return self._record(True, desc)
|
|
261
|
+
got = calls[0].short() if calls else "<no calls>"
|
|
262
|
+
return self._record(False, desc, f"first call was: {got}")
|
|
263
|
+
|
|
264
|
+
def last(self, name: str, *args, **kwargs) -> "Expect":
|
|
265
|
+
desc = f'last("{name}"{self._fmt_expect(args, kwargs)})'
|
|
266
|
+
calls = self.traj.calls
|
|
267
|
+
if calls and calls[-1].name == name and call_matches(calls[-1], args, kwargs):
|
|
268
|
+
return self._record(True, desc)
|
|
269
|
+
got = calls[-1].short() if calls else "<no calls>"
|
|
270
|
+
return self._record(False, desc, f"last call was: {got}")
|
|
271
|
+
|
|
272
|
+
# -- budgets & completion ------------------------------------------
|
|
273
|
+
def completed_within(self, n: int) -> "Expect":
|
|
274
|
+
steps = self.traj.steps
|
|
275
|
+
return self._record(
|
|
276
|
+
steps <= n,
|
|
277
|
+
f"completed_within({n})",
|
|
278
|
+
f"took {steps} steps" if steps > n else "",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def finished(self) -> "Expect":
|
|
282
|
+
ok = self.traj.finished and not self.traj.failure
|
|
283
|
+
return self._record(
|
|
284
|
+
ok,
|
|
285
|
+
"finished()",
|
|
286
|
+
f"agent did not finish: {self.traj.failure}" if not ok else "",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# -- output & narration --------------------------------------------
|
|
290
|
+
def final_answer(self, expected) -> "Expect":
|
|
291
|
+
"""Match the agent's return value: a Matcher, or a literal (==)."""
|
|
292
|
+
matcher = m.to_matcher(expected)
|
|
293
|
+
ok = matcher.matches(self.traj.final_answer)
|
|
294
|
+
return self._record(
|
|
295
|
+
ok,
|
|
296
|
+
f"final_answer({matcher.describe()})",
|
|
297
|
+
f"got {self.traj.final_answer!r}" if not ok else "",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def final_answer_contains(self, text: str) -> "Expect":
|
|
301
|
+
return self.final_answer(m.Contains(text))
|
|
302
|
+
|
|
303
|
+
def notes_contain(self, text: str) -> "Expect":
|
|
304
|
+
ok = any(text in n.text for n in self.traj.notes)
|
|
305
|
+
return self._record(
|
|
306
|
+
ok,
|
|
307
|
+
f"notes_contain({text!r})",
|
|
308
|
+
"no note contained it" if not ok else "",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def notes_match(self, pattern: str) -> "Expect":
|
|
312
|
+
matcher = m.Regex(pattern)
|
|
313
|
+
ok = any(matcher.matches(n.text) for n in self.traj.notes)
|
|
314
|
+
return self._record(
|
|
315
|
+
ok,
|
|
316
|
+
f"notes_match({pattern!r})",
|
|
317
|
+
"no note matched" if not ok else "",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# -- world state (the anti-lying assertions) ------------------------
|
|
321
|
+
def world_file(
|
|
322
|
+
self, path: str, *, equals=None, contains=None, matches=None
|
|
323
|
+
) -> "Expect":
|
|
324
|
+
"""Assert on the mock world's final file state.
|
|
325
|
+
|
|
326
|
+
The agent can *say* it saved the file; this checks that it did.
|
|
327
|
+
Pass one of equals/contains/matches (a Matcher), or none to just
|
|
328
|
+
check existence.
|
|
329
|
+
"""
|
|
330
|
+
world = self._require_world("world_file")
|
|
331
|
+
desc = f"world_file({path!r})"
|
|
332
|
+
try:
|
|
333
|
+
content = world.fs.read(path)
|
|
334
|
+
except FileNotFoundError:
|
|
335
|
+
return self._record(False, desc, f"{path!r} does not exist in mock world")
|
|
336
|
+
checks = {"equals": equals, "contains": contains, "matches": matches}
|
|
337
|
+
given = {k: v for k, v in checks.items() if v is not None}
|
|
338
|
+
if not given:
|
|
339
|
+
return self._record(True, desc + " exists")
|
|
340
|
+
if len(given) > 1:
|
|
341
|
+
raise ValueError("world_file: pass at most one of equals/contains/matches")
|
|
342
|
+
kind, want = next(iter(given.items()))
|
|
343
|
+
matcher = {
|
|
344
|
+
"equals": m.to_matcher(want),
|
|
345
|
+
"contains": m.Contains(want),
|
|
346
|
+
"matches": m.to_matcher(want),
|
|
347
|
+
}[kind]
|
|
348
|
+
ok = matcher.matches(content)
|
|
349
|
+
return self._record(
|
|
350
|
+
ok,
|
|
351
|
+
f"{desc} {kind}={matcher.describe()}",
|
|
352
|
+
f"file content was {content!r}" if not ok else "",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def world(self, description: str, predicate) -> "Expect":
|
|
356
|
+
"""Custom assertion over the whole mock world.
|
|
357
|
+
|
|
358
|
+
predicate(world) -> bool, or (bool, message).
|
|
359
|
+
"""
|
|
360
|
+
w = self._require_world("world")
|
|
361
|
+
ok, msg = self._coerce_result(predicate(w))
|
|
362
|
+
return self._record(ok, f"world({description})", msg if not ok else "")
|
|
363
|
+
|
|
364
|
+
def custom(self, description: str, fn) -> "Expect":
|
|
365
|
+
"""Escape hatch: fn(trajectory) -> bool, or (bool, message)."""
|
|
366
|
+
ok, msg = self._coerce_result(fn(self.traj))
|
|
367
|
+
return self._record(ok, f"custom({description})", msg if not ok else "")
|
|
368
|
+
|
|
369
|
+
# -- verdict ---------------------------------------------------------
|
|
370
|
+
def check(self) -> list[Violation]:
|
|
371
|
+
"""Return all violations without raising.
|
|
372
|
+
|
|
373
|
+
Like assert_all(), an assertion-free Expect yields a failure —
|
|
374
|
+
otherwise `assert not exp.check()` would pass vacuously.
|
|
375
|
+
"""
|
|
376
|
+
if not self._violations:
|
|
377
|
+
return [
|
|
378
|
+
Violation(
|
|
379
|
+
False,
|
|
380
|
+
"no assertions recorded",
|
|
381
|
+
"an eval that checks nothing always passes",
|
|
382
|
+
)
|
|
383
|
+
]
|
|
384
|
+
return list(self._violations)
|
|
385
|
+
|
|
386
|
+
def summary(self) -> str:
|
|
387
|
+
return "\n".join(v.line() for v in self._violations)
|
|
388
|
+
|
|
389
|
+
def assert_all(self) -> None:
|
|
390
|
+
"""Raise EvalFailure listing every failed assertion + transcript.
|
|
391
|
+
|
|
392
|
+
An eval with zero assertions fails loudly: an eval that checks
|
|
393
|
+
nothing always passes, which is exactly the false confidence
|
|
394
|
+
trajtest exists to kill.
|
|
395
|
+
"""
|
|
396
|
+
if not self._violations:
|
|
397
|
+
raise EvalFailure(
|
|
398
|
+
"trajtest: no assertions were recorded.\n"
|
|
399
|
+
"An eval that checks nothing always passes — add assertions "
|
|
400
|
+
"(e.g. .called(...), .never_called(...), .world_file(...)) "
|
|
401
|
+
"before calling assert_all()."
|
|
402
|
+
)
|
|
403
|
+
failed = [v for v in self._violations if not v.ok]
|
|
404
|
+
if not failed:
|
|
405
|
+
return
|
|
406
|
+
report = [
|
|
407
|
+
(
|
|
408
|
+
f"trajtest eval FAILED: {len(failed)} of {len(self._violations)} "
|
|
409
|
+
f"assertion(s) failed"
|
|
410
|
+
),
|
|
411
|
+
self.summary(),
|
|
412
|
+
f"Trajectory ({self.traj.steps} steps):",
|
|
413
|
+
self.traj.transcript(),
|
|
414
|
+
]
|
|
415
|
+
raise EvalFailure("\n".join(report))
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _fmt_expect(args, kwargs) -> str:
|
|
419
|
+
parts = [repr(a) for a in args]
|
|
420
|
+
parts += [f"{k}={v!r}" for k, v in kwargs.items()]
|
|
421
|
+
return (", " + ", ".join(parts)) if parts else ""
|