Trajectories

A trajectory is how an answer was produced: which tools were called, with what arguments, in what order, in how many steps, and whether anybody approved the actions that needed approving.

padosoft/eval-harness scores one. This package fills it from a laravel/ai response.

The translation

use Padosoft\EvalHarnessAiBridge\Trajectories\AgentResponseTrajectory;

$trajectory = AgentResponseTrajectory::fromResponse($response);
laravel/ai Trajectory
$response->toolCalls toolCalls[] — name and arguments
$response->toolResults the result / error on the matching call
$response->steps steps
last Step::$finishReason finishReason
$response->pendingApprovals pendingApprovals
approved tool results approvals[]
usage, provider, model metadata

Most of the time you never call it directly — AgentSampleRunner does it for you:

use Padosoft\EvalHarnessAiBridge\Runners\AgentSampleRunner;

$eval->run('support.agent', new AgentSampleRunner(
    fn (array $input) => Ai::agent(SupportAgent::class)->prompt($input['question']),
));

The callable receives the row’s input array and, if you type a second parameter, the SampleInvocation. Return whatever laravel/ai gave back; the runner takes ->text for the text metrics and records the trajectory for the trajectory metrics.

Four details that are not obvious

Each has a test, because each is a bug somebody would otherwise ship.

Results join calls by id, never by position. Parallel tools return out of order, and a call still awaiting approval has no result at all. Matching by index silently attaches one call’s outcome to another’s — a failure that looks like a passing eval.

A denied call is recorded as failed, with no result. The tool never ran. An assertion asking “did it look the order up?” must not be satisfied by a rejection.

A run stopped on an approval reports pending_approval, not stop. Text that says “I have submitted that refund” while an approval is pending reads as success and is not. This is the difference between a compliance finding and a UX detail.

Usage travels in metadata.usage, in the shape eval-harness’s cost ledger reads. Agent spend then appears next to judge spend in the run’s cost report instead of being quietly treated as free.

What you can assert

Expectations live in the row’s metadata.trajectory:

- id: refund-request
  input: { question: 'Refund order 44192 please.' }
  expected_output: 'confirms the refund is submitted for approval'
  metadata:
    trajectory:
      tools: [lookup_order, refund_order]        # tool-called
      forbidden: [charge_card]                    # tool-not-called
      arguments:
        lookup_order: { id: 44192 }               # tool-called-with
      order: [lookup_order, refund_order]         # tool-call-order
      max_steps: 6                                # steps-below
      approvals: [refund_order]                   # approval-gated
Metric Asks
tool-called did it use the tools this row required?
tool-not-called did it stay away from the ones it must not use here?
tool-called-with did it look up the right record?
tool-call-order did it check stock before charging, not after?
steps-below did it stay inside its step budget?
no-pending-approvals did it finish, or did it stop?
approval-gated did the actions that needed approval get it?

Two matching rules worth knowing, both from eval-harness:

  • Arguments match as a subset. A runtime that adds a trace id has still made the call.
  • Order matches as a subsequence. A new tool appearing between two expected ones must not fail an eval that was about the two.

Using the adapter on its own

Already recording trajectories from somewhere else? Skip the runner:

use Padosoft\EvalHarness\Trajectory\TrajectoryRecorder;
use Padosoft\EvalHarnessAiBridge\Trajectories\AgentResponseTrajectory;

app(TrajectoryRecorder::class)->record(
    $sample->id,
    AgentResponseTrajectory::fromResponse($response),
);

What happens without a recorder

AgentSampleRunner resolves the recorder from the container and degrades quietly if there is none — a missing recorder costs you the trajectory metrics, not the run. That keeps the runner usable in a plain unit test with no application booted.

A missing trajectory is different: eval-harness raises a MetricException on a trajectory metric with nothing to score, captured as a failure. Scoring 0 would blame the agent for the harness’s wiring; scoring 1 would let a dataset go green because nobody plugged the recorder in.