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}}` and `{{tool_trajectory}}` variables, 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` (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).

---

Expand Down
26 changes: 26 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,32 @@ 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. The recorded trajectory is rendered into two judge variables:

- **`{{message_history}}`** — the row input, then the trajectory, then the generated output, then the formatting instructions, in that order. Judges built from the AI Library's default templates already reference this variable, so a trajectory rubric can be written against an existing judge template.
- **`{{tool_trajectory}}`** — the same trajectory block on its own, for a rubric that asks about tool use without restating the conversation.

```
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.

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
24 changes: 22 additions & 2 deletions packages/client/src/launchdarkly_ai_server/evaluations/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
LDJudgeCriterionEventPayload,
TokenUsage,
)
from .trajectory import TrajectoryRecorder, render_row_trajectory, row_fields
from .types import (
DatasetRef,
DatasetRow,
Expand Down Expand Up @@ -544,11 +545,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 +570,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 +590,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,13 +706,21 @@ def _judge_variables(
ground_truth = parse_template(ground_truth, variables)
elif expected is not None:
ground_truth = str(expected)
# 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)
# 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.
# getting scored without edits, which is also why the trajectory is
# added to this variable rather than only to tool_trajectory: a
# trajectory rubric can be written against an existing judge template.
variables.update(
{
"input": row_result.get("input") or "",
Expand All @@ -711,11 +729,13 @@ def _judge_variables(
str(value)
for value in (
row_result.get("input"),
trajectory,
output,
FORMATTING_INSTRUCTIONS,
)
if value
),
"tool_trajectory": trajectory,
"expected_output": expected if expected is not None else "",
"ground_truth_context": (
ground_truth if ground_truth is not None else ""
Expand Down
Loading
Loading