Skip to content

fix(judges): a judge's own config must not fail the run it grades - #17

Merged
apucacao merged 1 commit into
mainfrom
alexis/judge-failures-are-not-fatal
Sep 25, 2026
Merged

apucacao merged 1 commit into
mainfrom
alexis/judge-failures-are-not-fatal

Conversation

@apucacao

@apucacao apucacao commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

The bug

Warning

This content was written by AI.

A judge grades a response that already exists. By the time the judge runs, the provider call is finished and billed.

Both judge loops resolved the judge's own AI Config with an unguarded extractVariation (judges.ts:62 in runJudges, and judges.ts:188 in buildJudgeTasks). That call throws Variation <key> is not enabled when the judge's served variation is disabled. Four call sites await runJudges without a guard: client.ts:108, client.ts:160, graph.ts:211 and graph.ts:317. The error therefore travelled all the way out.

What the caller saw:

  1. The model call succeeds. LaunchDarkly bills it.
  2. Someone turns off a judge attached to the config.
  3. The whole call fails. The caller gets an exception, not the response they paid for.

The trigger is deliberate and harmless. Someone flips a judge off in the user interface. Every request that judge is attached to then starts to fail.

How I found it

I ran the agent integration scenario against a live environment. It died with Variation launch-darkly-summarization-judge is not enabled, after the model had already answered.

The fix

Both loops now call resolveJudge. It returns null and logs, instead of throwing. The run skips a judge it cannot resolve, and continues.

Two patterns in this codebase already work that way:

  • judges.ts already skips a judge that has no compatible handler, rather than failing. The comment says "skip this judge rather than calling the wrong provider's API".
  • lifecycle.ts: inspectConfig returns { enabled: false, config: null } for a disabled config, rather than throwing.

A resolution failure on the main config still fails the run. You genuinely cannot run without that config, and client.test.ts has a test that pins the behaviour.

Tests

Three new tests, 823 to 826. I checked each one by reverting judges.ts and confirming that those three, and only those three, fail.

  • judges.test.ts: the run skips a disabled judge, returns {}, never calls executeAndTrack, and logs the skip.
  • judges.test.ts: with two judges where one is disabled, the healthy judge still runs and produces its score.
  • client.test.ts: invoke() leaves the disabled judge out of judgeTasks, and still returns the response.

yarn build, yarn typecheck, yarn test (826 tests), yarn code:check and yarn lint:pkg all pass.

For the reviewer

An earlier change in the pre-migration repo ("fix silent judge errors") made judge error handling louder. This PR keeps the failure visible through console.error. It only stops the failure from ending the run. Please check that this is the balance you want.

Provenance

Ported from launchdarkly-labs/tmp-launchdarkly-ai-sdk#58, which was open against the pre-migration repo and never reached its main, so it is absent from this repo. The commit is unchanged, and every added and removed line here is identical to the original.


Note

Overview
Fixes a failure mode where disabling a judge’s LaunchDarkly AI config could throw after the main model call completed, so callers lost the billed response.

Judge resolution now goes through resolveJudge, which catches extractVariation errors, logs Judge '<key>' skipped:, and returns null instead of propagating. runJudges and buildJudgeTasks both skip unresolvable judges and continue, consistent with skipping judges that have no compatible handler.

Main config resolution is unchanged: a disabled primary flag still fails the run.

Tests cover a fully disabled judge, a mix of disabled and working judges, and invoke() with skipJudges: true still returning the LLM response with empty judgeTasks.

Reviewed by Cursor Bugbot for commit aba6200. Bugbot is set up for automated code reviews on this repo. Configure here.

A judge grades a response that already exists. By the time one runs, the
provider call is finished and billed.

Both judge loops resolved the judge's own AI Config with an unguarded
`extractVariation`. That call throws `Variation <key> is not enabled` when the
config's served variation is disabled. Four call sites await `runJudges`
without a guard, so the error travelled all the way out. The caller paid for a
completion and received an exception instead.

The trigger is deliberate and harmless. Someone toggles a judge off in
LaunchDarkly, and every request that judge is attached to starts to fail. I
found this by running the `agent` integration scenario. It died on `Variation
launch-darkly-summarization-judge is not enabled`, after the model had already
answered.

