fix(judges): a judge's own config must not fail the run it grades - #17
Merged
Merged
Conversation
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
force-pushed
the
alexis/judge-failures-are-not-fatal
branch
from
August 10, 2026 17:49
aef5c2b to
aba6200
Compare
apucacao
marked this pull request as ready for review
August 10, 2026 17:53
andrewklatzke
approved these changes
Aug 14, 2026
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.
10 tasks
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.
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:62inrunJudges, andjudges.ts:188inbuildJudgeTasks). That call throwsVariation <key> is not enabledwhen the judge's served variation is disabled. Four call sites awaitrunJudgeswithout a guard:client.ts:108,client.ts:160,graph.ts:211andgraph.ts:317. The error therefore travelled all the way out.What the caller saw:
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
agentintegration scenario against a live environment. It died withVariation launch-darkly-summarization-judge is not enabled, after the model had already answered.The fix
Both loops now call
resolveJudge. It returnsnulland logs, instead of throwing. The run skips a judge it cannot resolve, and continues.Two patterns in this codebase already work that way:
judges.tsalready 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:inspectConfigreturns{ 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.tshas a test that pins the behaviour.Tests
Three new tests, 823 to 826. I checked each one by reverting
judges.tsand confirming that those three, and only those three, fail.judges.test.ts: the run skips a disabled judge, returns{}, never callsexecuteAndTrack, 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 ofjudgeTasks, and still returns the response.yarn build,yarn typecheck,yarn test(826 tests),yarn code:checkandyarn lint:pkgall 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 catchesextractVariationerrors, logsJudge '<key>' skipped:, and returnsnullinstead of propagating.runJudgesandbuildJudgeTasksboth 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()withskipJudges: truestill returning the LLM response with emptyjudgeTasks.Reviewed by Cursor Bugbot for commit aba6200. Bugbot is set up for automated code reviews on this repo. Configure here.