Conversation
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
marked this pull request as ready for review
September 18, 2026 00:04
donei003
merged commit Sep 18, 2026
bbc59c7
into
feature/ld-judges-tool-trajectory
7 of 8 checks passed
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 398dbad. Configure here.
| recorder = TrajectoryRecorder() | ||
| tracked_tool_handlers = wrap_tool_handlers( | ||
| recorder.wrap(tool_handlers or {}), ld_ctx, track_data | ||
| ) |
There was a problem hiding this comment.
Handoff tools pollute judge trajectories
Medium Severity
execute_and_track now records every callable into the trajectory, including synthetic __handoff_* tools that route() injects. Per-node judges therefore see those names under tools available and as real calls, and a node with no customer tools still gets a trajectory block. wrap_tool_handlers already omits the same tools from $ld:ai:tool_call as non-work.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 398dbad. Configure here.
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 #89 — base is
feature/ld-judges-tool-trajectory, so the diff is just this change. Retarget tomainwhen #89 merges.Problem
#89 gave the trajectory to the offline judge only. The two online paths built their own
message_historyand 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:
message_historywasrun_judges)run_judge)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_historyis 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 alongsideresponseandtrack_data.client.pyand the two per-nodegraph.pyjudge runs thread it through.JudgeTaskgainsuser_inputandtrajectory— plain strings, since every field on it has to survive pickling to a worker thread; a test pins that.trajectory.pymoves fromevaluations/to the package root, since it is no longer evaluations-specific.The
NativeTooldecision you asked aboutRecording is composed inside
wrap_tool_handlers, on the original tool map, so the recorder still sees aNativeToolas aNativeTooland skips it — identically to offline.Wrapping the tracked map instead was the tempting option, because native calls are locally observable online:
wrap_tool_handlerssubstitutes 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_callstill 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_judgegrades 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 skippeduv run mypy packages/client/src/launchdarkly_ai_server— clean;ruff check/format --check— cleantest_judge_message_history.py: the builder's ordering and skipping, the trajectory reaching both online paths, inline-vs-deferred agreement,JudgeTaskpicklability, online capture through the realexecute_and_track, native-tool exclusion, and$ld:ai:tool_callsurviving the compositionSpec follow-up for
ai-sdks-monorepo§3.13/§3.14 to come once this shape is agreed.🤖 Generated with Claude Code
Note
Overview
Unifies what judges see across inline online, deferred (
JudgeTask), and offline evaluation paths by introducingjudge_scoring.build_message_historyas the only place{{message_history}}is assembled: user input, rendered tool trajectory, model output, then JSON formatting instructions (empty parts skipped).Online invocations now capture tool trajectories in
execute_and_track/execute_and_stream(per-invocationTrajectoryRecorder, composed insidewrap_tool_handlerson the original tool map soNativeToolstays unrecorded). The renderedtrajectorystring is returned with the generation result and threaded throughclient.py,graph.py,run_judges, andbuild_judge_tasks.Deferred judges gain
JudgeTask.user_inputandJudgeTask.trajectory(picklable strings), fixing the prior bug where deferred grading omitted the request entirely. Offline scoring inevaluations/runner.pyuses the same builder instead of inline joins.trajectorymoves fromevaluations/to the package root for shared online/offline use. Docs andtest_judge_message_history.pyassert inline vs deferred byte-identical history and online capture behavior.Reviewed by Cursor Bugbot for commit 398dbad. Bugbot is set up for automated code reviews on this repo. Configure here.