Route both loops through `resolveJudge`. It returns null and logs instead of
throwing, so the run skips a judge it cannot resolve. That matches how this
file already handles a judge with no compatible handler, and how
`inspectConfig` reports a disabled config rather than throwing.

A resolution failure on the main config still fails the run. You genuinely
cannot run without that config.
@apucacao
apucacao force-pushed the alexis/judge-failures-are-not-fatal branch from aef5c2b to aba6200 Compare August 10, 2026 17:49
@apucacao
apucacao marked this pull request as ready for review August 10, 2026 17:53
@apucacao
apucacao requested a review from andrewklatzke August 11, 2026 20:53
apucacao added a commit that referenced this pull request Sep 11, 2026
Callers can now hand config().invoke()/.stream() a lazy judgeContext
callback that resolves once, right after the primary handler settles
and before output-format parsing — even when no judge ends up
sampled, because sampling controls execution, not the freeze
boundary. The resolved value must be acyclic JSON under 64 KiB; on
success it comes back unchanged on ProviderResponse.judgeContext and
is injected into every judge's message_history inside a delimited
UNTRUSTED_ACTUATOR_EVIDENCE block, so a judge can ground its verdict
in evidence the primary model produced (e.g. via a tool) without that
evidence ever reaching the primary model's own prompt, track data, or
spans.

Every judge now runs inside its own isolation boundary: a disabled
judge config, a provider failure, an unparseable verdict, a track()
failure, or a per-judge timeout (judgeTimeoutMs, default 30s) each
produce one JudgeDiagnostic instead of raising or silently discarding
the response the caller already paid for. Duplicate judge keys in a
single judgeConfiguration only run once. Judge reasoning is capped at
4 KiB before it reaches judgeResults or a worker payload. The
skipJudges path (buildJudgeTasks/runJudge) carries the same resolved
context and diagnostics through JudgeTask so a background worker
thread reconstructs the identical judge prompt.

This also folds in the already-reviewed judge-isolation fix from
public PR #17 (cherry-picked as its own commit): a judge's own AI
Config resolving to a disabled variation no longer throws out of
config().invoke() after the primary call was already billed.

Graph nodes do not receive a caller-supplied judgeContext in v1;
graph()/resolveGraph() forward judgeDiagnostics from a graph-level
judge only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

BREAKING CHANGE: runJudges and buildJudgeTasks now return an object
({ judgeResults, judgeDiagnostics } and { judgeTasks, judgeDiagnostics })
instead of the bare value. The per-entry shape inside judgeResults is
unchanged. runJudges is internal; buildJudgeTasks is exported.
apucacao added a commit that referenced this pull request Sep 11, 2026
Callers can now hand config().invoke()/.stream() a lazy judgeContext
callback that resolves once, right after the primary handler settles
and before output-format parsing — even when no judge ends up
sampled, because sampling controls execution, not the freeze
boundary. The resolved value must be acyclic JSON under 64 KiB; on
success it comes back unchanged on ProviderResponse.judgeContext and
is injected into every judge's message_history inside a delimited
UNTRUSTED_ACTUATOR_EVIDENCE block, so a judge can ground its verdict
in evidence the primary model produced (e.g. via a tool) without that
evidence ever reaching the primary model's own prompt, track data, or
spans.

Every judge now runs inside its own isolation boundary: a disabled
judge config, a provider failure, an unparseable verdict, a track()
failure, or a per-judge timeout (judgeTimeoutMs, default 30s) each
produce one JudgeDiagnostic instead of raising or silently discarding
the response the caller already paid for. Duplicate judge keys in a
single judgeConfiguration only run once. Judge reasoning is capped at
4 KiB before it reaches judgeResults or a worker payload. The
skipJudges path (buildJudgeTasks/runJudge) carries the same resolved
context and diagnostics through JudgeTask so a background worker
thread reconstructs the identical judge prompt.

This also folds in the already-reviewed judge-isolation fix from
public PR #17 (cherry-picked as its own commit): a judge's own AI
Config resolving to a disabled variation no longer throws out of
config().invoke() after the primary call was already billed.

Graph nodes do not receive a caller-supplied judgeContext in v1;
graph()/resolveGraph() forward judgeDiagnostics from a graph-level
judge only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

