Agent Evals API¶
Record an agent run, replay it in CI, and grade eval cases over the trajectory.
Details: Testing agents.
Record an agent run, replay it in CI.
An agent's behaviour depends on a model you do not control, so the usual test shapes do not fit: a live model makes the suite slow, flaky, and expensive, and a hand-written fake drifts from what the model actually does.
So record one real run — every completion the model returned and every tool result that came back off the fabric — and keep it as a file. Replaying pins the model and the mesh to what was recorded, leaving your own code as the only variable: the prompt, the tool catalogue, the loop, the approval gates. If a refactor changes which tools get called or what the agent finally says, the replay says so, offline and in milliseconds.
Recording::
traj, events = await record_agent(
model, tools, [], name="refund-flow", user="refund order o-1",
)
traj.save("trajectories/refund-flow.json")
Replaying (no model, no mesh, no network)::
result = await replay(Trajectory.load("trajectories/refund-flow.json"))
assert result.ok, result.diff
What a replay cannot tell you is whether the model would still answer that way —
that is what an eval against a live model is for (see :mod:istos.testing.evals).
ModelTurn
dataclass
¶
One completion the model produced, as it came back.
Source code in src/istos/testing/trajectory.py
RecordingModel
¶
Wraps a :class:~istos.agent.Model and keeps every reply it produced.
Source code in src/istos/testing/trajectory.py
ReplayExhausted
¶
ReplayModel
¶
Returns the recorded completions, in order. No network, no model.
tools_offered records the catalogue each recorded turn was given, so a
replay can notice that a tool disappeared from the agent's catalogue even when
the model never called it.
Source code in src/istos/testing/trajectory.py
ReplayResult
dataclass
¶
What a replay produced, and how it differed from the recording.
Source code in src/istos/testing/trajectory.py
ToolOutcome
dataclass
¶
One tool call and what it returned.
Source code in src/istos/testing/trajectory.py
Trajectory
dataclass
¶
One recorded agent run: what was asked, what the model did, what came back.
Source code in src/istos/testing/trajectory.py
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | |
TrajectoryRecorder
¶
Collects a run into a :class:Trajectory.
Use it directly when you drive the loop yourself (inside a @channel, say)
rather than through :func:record_agent::
rec = TrajectoryRecorder(name="refund-flow", system=SYSTEM)
model = rec.model(OpenAIChatModel(...))
rec.user("refund order o-1")
async for event in run_agent(model, tools, messages):
rec.observe(event)
...
rec.build().save("trajectories/refund-flow.json")
Source code in src/istos/testing/trajectory.py
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | |
model(inner)
¶
observe(event)
¶
Feed one loop event.
Source code in src/istos/testing/trajectory.py
check_against_app(traj, app)
¶
Check a recording against a live app's registry.
A replay cannot see this: the stubs answer whatever the recording said, so a handler that was renamed or whose signature changed still "works". Ask the app directly instead — do these tools still exist, and do the recorded arguments still satisfy their current signatures?
assert not check_against_app(Trajectory.load(path), app)
Returns one string per problem; empty means the recording still matches the
code. This is what istos eval --app runs.
Source code in src/istos/testing/trajectory.py
event_to_dict(event)
¶
An :class:AgentEvent as a plain dict, dropping what was never set.
Source code in src/istos/testing/trajectory.py
record_agent(model, tools, messages, *, name='', user=None, system=None, **run_kwargs)
async
¶
Run the agent once and record it.
user is appended to messages as user turns (a string, or several run
one after another) and system is prepended when messages is empty, so
the common case is a single call. Everything else goes to
:func:~istos.agent.run_agent.
Source code in src/istos/testing/trajectory.py
replay(traj, *, tools=None, approvals=None, compare_text=True, **run_kwargs)
async
¶
Re-run a recorded trajectory against the current code.
The model is pinned to the recorded completions and, by default, tools hand
back the recorded results — so a difference means your code changed: the
catalogue, the loop, an approval gate. Pass tools to replay against real
:class:~istos.agent.MeshTool objects instead (their invoke still runs,
which is useful for pure local tools and wrong for anything with effects).
diff lists what moved: tool calls made or skipped, arguments changed, the
final message, unrecorded calls, an exhausted recording. ok is
diff == [].
A recording that went through an approval gate replays through one too: unless
you pass approvals, a stand-in answers the way the recorded human did.
Source code in src/istos/testing/trajectory.py
replay_gate(traj)
¶
A gate that answers the way the recorded human did, in the same order.
Replay is about the code, not the operator: a run that was approved replays approved and one that was refused replays refused, without anyone watching.
Source code in src/istos/testing/trajectory.py
replay_tools(traj)
¶
Stub tools that hand back what the recording captured.
One tool per recorded name. A repeated call replays that tool's outcomes in order; calls beyond what was recorded return a marker the replay reports as a mismatch rather than pretending to succeed.
Source code in src/istos/testing/trajectory.py
Eval cases for agents — assert on a whole trajectory, not one return value.
A tool test asserts a return value. An agent test has to assert something looser
but more useful: that the agent reached for the right tool, with plausible
arguments, without touching the ones it must not, and said something recognisable
at the end. That is what an :class:EvalCase states::
cases = [
EvalCase(
name="refund happy path",
user="please refund order o-1",
expect_tools=["billing-refund"],
expect_text="refunded",
),
EvalCase(
name="no refund without an order",
user="give me money",
forbid_tools=["billing-refund"],
),
]
report = await run_eval(cases, model=lambda: OpenAIChatModel(...), tools=tools)
assert report.ok, format_eval_report(report)
Each case runs its own conversation and is recorded, so a run can be saved and
replayed later without the model (see :mod:istos.testing.trajectory).
Judgement stays out of here on purpose: expect_text is a substring, not an
LLM grader. Pass check= for anything sharper — it receives the trajectory and
returns a failure string, or None.
EvalCase
dataclass
¶
One thing an agent should (or should not) do.
Source code in src/istos/testing/evals.py
evaluate(traj)
¶
Every way traj failed this case. Empty means it passed.
Source code in src/istos/testing/evals.py
EvalReport
dataclass
¶
Every case's outcome, plus the tally.
Source code in src/istos/testing/evals.py
EvalResult
dataclass
¶
One case's outcome.
Source code in src/istos/testing/evals.py
format_eval_report(report, *, verbose=False)
¶
A short text report — the CLI's output, and readable in a pytest failure.
Source code in src/istos/testing/evals.py
run_case(case, *, model, tools=(), **run_kwargs)
async
¶
Run one case in a conversation of its own and grade it.
Source code in src/istos/testing/evals.py
run_eval(cases, *, model, tools=(), **run_kwargs)
async
¶
Run every case, sequentially, and collect the report.
model is a model or a zero-argument factory. Pass a factory when the model
holds per-run state (a :class:~istos.testing.trajectory.ReplayModel, a
scripted stub) — each case then gets its own.
Cases run one at a time on purpose: they share the fabric, and a parallel run would make a failure depend on what else was in flight.