Skip to content

feat(client): grounded judge context and per-judge diagnostics - #55

Draft
apucacao wants to merge 2 commits into
alexis/judge-output-format-ignoredfrom
alexis/grounded-judge-context
Draft

apucacao wants to merge 2 commits into
alexis/judge-output-format-ignoredfrom
alexis/grounded-judge-context

Conversation

@apucacao

@apucacao apucacao commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Why

A judge is a second AI call that grades the first one. Three things were wrong with how it ran. This PR fixes all three.

  1. The judge was grading blind. It saw the user request and the model's answer, but not the data the run actually looked at. So it had to guess. It now receives that evidence as context.

  2. A broken judge could sink a good answer. Judges run after the primary call is finished and billed. If the judge's own config or provider failed, the error propagated out and threw away the response the caller already paid for. Each judge is now isolated: its failure is recorded, not raised.

  3. Failures were invisible. A judge that was disabled, timed out, or errored simply vanished from the results, with no way to tell which. Every skip or failure now leaves a JudgeDiagnostic behind.

What changed

  • ConfigArgs gains judgeContext (a lazy callback, resolved once after the primary call) and judgeTimeoutMs.
  • ProviderResponse, the stream done event, and JudgeTask gain judgeContext; those plus ProviderGraphResponse gain judgeDiagnostics.
  • The context is validated as JSON under 64 KiB, then injected into each judge's message_history inside UNTRUSTED_ACTUATOR_EVIDENCE_BEGIN / ..._END. It never reaches the primary model and is never recorded in telemetry. A judge's own prompt is a LaunchDarkly AI Config and stays swappable at runtime; the evidence rides in a fixed slot that prompt cannot displace.

Breaking change

runJudges (internal) and buildJudgeTasks (exported) now return { judgeResults, judgeDiagnostics } and { judgeTasks, judgeDiagnostics } instead of the bare value. The per-entry shape inside is unchanged. This is a clean break on purpose: a wrapper that returned only results would reintroduce the silent-failure hole point 3 closes.

Stacked

Based on #56 (fix(judges): a judge config's outputFormat must not reach the provider), not on main, because both change judges.ts. Review #56 first; its diff is small. GitHub retargets this PR to main automatically when #56 merges.

@apucacao
apucacao force-pushed the alexis/grounded-judge-context branch from 1999481 to 8841bc3 Compare September 11, 2026 10:16
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/grounded-judge-context branch from 8841bc3 to 63e43ad Compare September 11, 2026 14:06
@apucacao
apucacao changed the base branch from main to alexis/judge-output-format-ignored September 11, 2026 14:06
@apucacao
apucacao force-pushed the alexis/grounded-judge-context branch from 63e43ad to 6ff4d6c Compare September 11, 2026 18:29
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 force-pushed the alexis/grounded-judge-context branch from 6ff4d6c to 2e68040 Compare September 11, 2026 18:29
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.

1 participant