trajtest 0.1.1__tar.gz
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-0.1.1/LICENSE +21 -0
- trajtest-0.1.1/PKG-INFO +176 -0
- trajtest-0.1.1/README.md +162 -0
- trajtest-0.1.1/pyproject.toml +29 -0
- trajtest-0.1.1/setup.cfg +4 -0
- trajtest-0.1.1/src/trajtest/__init__.py +61 -0
- trajtest-0.1.1/src/trajtest/cli.py +138 -0
- trajtest-0.1.1/src/trajtest/errors.py +21 -0
- trajtest-0.1.1/src/trajtest/expect.py +421 -0
- trajtest-0.1.1/src/trajtest/matchers.py +227 -0
- trajtest-0.1.1/src/trajtest/pytest_plugin.py +11 -0
- trajtest-0.1.1/src/trajtest/recorder.py +326 -0
- trajtest-0.1.1/src/trajtest/trace_io.py +120 -0
- trajtest-0.1.1/src/trajtest/world.py +376 -0
- trajtest-0.1.1/src/trajtest.egg-info/PKG-INFO +176 -0
- trajtest-0.1.1/src/trajtest.egg-info/SOURCES.txt +25 -0
- trajtest-0.1.1/src/trajtest.egg-info/dependency_links.txt +1 -0
- trajtest-0.1.1/src/trajtest.egg-info/entry_points.txt +5 -0
- trajtest-0.1.1/src/trajtest.egg-info/requires.txt +1 -0
- trajtest-0.1.1/src/trajtest.egg-info/top_level.txt +1 -0
- trajtest-0.1.1/tests/test_cli.py +92 -0
- trajtest-0.1.1/tests/test_e2e.py +141 -0
- trajtest-0.1.1/tests/test_expect.py +358 -0
- trajtest-0.1.1/tests/test_matchers.py +141 -0
- trajtest-0.1.1/tests/test_recorder.py +224 -0
- trajtest-0.1.1/tests/test_trace_io.py +122 -0
- trajtest-0.1.1/tests/test_world.py +297 -0
trajtest-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Calas Automations Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
trajtest-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trajtest
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: pytest for agent trajectories: assert on what an agent did, not just what it said
|
|
5
|
+
Author: Calas Automations
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://calasautomations.com
|
|
8
|
+
Keywords: agents,eval,evals,testing,trajectory,pytest
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: pytest>=7
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# trajtest — pytest for agent trajectories
|
|
16
|
+
|
|
17
|
+
**Assert on what an agent *did*, not just what it *said*.**
|
|
18
|
+
|
|
19
|
+
`trajtest` runs an agent against a mock world (in-memory FS, mock HTTP, KV
|
|
20
|
+
store) and lets you assert on its **trajectory** — which tools it called, in
|
|
21
|
+
what order, with what arguments, and what side effects it left behind.
|
|
22
|
+
Deterministic. Local-first. No LLM-as-judge. CI-ready.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from trajtest import Expect, World, run
|
|
26
|
+
|
|
27
|
+
def my_agent(task, tools):
|
|
28
|
+
rows = tools.http_get("https://api.example.com/rows").json()["rows"]
|
|
29
|
+
tools.fs_write("/out/report.md", f"total: {sum(rows)}")
|
|
30
|
+
return "done"
|
|
31
|
+
|
|
32
|
+
def test_agent_saves_before_claiming():
|
|
33
|
+
world = World()
|
|
34
|
+
world.http.mock("GET", "https://api.example.com/rows", json={"rows": [1, 2, 3]})
|
|
35
|
+
|
|
36
|
+
traj = run(my_agent, "summarize the rows", world, max_steps=15)
|
|
37
|
+
|
|
38
|
+
(Expect(traj)
|
|
39
|
+
.called("http_get", url__contains="api.example.com")
|
|
40
|
+
.then("fs_write", path="/out/report.md")
|
|
41
|
+
.never_called("fs_delete")
|
|
42
|
+
.completed_within(15)
|
|
43
|
+
.finished()
|
|
44
|
+
.world_file("/out/report.md", equals="total: 6") # the world, not the narration
|
|
45
|
+
.assert_all())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Why
|
|
49
|
+
|
|
50
|
+
Prompt-tests can't see the four ways agents actually fail:
|
|
51
|
+
|
|
52
|
+
1. **Confident false reports** — the agent says "saved the file" but never
|
|
53
|
+
called write. `world_file()` checks the mock world's final state, not the
|
|
54
|
+
narration.
|
|
55
|
+
2. **Silent policy violations** — narration says "I'll be careful", tool calls
|
|
56
|
+
did a delete. `never_called("fs_delete")` catches it.
|
|
57
|
+
3. **Evidence-free passes** — 94% pass rate, nobody knows what was verified.
|
|
58
|
+
Every `EvalFailure` lists each assertion's outcome plus the full trajectory
|
|
59
|
+
transcript. An eval with zero assertions **fails loudly**.
|
|
60
|
+
4. **Meaningless coverage** — green suite, dangerous tools never exercised.
|
|
61
|
+
`traj.unused_tools(TOOLS)` flags what the agent never touched.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
Not on PyPI yet; install from a clone of this repo:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Core concepts
|
|
72
|
+
|
|
73
|
+
**World** — the mock environment. Strict by design: missing files raise
|
|
74
|
+
`FileNotFoundError`, unmocked HTTP raises `UnmockedRequestError`. A world
|
|
75
|
+
that forgives everything produces agents that pass evals and fail in prod.
|
|
76
|
+
|
|
77
|
+
- `world.fs` — in-memory filesystem (`read`/`write`/`append`/`delete`/
|
|
78
|
+
`exists`/`listdir`/`glob`; str or bytes; `**` glob support)
|
|
79
|
+
- `world.http` — mock HTTP (`mock(method, url, status=..., json=...,
|
|
80
|
+
text=..., error="timeout"|"connection", times=n)`; exact then `*`
|
|
81
|
+
wildcard matching; repeated mocks form a consumable queue — mock a 500
|
|
82
|
+
then a 200 to test retry logic)
|
|
83
|
+
- `world.kv` — key-value scratch space; `world.clock` — controllable clock
|
|
84
|
+
- `world.reset()` — wipe everything between scenarios
|
|
85
|
+
|
|
86
|
+
**run(agent, task, world, max_steps=25, seed=None)** — the agent is a plain
|
|
87
|
+
function `(task, tools) -> str`. Every tool call is recorded (name, args,
|
|
88
|
+
result, duration, errors). Tool errors are recorded *and* re-raised, so the
|
|
89
|
+
agent can react to them. `tools.note("...")` captures narration separately —
|
|
90
|
+
notes are never steps and never pollute sequences, so you can compare what
|
|
91
|
+
the agent *said* against what it *did*. Crashes become `traj.finished=False`
|
|
92
|
+
with `traj.failure` set; exceeding `max_steps` is a trajectory failure, not
|
|
93
|
+
a hang.
|
|
94
|
+
|
|
95
|
+
**sample(agent, task, world_factory, n=5)** — for stochastic agents: n runs
|
|
96
|
+
against fresh worlds. Seeds are passed when the agent accepts a `seed`
|
|
97
|
+
keyword. Assert consistency with a loop:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
for t in sample(agent, "task", make_world, n=5):
|
|
101
|
+
Expect(t).never_called("fs_delete").completed_within(15).assert_all()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Expect(traj)** — the assertion DSL. All methods chain; `assert_all()`
|
|
105
|
+
reports *every* failure at once (fail-collecting, not fail-fast):
|
|
106
|
+
|
|
107
|
+
| Assertion | Meaning |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `called(name, *args, **kwargs)` | ≥1 call matching (positional prefix, kwarg subset, matchers ok) |
|
|
110
|
+
| `called_times / at_least / at_most` | call counts |
|
|
111
|
+
| `never_called(name, ...)` | forbids calls (optionally arg-scoped) |
|
|
112
|
+
| `in_order(a, b, c)` | ordered subsequence, gaps allowed |
|
|
113
|
+
| `then(name, ...)` | fluent chain: must occur after previous `then` |
|
|
114
|
+
| `then_immediately(name, ...)` | strict adjacency required |
|
|
115
|
+
| `first / last` | boundary calls |
|
|
116
|
+
| `completed_within(n)` / `finished()` | step budget / clean completion |
|
|
117
|
+
| `no_repeated_calls(max_repeats=2, name=None)` | loop detector: fails if an identical (tool, args) call happens more than `max_repeats` times; different args don't count |
|
|
118
|
+
| `final_answer(matcher-or-value)` | the agent's return value |
|
|
119
|
+
| `notes_contain / notes_match` | narration assertions |
|
|
120
|
+
| `world_file(path, equals/contains/matches)` | final world state — the anti-lying assertion |
|
|
121
|
+
| `world(desc, predicate)` / `custom(desc, fn)` | escape hatches |
|
|
122
|
+
|
|
123
|
+
Kwarg matching uses **parameter names**, so `called("http_get",
|
|
124
|
+
url__contains="api.example.com")` works whether the agent passed the URL
|
|
125
|
+
positionally or by keyword. Suffix sugar: `__contains __regex
|
|
126
|
+
__startswith __endswith __gt __lt __ge __le`. Matchers (`ANY`, `Contains`,
|
|
127
|
+
`Regex`, `StartsWith`, `EndsWith`, `Gt/Lt/Ge/Le`, `InstanceOf`,
|
|
128
|
+
`Predicate`, `DictSubset`) compose anywhere.
|
|
129
|
+
|
|
130
|
+
**load_trace(path)** — assert on a *real* recorded run. Reads a
|
|
131
|
+
[redbg](https://github.com/calasdan-sketch/redbg) trace (`.jsonl` or
|
|
132
|
+
`.jsonl.gz`; stdlib only, redbg is not imported): each tool call/result pair
|
|
133
|
+
becomes a call with its args as keyword args, the last response becomes the
|
|
134
|
+
final answer, notes stay notes, and a `session_end` status of `ok` means
|
|
135
|
+
finished. A call with no recorded result gets `error="no result recorded"`.
|
|
136
|
+
Call, order and answer assertions work; `world_file` / `world` raise
|
|
137
|
+
`EvalFailure` because a recording has no mock World.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
traj = load_trace("run.jsonl")
|
|
141
|
+
Expect(traj).in_order("read_file", "write_file").never_called("delete_file").assert_all()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**traj.save_trace(path)** — the reverse: write a trajectory as a redbg
|
|
145
|
+
trace (`.gz` suffix gzips it), so a failing eval can be replayed with
|
|
146
|
+
`redbg replay` or compared with `redbg diff`. Args are written by parameter
|
|
147
|
+
name; outputs are the recorded result reprs (truncated at 500 chars), so the
|
|
148
|
+
file is for debugging, not a lossless copy of the mock world.
|
|
149
|
+
|
|
150
|
+
**pytest plugin** — a `world` fixture (fresh `World` per test) is
|
|
151
|
+
auto-registered. **CLI** — `trajtest init` scaffolds an example eval;
|
|
152
|
+
`trajtest run <path>` runs evals through pytest.
|
|
153
|
+
|
|
154
|
+
## Limitations (honest)
|
|
155
|
+
|
|
156
|
+
- **trajtest can't tell you your assertions are meaningful.**
|
|
157
|
+
`Expect(t).finished().assert_all()` is a weak eval and it will pass.
|
|
158
|
+
Assert on trajectories and world state, not vibes.
|
|
159
|
+
- **The mock world is not the real world.** No network latency, no real
|
|
160
|
+
filesystem permissions, no file metadata (size/mtime), no concurrent
|
|
161
|
+
access. Fidelity gaps are where false confidence breeds — keep mocks
|
|
162
|
+
strict and review them.
|
|
163
|
+
- **Deterministic assertions vs stochastic agents** is an inherent tension.
|
|
164
|
+
Use `sample()`, seeds, and order-flexible assertions. If your agent is
|
|
165
|
+
wildly non-deterministic, trajectory evals will be brittle — that's a
|
|
166
|
+
signal about the agent, not just the eval.
|
|
167
|
+
- **Single-threaded.** Agents that spawn threads or subprocesses calling
|
|
168
|
+
tools concurrently are not supported.
|
|
169
|
+
- **No framework adapters in v1.** The agent is `(task, tools) -> str`;
|
|
170
|
+
wrapping a LangChain/CrewAI loop takes ~5 lines on your side.
|
|
171
|
+
- **No LLM-as-judge**, by design. Semantic judgments belong in a custom
|
|
172
|
+
`predicate`, not in the core.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
trajtest-0.1.1/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# trajtest — pytest for agent trajectories
|
|
2
|
+
|
|
3
|
+
**Assert on what an agent *did*, not just what it *said*.**
|
|
4
|
+
|
|
5
|
+
`trajtest` runs an agent against a mock world (in-memory FS, mock HTTP, KV
|
|
6
|
+
store) and lets you assert on its **trajectory** — which tools it called, in
|
|
7
|
+
what order, with what arguments, and what side effects it left behind.
|
|
8
|
+
Deterministic. Local-first. No LLM-as-judge. CI-ready.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from trajtest import Expect, World, run
|
|
12
|
+
|
|
13
|
+
def my_agent(task, tools):
|
|
14
|
+
rows = tools.http_get("https://api.example.com/rows").json()["rows"]
|
|
15
|
+
tools.fs_write("/out/report.md", f"total: {sum(rows)}")
|
|
16
|
+
return "done"
|
|
17
|
+
|
|
18
|
+
def test_agent_saves_before_claiming():
|
|
19
|
+
world = World()
|
|
20
|
+
world.http.mock("GET", "https://api.example.com/rows", json={"rows": [1, 2, 3]})
|
|
21
|
+
|
|
22
|
+
traj = run(my_agent, "summarize the rows", world, max_steps=15)
|
|
23
|
+
|
|
24
|
+
(Expect(traj)
|
|
25
|
+
.called("http_get", url__contains="api.example.com")
|
|
26
|
+
.then("fs_write", path="/out/report.md")
|
|
27
|
+
.never_called("fs_delete")
|
|
28
|
+
.completed_within(15)
|
|
29
|
+
.finished()
|
|
30
|
+
.world_file("/out/report.md", equals="total: 6") # the world, not the narration
|
|
31
|
+
.assert_all())
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
Prompt-tests can't see the four ways agents actually fail:
|
|
37
|
+
|
|
38
|
+
1. **Confident false reports** — the agent says "saved the file" but never
|
|
39
|
+
called write. `world_file()` checks the mock world's final state, not the
|
|
40
|
+
narration.
|
|
41
|
+
2. **Silent policy violations** — narration says "I'll be careful", tool calls
|
|
42
|
+
did a delete. `never_called("fs_delete")` catches it.
|
|
43
|
+
3. **Evidence-free passes** — 94% pass rate, nobody knows what was verified.
|
|
44
|
+
Every `EvalFailure` lists each assertion's outcome plus the full trajectory
|
|
45
|
+
transcript. An eval with zero assertions **fails loudly**.
|
|
46
|
+
4. **Meaningless coverage** — green suite, dangerous tools never exercised.
|
|
47
|
+
`traj.unused_tools(TOOLS)` flags what the agent never touched.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
Not on PyPI yet; install from a clone of this repo:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Core concepts
|
|
58
|
+
|
|
59
|
+
**World** — the mock environment. Strict by design: missing files raise
|
|
60
|
+
`FileNotFoundError`, unmocked HTTP raises `UnmockedRequestError`. A world
|
|
61
|
+
that forgives everything produces agents that pass evals and fail in prod.
|
|
62
|
+
|
|
63
|
+
- `world.fs` — in-memory filesystem (`read`/`write`/`append`/`delete`/
|
|
64
|
+
`exists`/`listdir`/`glob`; str or bytes; `**` glob support)
|
|
65
|
+
- `world.http` — mock HTTP (`mock(method, url, status=..., json=...,
|
|
66
|
+
text=..., error="timeout"|"connection", times=n)`; exact then `*`
|
|
67
|
+
wildcard matching; repeated mocks form a consumable queue — mock a 500
|
|
68
|
+
then a 200 to test retry logic)
|
|
69
|
+
- `world.kv` — key-value scratch space; `world.clock` — controllable clock
|
|
70
|
+
- `world.reset()` — wipe everything between scenarios
|
|
71
|
+
|
|
72
|
+
**run(agent, task, world, max_steps=25, seed=None)** — the agent is a plain
|
|
73
|
+
function `(task, tools) -> str`. Every tool call is recorded (name, args,
|
|
74
|
+
result, duration, errors). Tool errors are recorded *and* re-raised, so the
|
|
75
|
+
agent can react to them. `tools.note("...")` captures narration separately —
|
|
76
|
+
notes are never steps and never pollute sequences, so you can compare what
|
|
77
|
+
the agent *said* against what it *did*. Crashes become `traj.finished=False`
|
|
78
|
+
with `traj.failure` set; exceeding `max_steps` is a trajectory failure, not
|
|
79
|
+
a hang.
|
|
80
|
+
|
|
81
|
+
**sample(agent, task, world_factory, n=5)** — for stochastic agents: n runs
|
|
82
|
+
against fresh worlds. Seeds are passed when the agent accepts a `seed`
|
|
83
|
+
keyword. Assert consistency with a loop:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
for t in sample(agent, "task", make_world, n=5):
|
|
87
|
+
Expect(t).never_called("fs_delete").completed_within(15).assert_all()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Expect(traj)** — the assertion DSL. All methods chain; `assert_all()`
|
|
91
|
+
reports *every* failure at once (fail-collecting, not fail-fast):
|
|
92
|
+
|
|
93
|
+
| Assertion | Meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `called(name, *args, **kwargs)` | ≥1 call matching (positional prefix, kwarg subset, matchers ok) |
|
|
96
|
+
| `called_times / at_least / at_most` | call counts |
|
|
97
|
+
| `never_called(name, ...)` | forbids calls (optionally arg-scoped) |
|
|
98
|
+
| `in_order(a, b, c)` | ordered subsequence, gaps allowed |
|
|
99
|
+
| `then(name, ...)` | fluent chain: must occur after previous `then` |
|
|
100
|
+
| `then_immediately(name, ...)` | strict adjacency required |
|
|
101
|
+
| `first / last` | boundary calls |
|
|
102
|
+
| `completed_within(n)` / `finished()` | step budget / clean completion |
|
|
103
|
+
| `no_repeated_calls(max_repeats=2, name=None)` | loop detector: fails if an identical (tool, args) call happens more than `max_repeats` times; different args don't count |
|
|
104
|
+
| `final_answer(matcher-or-value)` | the agent's return value |
|
|
105
|
+
| `notes_contain / notes_match` | narration assertions |
|
|
106
|
+
| `world_file(path, equals/contains/matches)` | final world state — the anti-lying assertion |
|
|
107
|
+
| `world(desc, predicate)` / `custom(desc, fn)` | escape hatches |
|
|
108
|
+
|
|
109
|
+
Kwarg matching uses **parameter names**, so `called("http_get",
|
|
110
|
+
url__contains="api.example.com")` works whether the agent passed the URL
|
|
111
|
+
positionally or by keyword. Suffix sugar: `__contains __regex
|
|
112
|
+
__startswith __endswith __gt __lt __ge __le`. Matchers (`ANY`, `Contains`,
|
|
113
|
+
`Regex`, `StartsWith`, `EndsWith`, `Gt/Lt/Ge/Le`, `InstanceOf`,
|
|
114
|
+
`Predicate`, `DictSubset`) compose anywhere.
|
|
115
|
+
|
|
116
|
+
**load_trace(path)** — assert on a *real* recorded run. Reads a
|
|
117
|
+
[redbg](https://github.com/calasdan-sketch/redbg) trace (`.jsonl` or
|
|
118
|
+
`.jsonl.gz`; stdlib only, redbg is not imported): each tool call/result pair
|
|
119
|
+
becomes a call with its args as keyword args, the last response becomes the
|
|
120
|
+
final answer, notes stay notes, and a `session_end` status of `ok` means
|
|
121
|
+
finished. A call with no recorded result gets `error="no result recorded"`.
|
|
122
|
+
Call, order and answer assertions work; `world_file` / `world` raise
|
|
123
|
+
`EvalFailure` because a recording has no mock World.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
traj = load_trace("run.jsonl")
|
|
127
|
+
Expect(traj).in_order("read_file", "write_file").never_called("delete_file").assert_all()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**traj.save_trace(path)** — the reverse: write a trajectory as a redbg
|
|
131
|
+
trace (`.gz` suffix gzips it), so a failing eval can be replayed with
|
|
132
|
+
`redbg replay` or compared with `redbg diff`. Args are written by parameter
|
|
133
|
+
name; outputs are the recorded result reprs (truncated at 500 chars), so the
|
|
134
|
+
file is for debugging, not a lossless copy of the mock world.
|
|
135
|
+
|
|
136
|
+
**pytest plugin** — a `world` fixture (fresh `World` per test) is
|
|
137
|
+
auto-registered. **CLI** — `trajtest init` scaffolds an example eval;
|
|
138
|
+
`trajtest run <path>` runs evals through pytest.
|
|
139
|
+
|
|
140
|
+
## Limitations (honest)
|
|
141
|
+
|
|
142
|
+
- **trajtest can't tell you your assertions are meaningful.**
|
|
143
|
+
`Expect(t).finished().assert_all()` is a weak eval and it will pass.
|
|
144
|
+
Assert on trajectories and world state, not vibes.
|
|
145
|
+
- **The mock world is not the real world.** No network latency, no real
|
|
146
|
+
filesystem permissions, no file metadata (size/mtime), no concurrent
|
|
147
|
+
access. Fidelity gaps are where false confidence breeds — keep mocks
|
|
148
|
+
strict and review them.
|
|
149
|
+
- **Deterministic assertions vs stochastic agents** is an inherent tension.
|
|
150
|
+
Use `sample()`, seeds, and order-flexible assertions. If your agent is
|
|
151
|
+
wildly non-deterministic, trajectory evals will be brittle — that's a
|
|
152
|
+
signal about the agent, not just the eval.
|
|
153
|
+
- **Single-threaded.** Agents that spawn threads or subprocesses calling
|
|
154
|
+
tools concurrently are not supported.
|
|
155
|
+
- **No framework adapters in v1.** The agent is `(task, tools) -> str`;
|
|
156
|
+
wrapping a LangChain/CrewAI loop takes ~5 lines on your side.
|
|
157
|
+
- **No LLM-as-judge**, by design. Semantic judgments belong in a custom
|
|
158
|
+
`predicate`, not in the core.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trajtest"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "pytest for agent trajectories: assert on what an agent did, not just what it said"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Calas Automations" }]
|
|
13
|
+
keywords = ["agents", "eval", "evals", "testing", "trajectory", "pytest"]
|
|
14
|
+
dependencies = ["pytest>=7"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://calasautomations.com"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
trajtest = "trajtest.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.entry-points.pytest11]
|
|
23
|
+
trajtest = "trajtest.pytest_plugin"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
trajtest-0.1.1/setup.cfg
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
|
+
]
|
|
@@ -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())
|
|
@@ -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)."""
|