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 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.
@@ -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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)."""