Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ A judge is shown the tool calls the row made on the way to its output, so a rubr

The harness records this itself: it wraps your tool implementations once per row before handing them to the handler, so every handler package is covered without changes and your tools still return and raise exactly what they did before.

**This is not specific to offline evaluations.** Online judges — both the inline ones sampled by `config().invoke()` and the deferred ones you run from a `JudgeTask` on a background thread — are shown the same trajectory, built by the same function. See [Judges see one conversation](#judges-see-one-conversation).

The trajectory is rendered into **`{{message_history}}`** — the row input, then the trajectory, then the generated output, then the formatting instructions, in that order. There is no separate trajectory variable: `message_history` is already the transcript variable every judge reads, and judges built from the AI Library's default templates reference it, so a trajectory rubric can be written against an existing judge template with no new placeholder.

```
Expand All @@ -137,6 +139,24 @@ Tool calls made while producing the response, in order:

A row with tools that called none of them says so explicitly, which is the finding a tool-selection rubric most needs. A run with no tools adds no block at all, so judges written before trajectories existed read exactly the history they read before.

#### Judges see one conversation

All three judge paths build `{{message_history}}` through a single function, `judge_scoring.build_message_history`:

| Path | Entry point |
| --- | --- |
| Online, inline | `config().invoke()` → `run_judges` |
| Online, deferred | `config(skip_judges=True).invoke()` → `run_judge(task, handlers)` on your own thread |
| Offline | `init_evaluations().run(criteria=[Judge(...)])` |

Each one is the input, then the tool trajectory, then the output, then the `{score, reasoning}` format block, with empty parts skipped. A judge therefore grades the same conversation wherever it runs, which is what makes a rubric portable between a production sample and a dataset replay.

They did not always agree, and that is why this is a single function now: each path used to join its own history. The offline one carried the row input, the inline one carried the user input, and the deferred one carried **neither** — so a deferred judge graded a response with no request beside it. `JudgeTask` gained `user_input` and `trajectory` to close that.

For the deferred path those two fields travel on the task, which stays picklable — the trajectory crosses as the rendered string, not the structured record.

A **graph-level** judge (`graph_judge`) gets no trajectory: it grades a final answer produced across several nodes, and splicing their trajectories together would describe a conversation that never happened. Per-node judges inside a graph do get their own node's.

Two limits keep a trajectory from spending the judge's context window: at most 50 recorded calls per row and 2000 characters per rendered argument bag or result, with anything beyond either reported as a count or marked truncated. Calls past the limit still execute — truncation drops the record, never the work. A `NativeTool` runs inside the provider, so no local wrapper sees it; such a tool is left out of the trajectory and out of the "Tools available" line, since naming a tool whose use cannot be shown would invite a judge to conclude the model ignored it.

A tool result is now judge-prompt input. It stays literal for the same reason the generated output does: the judge config is handed to the handler unrendered and the handler makes exactly one template pass, so a `{{...}}` sequence coming back from a tool is never expanded into the judge prompt.
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/launchdarkly_ai_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ async def invoke(
handlers=resolved_handler_list,
llm_response=llm_str,
base_track_data=track_data,
user_input=user_input,
trajectory=result.get("trajectory", ""),
)
return ProviderResponse(
response=parsed_response,
Expand All @@ -137,6 +139,7 @@ async def invoke(
handler=handler,
handlers=resolved_handler_list,
user_input=user_input,
trajectory=result.get("trajectory", ""),
llm_response=llm_str,
base_track_data=track_data,
tool_handlers=resolved_tools,
Expand Down Expand Up @@ -215,6 +218,7 @@ async def _stream_events(
handler=handler,
handlers=resolved_handler_list,
user_input=user_input,
trajectory=done_event.get("trajectory", ""),
llm_response=done_event.get("response", ""),
base_track_data=track_data,
tool_handlers=resolved_tools,
Expand Down
40 changes: 17 additions & 23 deletions packages/client/src/launchdarkly_ai_server/evaluations/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@

from ..judge_scoring import (
FORMATTING_INSTRUCTIONS,
build_message_history,
numeric_score,
parse_judge_response,
)
from ..lifecycle import extract_variation
from ..trajectory import (
TrajectoryRecorder,
render_row_trajectory,
row_fields,
)
from ..types import NativeTool
from ..utils import (
collapse_messages_to_instructions,
Expand All @@ -35,7 +41,6 @@
LDJudgeCriterionEventPayload,
TokenUsage,
)
from .trajectory import TrajectoryRecorder, render_row_trajectory, row_fields
from .types import (
DatasetRef,
DatasetRow,
Expand Down Expand Up @@ -712,32 +717,21 @@ def _judge_variables(
# reading the history sees the request, what the agent did about it, and
# what it finally answered, in order.
trajectory = render_row_trajectory(row_result)
# message_history carries FORMATTING_INSTRUCTIONS the same way the
# online path builds it (judges.run_judges), because that -- not the
# standalone formatting_instructions variable below -- is what every
# judge built from the AI Library's default templates (accuracy,
# relevance, toxicity, and any judge cloned from them) actually
# references. A judge authored before this variable existed must keep
# getting scored without edits.
#
# The trajectory goes here and nowhere else. It was briefly also
# exposed as a standalone tool_trajectory variable, which bought
# nothing: this is already the transcript variable every judge reads,
# and two overlapping variables only invited a rubric to interpolate
# both and pay for the trajectory twice.
# Built by the shared builder, not inline here: this path and both
# online paths must show a judge the same conversation, and they did
# not while each one joined its own. The trajectory goes into
# message_history and nowhere else -- it is already the transcript
# variable every judge cloned from the AI Library's default templates
# reads, so a second overlapping variable only invited a rubric to
# interpolate both and pay for the trajectory twice.
variables.update(
{
"input": row_result.get("input") or "",
"response_to_evaluate": output if output is not None else "",
"message_history": "\n\n".join(
str(value)
for value in (
row_result.get("input"),
trajectory,
output,
FORMATTING_INSTRUCTIONS,
)
if value
"message_history": build_message_history(
user_input=row_result.get("input"),
trajectory=trajectory,
output=output,
),
"expected_output": expected if expected is not None else "",
"ground_truth_context": (
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/launchdarkly_ai_server/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ async def run_node(
base_track_data=result["track_data"],
tool_handlers=tool_handlers,
graph_key=key,
trajectory=result.get("trajectory", ""),
)

if from_node:
Expand Down Expand Up @@ -379,6 +380,7 @@ def _fn(*a: Any, **kw: Any) -> str:
base_track_data=result["track_data"],
tool_handlers=tool_handlers,
graph_key=key,
trajectory=result.get("trajectory", ""),
)

next_node = nodes.get(chosen[0]) if chosen else None
Expand Down
47 changes: 41 additions & 6 deletions packages/client/src/launchdarkly_ai_server/judge_scoring.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
"""Shared scoring contract for LaunchDarkly AI Judge invocations.
"""Shared contract for LaunchDarkly AI Judge invocations.

Both judge execution paths — the online path (``judges.run_judges``, sampled
per invocation) and the offline evaluations path (``evaluations.runner``) —
prompt a judge model for the same ``{"score": <0-1>, "reasoning": <string>}``
JSON shape and must parse it the same way. This module owns that contract so
the two paths cannot drift.
Three judge execution paths exist — the online inline path
(``judges.run_judges``, sampled per invocation), the online deferred path
(``judges.run_judge``, from a ``JudgeTask`` on a background thread), and the
offline evaluations path (``evaluations.runner``). All three prompt a judge
model for the same ``{"score": <0-1>, "reasoning": <string>}`` JSON shape, and
all three must show the judge the same conversation. This module owns both
halves of that contract so the paths cannot drift.

They did drift. Each path built ``message_history`` with its own inline join:
the offline one carried the row input, the inline online one carried the user
input, and the deferred one carried neither -- a judge grading the same
response saw a different conversation depending on which path reached it. The
trajectory landing in only one of the three is what made that visible.
:func:`build_message_history` is now the only place it is built.
"""

from __future__ import annotations
Expand All @@ -27,6 +36,32 @@
)


def build_message_history(
*,
user_input: Any = None,
trajectory: Any = None,
output: Any = None,
) -> str:
"""The conversation a judge is shown, as the ``message_history`` variable.

Ordered the way it happened: what was asked, what the agent did about it,
what it answered, and finally how to format the verdict. Empty parts are
skipped, so a run with no tools produces exactly the history it produced
before trajectories existed and a judge authored against it is unaffected.

``FORMATTING_INSTRUCTIONS`` is appended here rather than by each caller,
because every judge built from the AI Library's default templates
references ``{{message_history}}`` and not ``{{formatting_instructions}}``
-- a judge that stopped being told the JSON shape would start returning
prose, and every one of its results would become an invalid-output error.
"""
return "\n\n".join(
str(part)
for part in (user_input, trajectory, output, FORMATTING_INSTRUCTIONS)
if part
)


def numeric_score(score: Any) -> float | None:
"""Return ``score`` as a float only when it already is a finite number.

Expand Down
27 changes: 22 additions & 5 deletions packages/client/src/launchdarkly_ai_server/judges.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from .conversation import with_judge_evaluation
from .judge_scoring import (
FORMATTING_INSTRUCTIONS,
build_message_history,
numeric_score,
parse_judge_response,
)
Expand Down Expand Up @@ -53,10 +53,16 @@ async def run_judges(
base_track_data: TrackData,
tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None,
graph_key: str | None = None,
trajectory: str = "",
) -> dict[str, JudgeResult]:
"""
Runs any judges configured on ``config['judgeConfiguration']`` against the
produced output. Each judge is itself a tracked AI call.

``trajectory`` is the rendered tool-call trajectory of the invocation being
judged, from ``execute_and_track``. It defaults to empty so a caller that
has none -- a graph-level judge over several nodes, for instance -- is
unchanged, and so is a judge for a config with no tools.
"""
from .lifecycle import extract_variation
from .tracking import execute_and_track
Expand Down Expand Up @@ -145,8 +151,10 @@ async def run_judges(
else judge_ai_config
)

message_history = "\n\n".join(
filter(None, [user_input, llm_response, FORMATTING_INSTRUCTIONS])
message_history = build_message_history(
user_input=user_input,
trajectory=trajectory,
output=llm_response,
)

async with with_judge_evaluation(judge_key) as record_evaluation:
Expand Down Expand Up @@ -208,6 +216,8 @@ async def build_judge_tasks(
handlers: list[ProviderHandler] | None = None,
llm_response: str,
base_track_data: TrackData,
user_input: str | None = None,
trajectory: str = "",
) -> list[JudgeTask]:
"""
Resolves all judges configured on ``config['judgeConfiguration']`` into
Expand Down Expand Up @@ -306,6 +316,8 @@ async def build_judge_tasks(
judge_config=judge_ai_config,
judge_meta=judge_meta,
actual_output=llm_response,
user_input=user_input,
trajectory=trajectory,
user_context=user_context,
judge_provider=judge_provider,
judge_mode=judge_mode,
Expand Down Expand Up @@ -374,8 +386,13 @@ def _matches(h: ProviderHandler) -> bool:
else task.judge_config
)

message_history = "\n\n".join(
filter(None, [task.actual_output, FORMATTING_INSTRUCTIONS])
# user_input and trajectory come off the task rather than being omitted:
# this path used to build a history with neither, so a judge grading the
# same response saw a different conversation than the inline path did.
message_history = build_message_history(
user_input=task.user_input,
trajectory=task.trajectory,
output=task.actual_output,
)

async with with_judge_evaluation(task.config_key) as record_evaluation:
Expand Down
36 changes: 33 additions & 3 deletions packages/client/src/launchdarkly_ai_server/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections.abc import AsyncGenerator, Callable
from typing import Any

from .trajectory import TrajectoryRecorder, render_trajectory
from .types import (
NATIVE_TOOL_KEY,
AiConfigRep,
Expand Down Expand Up @@ -138,7 +139,16 @@ async def execute_and_track(
client = get_client()
ld_ctx = to_ld_context(client, user_context)

tracked_tool_handlers = wrap_tool_handlers(tool_handlers, ld_ctx, track_data)
# Recording is composed *inside* the tracking wrapper, on the original map,
# so the recorder still sees a NativeTool as a NativeTool and skips it. If
# it wrapped the tracked map instead it would see the callable stub
# wrap_tool_handlers substitutes for a native tool, and would show a judge
# a tool call with an empty result while the provider's real result stayed
# invisible. One recorder per invocation, since invocations run concurrently.
recorder = TrajectoryRecorder()
tracked_tool_handlers = wrap_tool_handlers(
recorder.wrap(tool_handlers or {}), ld_ctx, track_data
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 398dbad. Configure here.

merged_variables: dict[str, Any] = {
**(variables or {}),
"ldContext": {**user_context},
Expand Down Expand Up @@ -172,7 +182,19 @@ async def execute_and_track(

raw_output = result.get("output")
response = raw_output if raw_output is not None else ""
return {"usage": usage, "response": response, "track_data": track_data}
return {
"usage": usage,
"response": response,
"track_data": track_data,
# Rendered here rather than returned structurally: the one consumer is
# message_history, and a JudgeTask has to stay picklable for the
# background path.
"trajectory": render_trajectory(
recorder.invocations,
observable_tools=recorder.observable_tools,
omitted=recorder.omitted,
),
}


async def execute_and_stream(
Expand Down Expand Up @@ -219,7 +241,10 @@ async def execute_and_stream(
client = get_client()
ld_ctx = to_ld_context(client, user_context)

tracked_tool_handlers = wrap_tool_handlers(tool_handlers, ld_ctx, track_data)
recorder = TrajectoryRecorder()
tracked_tool_handlers = wrap_tool_handlers(
recorder.wrap(tool_handlers or {}), ld_ctx, track_data
)
merged_variables: dict[str, Any] = {
**(variables or {}),
"ldContext": {**user_context},
Expand Down Expand Up @@ -275,4 +300,9 @@ async def execute_and_stream(
"response": full_text,
"usage": usage,
"track_data": track_data,
"trajectory": render_trajectory(
recorder.invocations,
observable_tools=recorder.observable_tools,
omitted=recorder.omitted,
),
}
Loading
Loading