Skip to content
Draft
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
2 changes: 1 addition & 1 deletion packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ result = await evals.run(
)
```

`LD_API_TOKEN` is required. Configure `LD_SDK_KEY` — or initialize your own client with `init_client(client=...)` — to emit one `$ld:ai:offline-evals:generation` event per generated row, plus one `$ld:ai:offline-evals:criterion` event per `(row, criterion)` when `criteria` are supplied, through the standard SDK event transport. The SDK reports scores; LaunchDarkly rules on them at ingest. A judge served by a different provider than `generation` needs a handler for it in `judge_handlers`. Use `LD_API_BASE_URI` for staging or local management API traffic; it is separate from the SDK delivery setting `LD_BASE_URI`. Evaluation-run links use the explicit `ui_base_uri` option or `LD_UI_BASE_URI` (for example, `https://ld-stg.launchdarkly.com` in staging), defaulting to `https://app.launchdarkly.com`. See the [core evaluations guide](../client/README.md#run-an-evaluation-from-code).
`LD_API_TOKEN` is required. Configure `LD_SDK_KEY` — or initialize your own client with `init_client(client=...)` — to emit one `$ld:ai:offline-evals:generation` event per generated row, plus one `$ld:ai:offline-evals:criterion` event per `(row, criterion)` when `criteria` are supplied, through the standard SDK event transport. The SDK reports scores; LaunchDarkly rules on them at ingest. A judge served by a different provider than `generation` needs a handler for it in `judge_handlers`. Each row's tool calls are recorded during generation and rendered into the judge's `{{message_history}}`, between the row input and the generated output, so a rubric can grade the tool trajectory as well as the final answer. Use `LD_API_BASE_URI` for staging or local management API traffic; it is separate from the SDK delivery setting `LD_BASE_URI`. Evaluation-run links use the explicit `ui_base_uri` option or `LD_UI_BASE_URI`, defaulting to `https://app.launchdarkly.com`; set it when the project is not in production, or a run created elsewhere still links to the production app. See the [core evaluations guide](../client/README.md#run-an-evaluation-from-code).

---

Expand Down
45 changes: 45 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,51 @@ result = await init_evaluations().run(

**The SDK reports scores and never rules on them.** LaunchDarkly derives each row's verdict at ingest by comparing the score against the criterion's stored threshold and success direction, so pass/fail policy is one server-side implementation that applies to every SDK version and to runs already recorded. A judge's direction lives on its AI Config and is injected server-side, keeping the one input a verdict turns on server-attested; a `Scorer` has no LaunchDarkly-side config to read, so it declares its own `success_direction` (default `"higher_is_better"` — set `"lower_is_better"` for a scorer that counts something unwanted, like a regex hit count).

#### Judge the tool trajectory

A judge is shown the tool calls the row made on the way to its output, so a rubric can grade *how* the agent answered and not only *what* it answered — whether it called the right tool, in the right order, with the right arguments, and how it handled a tool that failed.

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.

```
Tools available: lookup_order, issue_refund
Tool calls made while producing the response, in order:
1. lookup_order
arguments: {"id":"A1"}
result: order A1 shipped 2026-08-02
2. issue_refund
arguments: {"id":"A1","amount":19.99}
error: refund window closed
```

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.

**Judges are independent AI Configs, so handlers are routed per judge.** A judge may resolve to a different provider or mode than `generation`, and a handler built for one provider cannot execute another's config. `handler` runs a judge when it provides for that judge's provider; pass handlers for any other providers in `judge_handlers`. Selection prefers a handler naming the judge's provider outright over a wildcard multi-provider adapter, and an agent-mode handler can serve a messages-mode judge with its messages collapsed into one instructions block. A plain callable that declares no `provides_for` routes itself, exactly as it already does for the generation config.

Judges are resolved through flag delivery, and handlers are matched to them, **before** any evaluation records are created — a missing judge or one no handler covers fails the run up front rather than after the generation spend. After that point a criterion failure never aborts the run: an unparseable judge response, an out-of-range score, a raising handler or scorer, and a row whose generation errored each become a per-criterion `ERROR` event with a cause code (`invalid_judge_output`, `invalid_score`, `handler_raised`, `scorer_raised`, `generation_incomplete`) and a top-level `errorMessage`. Event *delivery* is different: the backend needs one result per `(row, criterion)` to finish row accounting, so if tracking a criterion event fails, every remaining result is still attempted and flushed and then `run()` raises — rather than polling to its timeout with the cause hidden.
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
49 changes: 33 additions & 16 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 Down Expand Up @@ -544,11 +550,16 @@ async def _run_rows(

async def invoke(row: DatasetRow) -> dict[str, Any]:
await controller.acquire(config["provider"]["name"])
# One recorder per row, not one per run: rows are generated
# concurrently against the same tool map, so a shared recorder
# would splice one row's tool calls into another's trajectory.
recorder = TrajectoryRecorder()
row_tool_handlers = recorder.wrap(tool_handlers)
started = datetime.now(UTC)
started_clock = time.perf_counter()
try:
result = await handler(
config, row.input, tool_handlers, dict(row.variables)
config, row.input, row_tool_handlers, dict(row.variables)
)
if not isinstance(result, Mapping):
raise TypeError("handler result must be a mapping")
Expand All @@ -564,6 +575,7 @@ async def invoke(row: DatasetRow) -> dict[str, Any]:
"generated_at": completed.isoformat().replace("+00:00", "Z"),
"latency_ms": round((time.perf_counter() - started_clock) * 1000),
"status": "COMPLETE",
**row_fields(recorder),
}
usage = result.get("usage")
if isinstance(usage, Mapping):
Expand All @@ -583,6 +595,9 @@ async def invoke(row: DatasetRow) -> dict[str, Any]:
"latency_ms": round((time.perf_counter() - started_clock) * 1000),
"status": "ERROR",
"error": {"code": 5001, "message": f"handler raised: {error}"},
# The calls that ran before the handler raised are what
# explain why it raised, so an errored row records them too.
**row_fields(recorder),
}
finally:
controller.release()
Expand Down Expand Up @@ -696,25 +711,27 @@ def _judge_variables(
ground_truth = parse_template(ground_truth, variables)
elif expected is not None:
ground_truth = str(expected)
# 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 tool calls the row made on its way to `output`, recorded during
# generation (evaluations.trajectory). It sits between the input and the
# output in message_history because that is where it happened: a judge
# 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)
# 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"),
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
Loading
Loading