Conversation
donei003
force-pushed
the
feature/ld-judges-tool-trajectory
branch
2 times, most recently
from
September 17, 2026 04:07
65594c4 to
1e0625e
Compare
donei003
force-pushed
the
feature/ld-judges-tool-trajectory
branch
from
September 17, 2026 16:24
1e0625e to
d61c13c
Compare
donei003
added a commit
that referenced
this pull request
Sep 18, 2026
Stacked on #89 — base is `feature/ld-judges-tool-trajectory`, so the diff is just this change. Retarget to `main` when #89 merges. ## Problem #89 gave the trajectory to the **offline** judge only. The two online paths built their own `message_history` and neither included it — so a trajectory rubric silently degraded to grading prose when run online, and a judge grading the same response saw a different conversation depending on which path reached it. They had already drifted before the trajectory made it visible: | Path | `message_history` was | | --- | --- | | Offline evals | row input + **trajectory** + output + format block | | Online inline (`run_judges`) | user input + output + format block | | Online deferred (`run_judge`) | output + format block — **no input at all** | A deferred judge was grading a response with no request beside it. That is a pre-existing bug this PR also fixes. ## Change **`judge_scoring.build_message_history` is now the only place a history is built** — in the module that already owns the `{score, reasoning}` contract, for exactly the same reason. All three paths call it, and a test asserts the inline and deferred paths produce **byte-identical** output for one row. Online capture happens in `execute_and_track` / `execute_and_stream`, which now return the rendered trajectory alongside `response` and `track_data`. `client.py` and the two per-node `graph.py` judge runs thread it through. `JudgeTask` gains `user_input` and `trajectory` — plain strings, since every field on it has to survive pickling to a worker thread; a test pins that. `trajectory.py` moves from `evaluations/` to the package root, since it is no longer evaluations-specific. ## The `NativeTool` decision you asked about **Recording is composed *inside* `wrap_tool_handlers`, on the original tool map**, so the recorder still sees a `NativeTool` as a `NativeTool` and skips it — identically to offline. Wrapping the tracked map instead was the tempting option, because native calls *are* locally observable online: `wrap_tool_handlers` substitutes a callable tracking stub. But that stub returns nothing, so recording it would show a judge **a tool call with an empty result** while the provider's real result stayed invisible — worse than not showing it. Tests assert the native tool is absent from the online trajectory and that `$ld:ai:tool_call` still fires underneath the recorder. Tell me if you'd rather natives appear online with an explicit "result not observable" marker; it's a small change now that one function owns the rendering. ## Graph-level judges get no trajectory, deliberately `graph_judge` grades a final answer produced across several nodes. Splicing their trajectories together would describe a conversation that never happened, so it gets `""`. Per-node judges inside a graph do get their own node's. ## Validation - `uv run pytest -q` — **1282 passed**, 11 skipped - `uv run mypy packages/client/src/launchdarkly_ai_server` — clean; `ruff check` / `format --check` — clean - 13 new tests in `test_judge_message_history.py`: the builder's ordering and skipping, the trajectory reaching both online paths, inline-vs-deferred agreement, `JudgeTask` picklability, online capture through the real `execute_and_track`, native-tool exclusion, and `$ld:ai:tool_call` surviving the composition Spec follow-up for `ai-sdks-monorepo` §3.13/§3.14 to come once this shape is agreed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 trajectory reaches judges through message_history, interleaved
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, so a trajectory rubric
needs no new judge template. There is deliberately no standalone
trajectory variable: message_history is already the transcript variable,
and a second overlapping one only invited a rubric to interpolate both
and pay for the trajectory twice. 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>
The trajectory reached only the offline evaluations judge. The two online
paths built their own message_history and neither included it, so the
same judge grading the same response saw a different conversation
depending on which path reached it -- and a trajectory rubric silently
degraded to grading prose when run online.
They had already drifted before the trajectory made it visible:
offline row input + trajectory + output + format block
inline user input + + output + format block
deferred + output + format block
The deferred path carried no input at all, so a background judge graded
a response with no request beside it.
judge_scoring.build_message_history is now the only place a history is
built, in the module that already owns the {score, reasoning} contract
for the same reason. All three paths call it, and a test asserts the
inline and deferred paths produce byte-identical output for one row.
Capture online happens in execute_and_track and execute_and_stream,
which return the rendered trajectory alongside response and track_data.
client.py and the two per-node graph.py judge runs thread it through.
JudgeTask gains user_input and trajectory -- plain strings, since every
field on it has to survive pickling to a worker thread.
Recording is composed *inside* wrap_tool_handlers, on the original tool
map, so the recorder still sees a NativeTool as a NativeTool and skips
it. Wrapping the tracked map instead would have recorded the sync
callable stub that wrapper substitutes for a native tool, showing a
judge a call with an empty result while the provider's real result
stayed invisible. Both paths now treat natives identically, and
$ld:ai:tool_call still fires underneath -- both asserted.
trajectory.py moves from evaluations/ to the package root: it is no
longer evaluations-specific.
A graph-level judge deliberately gets no trajectory. It grades a final
answer produced across several nodes, and splicing their trajectories
would describe a conversation that never happened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
donei003
force-pushed
the
feature/ld-judges-tool-trajectory
branch
from
September 18, 2026 00:07
bbc59c7 to
c69920d
Compare
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.
Now carries two commits: the trajectory capture, and #98 (merged in here) which made every judge path share one
message_history.Problem
A judge can only grade what it is shown. Handler packages record tool traffic onto OpenTelemetry spans and return only
{output, usage}, so by the time a judge ran, the calls made on the way to that output were gone. "Did the agent call the right tool, in the right order, with the right arguments?" was an unaskable question of an SDK that had just run the agent that answered it.Underneath that, the three judge paths had already drifted. Each built
message_historywith its own inline join:message_historywasrun_judges)run_judge)A deferred judge was grading a response with no request beside it. That is a pre-existing bug, fixed here as a consequence of unifying.
Approach
Capture by wrapping the tool map, which both paths already own — so every handler package is covered without changing any of them, and a custom caller-supplied handler is covered too. A handler still resolves a tool by the key the model named and calls it.
evaluations/runner.py.tracking.execute_and_track/execute_and_stream, which now return the rendered trajectory alongsideresponseandtrack_data.judge_scoring.build_message_historyis the only place a history is built — in the module that already owns the{score, reasoning}contract, for exactly the same reason. It orders the conversation the way it happened: input → trajectory → output → format block, skipping empty parts. All three paths call it.What a judge now sees:
Verified live against a real LaunchDarkly judge: the judge's reasoning cited the call, its arguments, and its result — all three only visible via the trajectory.
Properties pinned by tests
{{expected_output}}.message_historyit read before.Design notes
{{tool_trajectory}}too; it overlappedmessage_historyand bought nothing, while inviting a rubric to interpolate both and pay for the trajectory twice. Confirmed live — a real judge config's own scaffolding already interpolates{{message_history}}. A test pins its absence. Sincemessage_historyis what judges cloned from the AI Library's default templates read, an existing judge becomes a trajectory judge by editing its rubric text alone.wrap_tool_handlerssubstitutes a callable tracking stub, so a native call is locally observable — but the stub returns nothing, so recording it would show a judge a call with an empty result while the provider's real result stayed invisible. Recording is therefore composed inside that wrapper, on the original map. Tests assert both the exclusion and that$ld:ai:tool_callstill fires underneath.JudgeTaskgainsuser_inputandtrajectoryas plain strings — every field on it has to survive pickling to a worker thread; a test pins that.graph_judgegrades an answer produced across several nodes, and splicing their trajectories would describe a conversation that never happened. Per-node judges get their own node's.trajectory.pysits at the package root, not underevaluations/, since it is no longer evaluations-specific.Deliberately out of scope
Scorers cannot see the trajectory.
Scorer.fn(row, output)is the contract, and the trajectory is not dataset-owned so it does not belong onDatasetRow. A deterministic check like "calledlookup_orderexactly once" is a natural follow-up but needs a contract change, not a quiet signature widening.Validation
uv run pytest -q— 1282 passed, 11 skippeduv run mypy packages/client/src/launchdarkly_ai_server— clean;ruff check/format --check— cleanai-sdk-evaluations-example(launchdarkly-labs/ai-sdk-evaluations-example#5), including a live run against a real judgeLanguage-agnostic spec: launchdarkly/ai-sdks-monorepo#13 — being restructured to describe the shared flow rather than the offline phase alone, now that this shape is settled. Submodule pointer: launchdarkly/ai-sdks-monorepo#14.
🤖 Generated with Claude Code