Eval Assertions
EvalContext exposes two families of checks. This page covers the deterministic family: plain PHP comparisons against the target’s AgentRun — reply text, tool calls, events, steps, and token usage. They involve no inference call, so they are free, instant, and give the same answer on every run. The semantic family, reached through $t->judge()->..., is covered on the eval judges page; Agent Evals introduces both and shows how they read together in a case.
Safety-critical invariants belong to this page, never to the judge alone. A judge grades the same target output an attacker controls, so a sufficiently crafted reply can steer it toward a high score. A deterministic assertion cannot be talked out of its answer.notCalledTool,toolOrder, and the step/token bounds below are what actually decide whether an unsafe action occurred; the judge grades quality on top of that decision, never in place of it. The full reasoning is at the end of this page — read it before you decide a judge assertion is sufficient for anything safety-relevant.
Severity and thresholds
Every deterministic assertion onEvalContext returns an AssertionHandle, and every recorded check is an AssertionResult with a score in [0, 1], a severity (AssertionSeverity::Gate or ::Soft), and an optional threshold. passed() is score >= (threshold ?? 1.0) — so a plain boolean check (score 0.0 or 1.0) needs an exact 1.0 to pass unless you widen it with atLeast().
AssertionHandle supports .gate(), .soft(), .atLeast(float $threshold), and .label(string $label), each mutating the just-recorded result in place. Every assertion documented on this page defaults to Gate severity; call .soft() to turn a failure into a lower score instead of a case failure. (One exception defaults to Soft on its own: ValueExpectation::similarity(), described below. Judge assertions from $t->judge()->..., described on the eval judges page, default to Soft only when a judge is actually configured — with none configured they default to Gate, and a judge that throws is always recorded as a Gate failure regardless of any prior .gate()/.soft() call. Call .gate() explicitly if a configured judge’s failure must fail the case.)
Severity determines the case verdict, resolved by EvalVerdictResolver:
A failed gate always wins: a case with one failed
Gate assertion and nine passed ones is Failed, not Scored.
Outcome assertions
These read the accumulatedAgentRun — see Agent Evals for how send() builds it across turns. None of them record a message on failure; a failing outcome assertion is identified by its assertion name alone (succeeded, stopped, messageIncludes, outputEquals, outputMatches).
outputEquals() accepts a literal value (strict ===), an EvalMatch (see below), or a partial array structure — but since reply() is always a string, only the literal-string and EvalMatch forms are meaningful here; partial-array matching is for tool arguments and results, covered next.
Trajectory assertions
These inspectrun()->tools(), the flattened list of every tool execution across every turn sent so far.
calledTool(string $name, mixed $arguments = null, mixed $result = null, ?bool $isError = null, int|EvalCount|null $count = null)counts executions of$namewhose arguments, result, and error state (when given) match, then compares that count against$count: an exactint, anEvalCount(below), or — when omitted — “at least one.” Arguments and results are compared withEvalMatcher::matches(), so you can pass a literal value, a partial array (only the given keys need to match; extra keys in the actual value are ignored), or anEvalMatch. The assertion name iscalledTool:{name}and the message is always"matched {n} tool calls", present whether it passes or fails.notCalledTool(string $name, mixed $arguments = null)passes when no execution of$name(matching$arguments, if given) exists. Assertion namenotCalledTool:{name}; no message.toolOrder(string ...$names)passes when$namesappears as a (not necessarily contiguous) ordered subsequence of the actual tool-call names — extra calls in between are fine, out-of-order or missing calls are not. Assertion nametoolOrder; no message.usedNoTools()passes when zero tools were called. Assertion nameusedNoTools; no message.maxToolCalls(int $maximum)passes when the total tool-call count is at most$maximum. Assertion namemaxToolCalls; no message.noFailedActions()passes when no tool execution has an error andrun()->errors()is empty. Assertion namenoFailedActions; no message.
EvalCount gives calledTool(), calledSubagent(), and event() a fluent way to express a count range instead of an exact integer: EvalCount::atLeast(int), ::atMost(int), ::between(int, int), or ::satisfies(Closure(int): bool) for anything else.
Subagent and event assertions
These inspectrun()->events(), the raw event stream captured during execution.
calledSubagent(string $name, int|EvalCount|null $count = null)countsSubagentCompletedevents whosesubagentNamematches, using the sameint/EvalCount/“at least one” rule ascalledTool(). Assertion namecalledSubagent:{name}; no message.event(string $class, ?Closure $predicate = null, int|EvalCount|null $count = null)counts events that are aninstanceof $classand, when given, satisfy$predicate. Assertion nameevent:{class}(the fully-qualified class name); no message.notEvent(string $class)passes when no event is aninstanceof $class. Assertion namenotEvent:{class}; no message.eventOrder(string ...$classes)passes when events matching each class in$classes, in order, appear as an ordered subsequence of the actual event stream (same subsequence semantics astoolOrder()). Assertion nameeventOrder; no message.eventsSatisfy(Closure(EvalEvents): bool $predicate)passes when$predicatereturns true for the wholeEvalEventscollection — the escape hatch for anything the named checks above don’t express. Assertion nameeventsSatisfy; no message.
Step and cost assertions
These readrun()->stepCount() and run()->usage()->total(), both accumulated across every send() on the session. Unlike the assertions above, all three always carry a message, whether they pass or fail:
stepCount(int $expected)passes only on an exact match.maxSteps(int $maximum)passes when the actual step count is at most$maximum.totalTokensAtMost(int $maximum)passes when accumulated token usage is at most$maximum.
expect() and the matcher vocabulary
$t->expect($value) returns a ValueExpectation for grading an arbitrary value pulled out of the run — not just the reply. Each call in the chain records its own assertion; .gate(), .soft(), .atLeast(), and .label() apply only to the most recently recorded one:
includes(mixed $expected)—str_contains()when the value is a string,in_array($expected, $value, true)when it’s an array, otherwise fails.equals(mixed $expected)—EvalMatcher::matches($expected, $value).matches(string|EvalMatch $pattern)— a bare string is treated asEvalMatch::regex($pattern).similarity(string $expected)—1 - (levenshtein / max length), a continuous score. This is the one built-in assertion that defaults toSoftseverity rather thanGate, because a Levenshtein ratio is rarely meant to gate a case on its own.satisfies(Closure(mixed): bool $predicate)— an arbitrary predicate over the value.
includes, equals, matches, similarity, satisfies.
Three of the above, plus outputEquals() and the arguments/result parameters of calledTool(), are backed by the same matcher vocabulary:
EvalMatcher::matches($expected, $actual)— anEvalMatchdelegates to itself; an array does a partial structural match (below); anything else is strict===.EvalMatcher::partial($expected, $actual)— both must be arrays. If$expectedis a list, it requires the same length and an elementwise partial match at each position. If$expectedis a map, every key in$expectedmust exist in$actualand match partially — extra keys in$actualare ignored, which is what makes it “partial.”EvalMatch::partial(array $value),::regex(string $pattern)(validated at construction; an invalid pattern throwsInvalidArgumentExceptionimmediately), and::satisfies(Closure $predicate)build a matcher explicitly, for when you need one as a value rather than as a chained call.
check(), require(), skip(), and log()
Four lower-level EvalContext methods sit underneath everything above:
check(string $name, bool $passed, string $message = ''): AssertionHandleis the generic escape hatch every named assertion in this page is built on. Reach for it directly when you have a boolean condition that no named assertion expresses.require(string $name, bool $passed, string $message = ''): voidcallscheck()(so the result is recorded either way) and, on failure, throwsEvalRequirementFailed, which stops the rest of the case closure immediately. The runner does not treat this as a separate error — the verdict comes from the gate failurecheck()already recorded. Use it for a precondition the rest of the case cannot meaningfully continue past (an empty fixture, a target that never replied), not as a stronger version of an ordinary assertion.skip(string $reason): neverthrowsEvalSkipped, which the runner turns into aSkippedverdict (unless a gate assertion recorded earlier in the closure already failed, in which caseFailedwins). Use it when a case does not apply in the current configuration, not when it fails.log(string $message, array $context = []): voidrecords a diagnostic entry with no effect on the verdict, retrievable from$t->logs(). Use it to leave breadcrumbs — an intermediate value, a branch taken — for a report to surface without turning that value into an assertion.
Why deterministic assertions are the safety boundary
A judge is graded material generated by the same target it is asked to evaluate. Wrapping that trace in JSON and labeling it untrusted in the judge’s system contract reduces how easily it can be misread as instructions, but it does not eliminate the risk: a language model has no enforced boundary between data and instructions, so “it’s JSON, not executable text” is not a security property, and this package does not claim it is one. A target reply can be adversarial in exactly the scenarios evals exist to catch, and a sufficiently crafted reply can steer a judge toward a high score regardless of what actually happened. The consequence is a scoping rule, not a filter: assert every safety-critical invariant with the deterministic assertions on this page, and never rely on a judge assertion alone to catch one.notCalledTool(), toolOrder(), maxSteps(), totalTokensAtMost(), and the rest of this catalogue decide whether an unsafe action happened, deterministically and the same way on every run. The judge — covered on the eval judges page — grades quality on top of a trajectory those deterministic gates have already accepted, not instead of checking it.