Conversation
Handler packages record tool traffic onto spans and return only
{output, usage}, so by the time a criterion ran the calls a row made on
its way to that output were gone -- which made "did the agent call the
right tool, in the right order, with the right arguments?" an unaskable
question of an SDK-run evaluation that had just run the agent that
answered it.
The runner now records the trajectory itself, wrapping the caller's tool
implementations once per row before handing them to the handler. Wrapping
is what covers every handler package without changing any of them: a
handler still resolves a tool by the key the model named and calls it.
The recorded trajectory reaches judges two ways: interleaved into
message_history between the row input and the generated output, which is
where it happened and which is the variable every judge cloned from the
AI Library's default templates already references, and as tool_trajectory
on its own for a rubric that asks about tool use without restating the
conversation. A run with no observable tools adds no block, so judges
authored before this read exactly the history they read before.
Three properties are pinned by tests. The recorder observes and never
intervenes: a wrapped tool returns and raises what the original did, and
calls past the recording cap still execute and are only counted. A
recorder belongs to one row, since rows generate concurrently against one
shared tool map. And a tool result stays literal in the judge prompt --
it is a new injection surface, closed by the existing rule that the judge
config is passed unrendered for the handler's single template pass.
Native provider tools are passed through unwrapped and left out of the
rendered "tools available" line: they execute inside the provider, so
naming a tool whose use cannot be shown would invite a judge to conclude
the model ignored it.
Nothing about the trajectory is added to any event payload.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #63 — base is
feature/ld-judges-phase3, so the diff here is just this change.Problem
#63 made the run's handler the judge's invoker and gave the judge the row's input, output, and expected output. What it could not give the judge was how the agent got there. Handler packages record tool traffic onto OpenTelemetry spans and return only
{output, usage}, so by the time a criterion ran, the calls the row made on its way to that output were gone."Did the agent call the right tool, in the right order, with the right arguments?" was therefore an unaskable question of an SDK-run evaluation — one that had just finished running the agent that answered it.
Approach
The runner records the trajectory itself. A new
evaluations/trajectory.pywraps the caller's tool implementations once per row, before they are handed to the handler.Wrapping there is what makes this work with every handler package without changing any of them: a handler still looks a tool up by the key the model named and calls it, exactly as before. The alternative — having each of the six handler packages return a trajectory — would have been six changes, six chances to drift, and nothing for a custom caller-supplied handler.
The recorded trajectory reaches judges two ways:
{{message_history}}— the row input, then the trajectory, then the generated output, then the formatting instructions. The placement is load-bearing twice: it is where the calls happened, andmessage_historyis the variable every judge cloned from the AI Library's default templates already references, so a trajectory rubric needs no new judge template.{{tool_trajectory}}— the same block on its own, for a rubric that asks about tool use without restating the conversation.What a judge now sees:
Properties pinned by tests
parse_templatepass, so a{{...}}sequence coming back from a tool is never expanded. Tested with a tool that returns the literal text{{expected_output}}.tool_trajectoryis"", so a judge authored before this reads exactly themessage_historyit read before.Design notes
row_fields()/render_row_trajectory()keep the row-record key names in one module: a key renamed on the write side without its reader would silently render every trajectory as empty, which reads exactly like an agent that called no tools.Deliberately out of scope
Scorers have no access to the trajectory.
Scorer.fn(row, output)is the contract, and the trajectory is not dataset-owned so it does not belong onDatasetRow. A deterministic trajectory check ("calledlookup_orderexactly once") is a natural follow-up but needs a contract change, not a quiet signature widening.Validation
uv run pytest -q— 1194 passed, 11 skippeduv run mypy packages/client/src/launchdarkly_ai_server/evaluations— cleanuv run ruff format --check/uv run ruff check— cleantest_evaluations_trajectory.py, 6 new end-to-end tests intest_evaluations_run.pyLanguage-agnostic spec: launchdarkly/ai-sdks-monorepo#13 (new §8.7.4, amended §8.8.3).
🤖 Generated with Claude Code