BREAKING CHANGE: runJudges and buildJudgeTasks now return an object
({ judgeResults, judgeDiagnostics } and { judgeTasks, judgeDiagnostics })
instead of the bare value. The per-entry shape inside judgeResults is
unchanged. runJudges is internal; buildJudgeTasks is exported.
apucacao added a commit that referenced this pull request Sep 11, 2026
Callers can now hand config().invoke()/.stream() a lazy judgeContext
callback that resolves once, right after the primary handler settles
and before output-format parsing — even when no judge ends up
sampled, because sampling controls execution, not the freeze
boundary. The resolved value must be acyclic JSON under 64 KiB; on
success it comes back unchanged on ProviderResponse.judgeContext and
is injected into every judge's message_history inside a delimited
UNTRUSTED_ACTUATOR_EVIDENCE block, so a judge can ground its verdict
in evidence the primary model produced (e.g. via a tool) without that
evidence ever reaching the primary model's own prompt, track data, or
spans.

Every judge now runs inside its own isolation boundary: a disabled
judge config, a provider failure, an unparseable verdict, a track()
failure, or a per-judge timeout (judgeTimeoutMs, default 30s) each
produce one JudgeDiagnostic instead of raising or silently discarding
the response the caller already paid for. Duplicate judge keys in a
single judgeConfiguration only run once. Judge reasoning is capped at
4 KiB before it reaches judgeResults or a worker payload. The
skipJudges path (buildJudgeTasks/runJudge) carries the same resolved
context and diagnostics through JudgeTask so a background worker
thread reconstructs the identical judge prompt.

This also folds in the already-reviewed judge-isolation fix from
public PR #17 (cherry-picked as its own commit): a judge's own AI
Config resolving to a disabled variation no longer throws out of
config().invoke() after the primary call was already billed.

Graph nodes do not receive a caller-supplied judgeContext in v1;
graph()/resolveGraph() forward judgeDiagnostics from a graph-level
judge only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

BREAKING CHANGE: runJudges and buildJudgeTasks now return an object
({ judgeResults, judgeDiagnostics } and { judgeTasks, judgeDiagnostics })
instead of the bare value. The per-entry shape inside judgeResults is
unchanged. runJudges is internal; buildJudgeTasks is exported.
apucacao added a commit that referenced this pull request Sep 11, 2026
Callers can now hand config().invoke()/.stream() a lazy judgeContext
callback that resolves once, right after the primary handler settles
and before output-format parsing — even when no judge ends up
sampled, because sampling controls execution, not the freeze
boundary. The resolved value must be acyclic JSON under 64 KiB; on
success it comes back unchanged on ProviderResponse.judgeContext and
is injected into every judge's message_history inside a delimited
UNTRUSTED_ACTUATOR_EVIDENCE block, so a judge can ground its verdict
in evidence the primary model produced (e.g. via a tool) without that
evidence ever reaching the primary model's own prompt, track data, or
spans.

Every judge now runs inside its own isolation boundary: a disabled
judge config, a provider failure, an unparseable verdict, a track()
failure, or a per-judge timeout (judgeTimeoutMs, default 30s) each
produce one JudgeDiagnostic instead of raising or silently discarding
the response the caller already paid for. Duplicate judge keys in a
single judgeConfiguration only run once. Judge reasoning is capped at
4 KiB before it reaches judgeResults or a worker payload. The
skipJudges path (buildJudgeTasks/runJudge) carries the same resolved
context and diagnostics through JudgeTask so a background worker
thread reconstructs the identical judge prompt.

This also folds in the already-reviewed judge-isolation fix from
public PR #17 (cherry-picked as its own commit): a judge's own AI
Config resolving to a disabled variation no longer throws out of
config().invoke() after the primary call was already billed.

Graph nodes do not receive a caller-supplied judgeContext in v1;
graph()/resolveGraph() forward judgeDiagnostics from a graph-level
judge only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

BREAKING CHANGE: runJudges and buildJudgeTasks now return an object
({ judgeResults, judgeDiagnostics } and { judgeTasks, judgeDiagnostics })
instead of the bare value. The per-entry shape inside judgeResults is
unchanged. runJudges is internal; buildJudgeTasks is exported.
@apucacao
apucacao merged commit 6e85cac into main Sep 25, 2026
8 checks passed
@apucacao
apucacao deleted the alexis/judge-failures-are-not-fatal branch September 25, 2026 15:07
@github-actions github-actions Bot mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants