Skip to content

feat(judge): sampled LLM-as-judge scoring pass (DOS-860) - #9

Open
mdostal wants to merge 7 commits into
mainfrom
agent/dostal-dev/083bf0d6
Open

feat(judge): sampled LLM-as-judge scoring pass (DOS-860)#9
mdostal wants to merge 7 commits into
mainfrom
agent/dostal-dev/083bf0d6

Conversation

@mdostal

@mdostal mdostal commented Jul 22, 2026

Copy link
Copy Markdown

Summary

  • Adds judge_score table (migration 121) linked to agent_task_queue, storing correctness/adherence/tone/clarity/trajectory/overall scores (0-100), rationale, judge provider/model, token usage, cost, and calibration_status (defaults to MODELED until a future calibration story lands).
  • New internal/judge package: rubric prompt builder, trajectory builder (reads task_message ordered by seq + issue context so the judge grades the tool-call sequence, not just the final diff), and an Anthropic Messages API-backed Judge implementation (tool-forced JSON output).
  • New judge_score_sampler job registered on the existing DB-backed execution scheduler (internal/scheduler, MUL-2957) rather than a plain ticker — gives leasing/retry/audit for free and matches "no box cron". Sampling is a deterministic hash of task id vs a configured rate (env JUDGE_SAMPLE_RATE, default 5%), so retries don't re-roll the coin. Only registered when JUDGE_ANTHROPIC_API_KEY is set.
  • Judge's own token usage/cost is priced via the existing internal/metrics price table and stored per score row, so the judge's cost is observable and bounded (BatchLimit caps candidates per tick).

Test plan

  • go build ./..., go vet ./...
  • go test ./internal/judge/... — sample-rate boundary/determinism/convergence, Anthropic tool-use response parsing against a fake HTTP server
  • go test ./internal/scheduler/... — integration tests against real Postgres with a fake Judge: sampled subset gets scored (not all), 0% rate scores nothing, already-scored tasks aren't re-billed on a second run
  • Manual smoke test against a real Anthropic API key (not run in this environment)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added judge scoring for completed tasks, including rubric scores, rationales, and calibration status.
    • Added a scheduled sampling process to evaluate a subset of completed tasks.
    • Added a “Judge score” section to task transcripts with overall and detailed metrics.
    • Added localized judge score labels and tooltips in English, Japanese, Korean, and Simplified Chinese.
  • Bug Fixes
    • Judge score retrieval now safely falls back when responses cannot be parsed.

Adds a judge_score table plus a DB-backed scheduler job that samples a
configurable fraction of completed agent_task_queue rows, scores them
against a rubric (correctness/adherence/tone/clarity) and a separate
trajectory score derived from the task_message tool-call sequence, and
writes results with rationale, judge model, and the judge's own
token/cost usage. Scores are labeled MODELED until a future calibration
story lands. Judge is called through an interface so the scheduler job
tests run against a fake, deterministic implementation instead of a
live Anthropic API key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mdostal, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 669f3668-cffb-4571-a16e-292aa8a2dcdc

📥 Commits

Reviewing files that changed from the base of the PR and between b0abf13 and a69065f.

📒 Files selected for processing (1)
  • packages/views/common/task-transcript/judge-score-section.test.tsx

Walkthrough

Adds an LLM-as-judge pipeline that samples completed tasks, scores trajectories through Anthropic, stores rubric results, exposes them through an API, and displays the latest modeled score in task transcripts.

Changes

Judge scoring

Layer / File(s) Summary
Judge evaluation engine
server/internal/judge/*
Defines judge contracts, builds untrusted-data-bounded prompts and task trajectories, calls Anthropic’s forced scoring tool, clamps scores, and tests parsing and sampling behavior.
Sampling and score persistence
server/internal/scheduler/*, server/migrations/*, server/pkg/db/queries/judge_score.sql, server/cmd/server/main.go
Adds scheduled deterministic sampling, sampling decisions, score upserts, cost calculation, migrations, configuration, and integration coverage.
Score API contract and retrieval
server/pkg/protocol/messages.go, server/internal/handler/judge_score.go, server/cmd/server/router.go, packages/core/api/*, packages/core/types/*
Adds score payloads and schemas, serves workspace-scoped task scores, and provides typed client retrieval with fallback parsing.
Transcript score display
packages/views/common/task-transcript/*, packages/views/locales/*/agents.json
Loads scores for completed tasks, displays the newest modeled score and rubric dimensions, and adds localized labels and tooltip text.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Scheduler
  participant Database
  participant TrajectoryBuilder
  participant AnthropicJudge
  participant AnthropicAPI
  participant TranscriptClient

  Scheduler->>Database: List unjudged completed tasks
  Scheduler->>Database: Insert sampling decision
  Scheduler->>TrajectoryBuilder: Build completed-task trajectory
  TrajectoryBuilder-->>Scheduler: Return trajectory
  Scheduler->>AnthropicJudge: Score trajectory
  AnthropicJudge->>AnthropicAPI: Submit rubric tool request
  AnthropicAPI-->>AnthropicJudge: Return rubric scores
  AnthropicJudge-->>Scheduler: Return judge result
  Scheduler->>Database: Upsert judge score
  TranscriptClient->>Database: GET task judge scores
  Database-->>TranscriptClient: Return score payloads
Loading

Poem

I’m a rabbit with scores in my hat,
Sampling task trails—fancy that!
Anthropic hops in,
Rubrics begin,
And transcripts show where the marks sat.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a sampled LLM-as-judge scoring pass.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/dostal-dev/083bf0d6

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

mdostal and others added 5 commits July 21, 2026 20:15
ListUnjudgedCompletedTasks previously excluded only scored tasks, so a
batch of oldest completed tasks that all missed the sample-rate hash was
returned unchanged on every tick forever, permanently halting scoring
past that batch. A new judge_sample_decision table records every task
the sampler considers (sampled or not), which the candidate query now
excludes on instead, guaranteeing forward progress each tick.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Task descriptions and tool call output are attacker-influenceable
content that gets fed straight into the judge's grading prompt. Add a
system-turn instruction establishing that content as untrusted data (not
commands) and wrap it in <untrusted-task-context>/<untrusted-trajectory>
delimiters in the user turn, so an adversarial instruction embedded in a
task or tool output can't steer the judge into inflating its own score.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The integration tests each registered a bare `DELETE FROM judge_score`
cleanup, which targets whatever DATABASE_URL points at — the shared
local dev DB when unset — deleting every row, not just the test's own
fixture rows. seedJudgeFixture's own workspace-delete cleanup already
cascades through agent_task_queue to judge_score/judge_sample_decision,
so the extra unscoped cleanup was both redundant and unsafe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Add GET /api/tasks/:id/judge-scores plus a frontend query + UI section
so the DoD's "UI surfaces scores as MODELED" requirement is met: the
agent transcript dialog now shows the rubric dimensions, overall score,
and rationale for a sampled task, with the calibration_status badge
always rendered so users don't mistake MODELED scores for ground truth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
text-amber-600 on bg-amber-500/15 computed to ~2.83:1 in light mode,
well under the 4.5:1 AA threshold for normal text. text-amber-800
clears AA with margin; dark mode was already fine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/views/common/task-transcript/judge-score-section.tsx`:
- Around line 28-50: Update the score state and loading flow in the component
around the useEffect so each response is stored with its originating taskId, and
clear or invalidate the previous result when taskId or taskStatus changes. Guard
rendering of the selected score so it only occurs when the stored task ID
matches the current taskId and taskStatus is "completed"; otherwise return null.

In `@packages/views/locales/ko/agents.json`:
- Around line 461-464: Update the modeled_tooltip value in the judge_score
Korean locale entry to use natural wording equivalent to “모델 추정 점수이며 아직 사람의 검토와
보정을 거치지 않았습니다.” Leave the title and overall translations unchanged.

In `@server/cmd/server/main.go`:
- Around line 384-400: Update the fallback value assigned to judgeModel in the
JUDGE_ANTHROPIC_API_KEY registration block to use Anthropic’s hyphenated model
ID claude-opus-4-8, while preserving the JUDGE_MODEL environment override and
the surrounding scheduler registration flow.

In `@server/internal/judge/anthropic.go`:
- Around line 13-14: Make the Anthropic endpoint configurable through a BaseURL
field on AnthropicJudge, defaulting to anthropicAPIURL when unset. Update the
request-building logic and constructors around AnthropicJudge to use this
resolved base URL, preserving the current production endpoint while allowing
tests and alternate endpoints without custom transport redirection.

In `@server/internal/judge/prompt.go`:
- Around line 43-88: Update BuildPrompt and sanitize every attacker-controlled
value before interpolation, including t.IssueTitle, t.IssueBody, each step’s
Content, Input, and Output, plus t.FinalError and t.FinalResult. Reuse the
existing sanitizeUntrusted helper so literal structural delimiters are
neutralized while preserving the current prompt layout and truncation behavior.

In `@server/internal/scheduler/jobs_judge_score.go`:
- Around line 182-190: Update judgeCallCostUSD so an unrecognized judge model is
not silently treated as a valid zero-cost result: emit an appropriate warning or
error identifying r.Model, while preserving the existing zero return and normal
priced-model calculation.
- Around line 94-161: Update the sampling flow around InsertJudgeSampleDecision,
BuildTrajectory, j.Score, and InsertJudgeScore so sampled=true is recorded only
after the score is successfully persisted. Continue recording sampled=false
immediately for tasks that miss the sample decision, and ensure failures during
trajectory building, scoring, cost conversion, or score insertion leave no
sampled=true marker so the task can be retried on a later tick.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 369bd3de-970f-43ee-be73-f3c3653466a8

📥 Commits

Reviewing files that changed from the base of the PR and between 9696a37 and b0abf13.

⛔ Files ignored due to path filters (2)
  • server/pkg/db/generated/judge_score.sql.go is excluded by !**/generated/**
  • server/pkg/db/generated/models.go is excluded by !**/generated/**
📒 Files selected for processing (29)
  • packages/core/api/client.ts
  • packages/core/api/schemas.ts
  • packages/core/types/agent.ts
  • packages/core/types/index.ts
  • packages/views/common/task-transcript/agent-transcript-dialog.tsx
  • packages/views/common/task-transcript/index.ts
  • packages/views/common/task-transcript/judge-score-section.tsx
  • packages/views/locales/en/agents.json
  • packages/views/locales/ja/agents.json
  • packages/views/locales/ko/agents.json
  • packages/views/locales/zh-Hans/agents.json
  • server/cmd/server/main.go
  • server/cmd/server/router.go
  • server/internal/handler/judge_score.go
  • server/internal/judge/anthropic.go
  • server/internal/judge/anthropic_test.go
  • server/internal/judge/judge.go
  • server/internal/judge/prompt.go
  • server/internal/judge/sample.go
  • server/internal/judge/sample_test.go
  • server/internal/judge/trajectory.go
  • server/internal/scheduler/jobs_judge_score.go
  • server/internal/scheduler/jobs_judge_score_test.go
  • server/migrations/121_judge_score.down.sql
  • server/migrations/121_judge_score.up.sql
  • server/migrations/122_judge_sample_decision.down.sql
  • server/migrations/122_judge_sample_decision.up.sql
  • server/pkg/db/queries/judge_score.sql
  • server/pkg/protocol/messages.go

Comment on lines +28 to +50
const [scores, setScores] = useState<JudgeScore[] | null>(null);

useEffect(() => {
if (taskStatus !== "completed") return;
let cancelled = false;
api
.listJudgeScores(taskId)
.then((result) => {
if (!cancelled) setScores(result);
})
.catch(() => {
if (!cancelled) setScores([]);
});
return () => {
cancelled = true;
};
}, [taskId, taskStatus]);

if (!scores || scores.length === 0) return null;

// A task can be scored by more than one judge model over time; show the
// most recent pass (GetJudgeScoresByTask orders newest-first).
const score = scores[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope loaded scores to the current task.

loadedScores is never cleared or associated with taskId. If the dialog receives another task, the previous score remains visible until the new request finishes; if the new task is not completed, the early return leaves the wrong score rendered indefinitely. Store the task ID with the response and guard rendering against both taskId and taskStatus.

Proposed fix
-  const [scores, setScores] = useState<JudgeScore[] | null>(null);
+  const [loadedScores, setLoadedScores] = useState<{
+    taskId: string;
+    scores: JudgeScore[];
+  } | null>(null);

...
-        if (!cancelled) setScores(result);
+        if (!cancelled) setLoadedScores({ taskId, scores: result });

...
-  if (!scores || scores.length === 0) return null;
+  if (
+    taskStatus !== "completed" ||
+    !loadedScores ||
+    loadedScores.taskId !== taskId ||
+    loadedScores.scores.length === 0
+  ) {
+    return null;
+  }

-  const score = scores[0];
+  const score = loadedScores.scores[0];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [scores, setScores] = useState<JudgeScore[] | null>(null);
useEffect(() => {
if (taskStatus !== "completed") return;
let cancelled = false;
api
.listJudgeScores(taskId)
.then((result) => {
if (!cancelled) setScores(result);
})
.catch(() => {
if (!cancelled) setScores([]);
});
return () => {
cancelled = true;
};
}, [taskId, taskStatus]);
if (!scores || scores.length === 0) return null;
// A task can be scored by more than one judge model over time; show the
// most recent pass (GetJudgeScoresByTask orders newest-first).
const score = scores[0];
const [loadedScores, setLoadedScores] = useState<{
taskId: string;
scores: JudgeScore[];
} | null>(null);
useEffect(() => {
if (taskStatus !== "completed") return;
let cancelled = false;
api
.listJudgeScores(taskId)
.then((result) => {
if (!cancelled) setLoadedScores({ taskId, scores: result });
})
.catch(() => {
if (!cancelled) setLoadedScores(null);
});
return () => {
cancelled = true;
};
}, [taskId, taskStatus]);
if (
taskStatus !== "completed" ||
!loadedScores ||
loadedScores.taskId !== taskId ||
loadedScores.scores.length === 0
) {
return null;
}
// A task can be scored by more than one judge model over time; show the
// most recent pass (GetJudgeScoresByTask orders newest-first).
const score = loadedScores.scores[0];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/views/common/task-transcript/judge-score-section.tsx` around lines
28 - 50, Update the score state and loading flow in the component around the
useEffect so each response is stored with its originating taskId, and clear or
invalidate the previous result when taskId or taskStatus changes. Guard
rendering of the selected score so it only occurs when the stored task ID
matches the current taskId and taskStatus is "completed"; otherwise return null.

Comment on lines +461 to +464
"judge_score": {
"title": "심사 점수",
"modeled_tooltip": "모델링된 점수이며 아직 사람 검토와 보정되지 않았습니다",
"overall": "전체 {{score}}/100",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the Korean modeled-score tooltip wording.

Use a natural phrasing such as 모델 추정 점수이며 아직 사람의 검토와 보정을 거치지 않았습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/views/locales/ko/agents.json` around lines 461 - 464, Update the
modeled_tooltip value in the judge_score Korean locale entry to use natural
wording equivalent to “모델 추정 점수이며 아직 사람의 검토와 보정을 거치지 않았습니다.” Leave the title and
overall translations unchanged.

Comment thread server/cmd/server/main.go
Comment on lines +384 to +400
// DOS-860: sampled LLM-as-judge scoring pass. Registered only when
// an Anthropic API key is configured — with no key there is no
// judge to call, so the job would just fail every attempt.
if apiKey := os.Getenv("JUDGE_ANTHROPIC_API_KEY"); apiKey != "" {
judgeModel := os.Getenv("JUDGE_MODEL")
if judgeModel == "" {
judgeModel = "claude-opus-4.8"
}
judgeCfg := scheduler.DefaultJudgeScoreSamplerConfig()
if rate, ok := envFloatInUnitInterval("JUDGE_SAMPLE_RATE"); ok {
judgeCfg.SampleRate = rate
}
anthropicJudge := judge.NewAnthropicJudge(apiKey, judgeModel, nil)
if err := schedulerMgr.Register(scheduler.JudgeScoreSamplerJob(pool, anthropicJudge, judgeCfg)); err != nil {
slog.Warn("scheduler: failed to register judge_score_sampler job", "error", err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the correct Anthropic API model ID for Claude Opus 4.8 — claude-opus-4-8 or claude-opus-4.8?

💡 Result:

The correct Anthropic API model ID for Claude Opus 4.8 is claude-opus-4-8 [1][2][3]. Official documentation and resources from Anthropic consistently use this identifier [2][4][5]. It does not contain a period (dot) in the string, as evidenced by its structure following the standard claude-{name}-{major}[-{minor}] format used for the Claude 4.6 generation and later [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the judge job and cost lookup paths referenced by the comment.
git ls-files 'server/cmd/server/main.go' 'server/jobs_judge_score.go' 'server/**/judge*' | sed 's/^/FILE /'

printf '\n--- server/jobs_judge_score.go (relevant sections) ---\n'
sed -n '1,260p' server/jobs_judge_score.go

printf '\n--- search for PriceForModelAlias usage ---\n'
rg -n 'PriceForModelAlias|judgeCallCostUSD|ModelAlias|result\.Model' server -S

Repository: firefly-events/multica

Length of output: 730


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- server/internal/handler/judge_score.go ---\n'
wc -l server/internal/handler/judge_score.go
sed -n '1,260p' server/internal/handler/judge_score.go

printf '\n--- server/internal/judge/anthropic.go ---\n'
wc -l server/internal/judge/anthropic.go
sed -n '1,260p' server/internal/judge/anthropic.go

printf '\n--- search for cost lookup / pricing aliases in server/internal ---\n'
rg -n 'PriceForModelAlias|model alias|ModelAlias|judgeCallCostUSD|costUSD|PriceForModel|price table|PriceFor' server/internal server/pkg -S

Repository: firefly-events/multica

Length of output: 11417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- server/internal/scheduler/jobs_judge_score.go ---\n'
wc -l server/internal/scheduler/jobs_judge_score.go
sed -n '160,240p' server/internal/scheduler/jobs_judge_score.go

printf '\n--- server/internal/metrics/pricing.go ---\n'
wc -l server/internal/metrics/pricing.go
sed -n '1,220p' server/internal/metrics/pricing.go

printf '\n--- server/internal/metrics/pricing_test.go ---\n'
wc -l server/internal/metrics/pricing_test.go
sed -n '1,120p' server/internal/metrics/pricing_test.go

Repository: firefly-events/multica

Length of output: 8064


Default judge model ID should use hyphens
JUDGE_MODEL falls back to claude-opus-4.8, but Anthropic uses claude-opus-4-8. When the env var is unset, the judge call is sent with an invalid model ID and scoring fails.

🔧 Proposed fix
-			judgeModel = "claude-opus-4.8"
+			judgeModel = "claude-opus-4-8"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// DOS-860: sampled LLM-as-judge scoring pass. Registered only when
// an Anthropic API key is configured — with no key there is no
// judge to call, so the job would just fail every attempt.
if apiKey := os.Getenv("JUDGE_ANTHROPIC_API_KEY"); apiKey != "" {
judgeModel := os.Getenv("JUDGE_MODEL")
if judgeModel == "" {
judgeModel = "claude-opus-4.8"
}
judgeCfg := scheduler.DefaultJudgeScoreSamplerConfig()
if rate, ok := envFloatInUnitInterval("JUDGE_SAMPLE_RATE"); ok {
judgeCfg.SampleRate = rate
}
anthropicJudge := judge.NewAnthropicJudge(apiKey, judgeModel, nil)
if err := schedulerMgr.Register(scheduler.JudgeScoreSamplerJob(pool, anthropicJudge, judgeCfg)); err != nil {
slog.Warn("scheduler: failed to register judge_score_sampler job", "error", err)
}
}
// DOS-860: sampled LLM-as-judge scoring pass. Registered only when
// an Anthropic API key is configured — with no key there is no
// judge to call, so the job would just fail every attempt.
if apiKey := os.Getenv("JUDGE_ANTHROPIC_API_KEY"); apiKey != "" {
judgeModel := os.Getenv("JUDGE_MODEL")
if judgeModel == "" {
judgeModel = "claude-opus-4-8"
}
judgeCfg := scheduler.DefaultJudgeScoreSamplerConfig()
if rate, ok := envFloatInUnitInterval("JUDGE_SAMPLE_RATE"); ok {
judgeCfg.SampleRate = rate
}
anthropicJudge := judge.NewAnthropicJudge(apiKey, judgeModel, nil)
if err := schedulerMgr.Register(scheduler.JudgeScoreSamplerJob(pool, anthropicJudge, judgeCfg)); err != nil {
slog.Warn("scheduler: failed to register judge_score_sampler job", "error", err)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/cmd/server/main.go` around lines 384 - 400, Update the fallback value
assigned to judgeModel in the JUDGE_ANTHROPIC_API_KEY registration block to use
Anthropic’s hyphenated model ID claude-opus-4-8, while preserving the
JUDGE_MODEL environment override and the surrounding scheduler registration
flow.

Comment on lines +13 to +14
const anthropicAPIURL = "https://api.anthropic.com/v1/messages"
const anthropicAPIVersion = "2023-06-01"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider making the API base URL configurable instead of a fixed constant.

anthropicAPIURL is hardcoded, forcing anthropic_test.go to build a custom RoundTripper just to redirect requests to a fake server. A BaseURL field on AnthropicJudge (defaulting to anthropicAPIURL when empty) would simplify tests and support alternate/self-hosted endpoints without behavior change for production callers.

Also applies to: 43-56, 137-137

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/internal/judge/anthropic.go` around lines 13 - 14, Make the Anthropic
endpoint configurable through a BaseURL field on AnthropicJudge, defaulting to
anthropicAPIURL when unset. Update the request-building logic and constructors
around AnthropicJudge to use this resolved base URL, preserving the current
production endpoint while allowing tests and alternate endpoints without custom
transport redirection.

Comment on lines +43 to +88
func BuildPrompt(t Trajectory) string {
var b strings.Builder
b.WriteString(rubricInstructions)

b.WriteString("\n\n<untrusted-task-context>\n")
fmt.Fprintf(&b, "Title: %s\n\n", t.IssueTitle)
if t.IssueBody != "" {
fmt.Fprintf(&b, "Description:\n%s\n\n", t.IssueBody)
}
b.WriteString("</untrusted-task-context>\n")

b.WriteString("\n<untrusted-trajectory>\n")
if len(t.Steps) == 0 {
b.WriteString("(no recorded tool-call steps)\n\n")
}
for _, s := range t.Steps {
fmt.Fprintf(&b, "%d. [%s]", s.Seq, s.Type)
if s.Tool != "" {
fmt.Fprintf(&b, " tool=%s", s.Tool)
}
b.WriteString("\n")
if s.Content != "" {
fmt.Fprintf(&b, " content: %s\n", truncate(s.Content, 2000))
}
if s.Input != "" {
fmt.Fprintf(&b, " input: %s\n", truncate(s.Input, 2000))
}
if s.Output != "" {
fmt.Fprintf(&b, " output: %s\n", truncate(s.Output, 2000))
}
}

b.WriteString("\n## Final outcome\n\n")
if t.FinalError != "" {
fmt.Fprintf(&b, "error: %s\n", truncate(t.FinalError, 4000))
}
if t.FinalResult != "" {
fmt.Fprintf(&b, "result: %s\n", truncate(t.FinalResult, 8000))
}
if t.FinalError == "" && t.FinalResult == "" {
b.WriteString("(no recorded result or error)\n")
}
b.WriteString("</untrusted-trajectory>\n")

return b.String()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Untrusted content can forge the tag delimiters to escape the sandbox.

t.IssueTitle, t.IssueBody, and each step's Content/Input/Output are interpolated verbatim between the <untrusted-task-context>/<untrusted-trajectory> tags. Since these tags are the only structural boundary the model relies on (per judgeSystemPrompt), any attacker-controlled content containing the literal closing tag (e.g. </untrusted-trajectory>) can prematurely close the block, inject fake grading instructions, and re-open the tag to keep the prompt well-formed. This defeats the injection protection this PR is meant to add — task descriptions, tool output, and web/file content the agent touched are all attacker-influenceable per the file's own doc comment.

Neutralize the delimiter strings in untrusted content before interpolation (or use a per-request random tag suffix that can't be guessed/forged) rather than relying solely on the system-prompt instruction.

🔒 Proposed fix
+// sanitizeUntrusted strips any occurrence of the judge's own delimiter
+// tags from untrusted content so it cannot forge a tag boundary and
+// escape the <untrusted-*> block.
+func sanitizeUntrusted(s string) string {
+	for _, tag := range []string{
+		"<untrusted-task-context>", "</untrusted-task-context>",
+		"<untrusted-trajectory>", "</untrusted-trajectory>",
+	} {
+		s = strings.ReplaceAll(s, tag, "")
+	}
+	return s
+}
+
 func BuildPrompt(t Trajectory) string {
 	var b strings.Builder
 	b.WriteString(rubricInstructions)

 	b.WriteString("\n\n<untrusted-task-context>\n")
-	fmt.Fprintf(&b, "Title: %s\n\n", t.IssueTitle)
-	if t.IssueBody != "" {
-		fmt.Fprintf(&b, "Description:\n%s\n\n", t.IssueBody)
+	fmt.Fprintf(&b, "Title: %s\n\n", sanitizeUntrusted(t.IssueTitle))
+	if t.IssueBody != "" {
+		fmt.Fprintf(&b, "Description:\n%s\n\n", sanitizeUntrusted(t.IssueBody))
 	}

Apply the same sanitizeUntrusted wrap to s.Content, s.Input, s.Output, t.FinalResult, and t.FinalError below.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/internal/judge/prompt.go` around lines 43 - 88, Update BuildPrompt and
sanitize every attacker-controlled value before interpolation, including
t.IssueTitle, t.IssueBody, each step’s Content, Input, and Output, plus
t.FinalError and t.FinalResult. Reuse the existing sanitizeUntrusted helper so
literal structural delimiters are neutralized while preserving the current
prompt layout and truncation behavior.

Comment on lines +94 to +161
for _, task := range candidates {
taskID := task.ID.String()
decision := judge.ShouldSample(taskID, cfg.SampleRate)

// Record that this task was considered regardless of the
// sample outcome. ListUnjudgedCompletedTasks excludes on
// judge_sample_decision, not judge_score, so this is what
// lets the candidate window advance past a task that missed
// the sample-rate hash instead of returning it forever.
if err := q.InsertJudgeSampleDecision(ctx, db.InsertJudgeSampleDecisionParams{
TaskID: task.ID,
Sampled: decision,
}); err != nil {
failed++
continue
}

if !decision {
continue
}
sampled++

if in.Heartbeat != nil {
_ = in.Heartbeat(ctx)
}

trajectory, err := judge.BuildTrajectory(ctx, q, task)
if err != nil {
failed++
continue
}

result, err := j.Score(ctx, judge.Input{Trajectory: trajectory})
if err != nil {
failed++
continue
}

costUSD := judgeCallCostUSD(result)

var costNumeric pgtype.Numeric
if err := costNumeric.Scan(fmt.Sprintf("%.6f", costUSD)); err != nil {
failed++
continue
}

if _, err := q.InsertJudgeScore(ctx, db.InsertJudgeScoreParams{
TaskID: task.ID,
JudgeProvider: result.Provider,
JudgeModel: result.Model,
CorrectnessScore: int32(result.CorrectnessScore),
AdherenceScore: int32(result.AdherenceScore),
ToneScore: int32(result.ToneScore),
ClarityScore: int32(result.ClarityScore),
TrajectoryScore: int32(result.TrajectoryScore),
OverallScore: int32(result.OverallScore),
Rationale: result.Rationale,
TrajectoryRationale: result.TrajectoryRationale,
CalibrationStatus: judge.ModeledStatus,
InputTokens: result.InputTokens,
OutputTokens: result.OutputTokens,
CostUsd: costNumeric,
}); err != nil {
failed++
continue
}
scored++
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Sampled tasks that fail to score are silently lost forever.

InsertJudgeSampleDecision(Sampled: true) is written before BuildTrajectory/j.Score/InsertJudgeScore run. If any of those calls fail, the task is failed++ and skipped, but the sample-decision row still exists — so ListUnjudgedCompletedTasks' NOT EXISTS (... judge_sample_decision ...) clause permanently excludes it from every future tick. A transient judge API error (rate limit, timeout) forever burns that task's only chance to be judged, and the job's own MaxAttempts/RetryBackoff never engages since per-task errors don't bubble up as a handler error.

Defer recording sampled=true until after the score is actually persisted; record sampled=false immediately since that path never needs a retry.

🔧 Proposed fix
 		for _, task := range candidates {
 			taskID := task.ID.String()
 			decision := judge.ShouldSample(taskID, cfg.SampleRate)
 
-			// Record that this task was considered regardless of the
-			// sample outcome. ListUnjudgedCompletedTasks excludes on
-			// judge_sample_decision, not judge_score, so this is what
-			// lets the candidate window advance past a task that missed
-			// the sample-rate hash instead of returning it forever.
-			if err := q.InsertJudgeSampleDecision(ctx, db.InsertJudgeSampleDecisionParams{
-				TaskID:  task.ID,
-				Sampled: decision,
-			}); err != nil {
-				failed++
-				continue
-			}
-
 			if !decision {
+				// Record the "not sampled" decision immediately so the
+				// candidate window advances past this task; no retry is
+				// ever needed for a task that was never scored.
+				if err := q.InsertJudgeSampleDecision(ctx, db.InsertJudgeSampleDecisionParams{
+					TaskID:  task.ID,
+					Sampled: false,
+				}); err != nil {
+					failed++
+				}
 				continue
 			}
 			sampled++
 
 			if in.Heartbeat != nil {
 				_ = in.Heartbeat(ctx)
 			}
 
 			trajectory, err := judge.BuildTrajectory(ctx, q, task)
 			if err != nil {
 				failed++
-				continue
+				continue // decision not recorded — retried next tick
 			}
 
 			result, err := j.Score(ctx, judge.Input{Trajectory: trajectory})
 			if err != nil {
 				failed++
-				continue
+				continue // decision not recorded — retried next tick
 			}
 
 			costUSD := judgeCallCostUSD(result)
 
 			var costNumeric pgtype.Numeric
 			if err := costNumeric.Scan(fmt.Sprintf("%.6f", costUSD)); err != nil {
 				failed++
 				continue
 			}
 
 			if _, err := q.InsertJudgeScore(ctx, db.InsertJudgeScoreParams{
 				TaskID:              task.ID,
 				JudgeProvider:       result.Provider,
 				JudgeModel:          result.Model,
 				CorrectnessScore:    int32(result.CorrectnessScore),
 				AdherenceScore:      int32(result.AdherenceScore),
 				ToneScore:           int32(result.ToneScore),
 				ClarityScore:        int32(result.ClarityScore),
 				TrajectoryScore:     int32(result.TrajectoryScore),
 				OverallScore:        int32(result.OverallScore),
 				Rationale:           result.Rationale,
 				TrajectoryRationale: result.TrajectoryRationale,
 				CalibrationStatus:   judge.ModeledStatus,
 				InputTokens:         result.InputTokens,
 				OutputTokens:        result.OutputTokens,
 				CostUsd:             costNumeric,
 			}); err != nil {
 				failed++
 				continue
 			}
+
+			if err := q.InsertJudgeSampleDecision(ctx, db.InsertJudgeSampleDecisionParams{
+				TaskID:  task.ID,
+				Sampled: true,
+			}); err != nil {
+				failed++
+			}
 			scored++
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, task := range candidates {
taskID := task.ID.String()
decision := judge.ShouldSample(taskID, cfg.SampleRate)
// Record that this task was considered regardless of the
// sample outcome. ListUnjudgedCompletedTasks excludes on
// judge_sample_decision, not judge_score, so this is what
// lets the candidate window advance past a task that missed
// the sample-rate hash instead of returning it forever.
if err := q.InsertJudgeSampleDecision(ctx, db.InsertJudgeSampleDecisionParams{
TaskID: task.ID,
Sampled: decision,
}); err != nil {
failed++
continue
}
if !decision {
continue
}
sampled++
if in.Heartbeat != nil {
_ = in.Heartbeat(ctx)
}
trajectory, err := judge.BuildTrajectory(ctx, q, task)
if err != nil {
failed++
continue
}
result, err := j.Score(ctx, judge.Input{Trajectory: trajectory})
if err != nil {
failed++
continue
}
costUSD := judgeCallCostUSD(result)
var costNumeric pgtype.Numeric
if err := costNumeric.Scan(fmt.Sprintf("%.6f", costUSD)); err != nil {
failed++
continue
}
if _, err := q.InsertJudgeScore(ctx, db.InsertJudgeScoreParams{
TaskID: task.ID,
JudgeProvider: result.Provider,
JudgeModel: result.Model,
CorrectnessScore: int32(result.CorrectnessScore),
AdherenceScore: int32(result.AdherenceScore),
ToneScore: int32(result.ToneScore),
ClarityScore: int32(result.ClarityScore),
TrajectoryScore: int32(result.TrajectoryScore),
OverallScore: int32(result.OverallScore),
Rationale: result.Rationale,
TrajectoryRationale: result.TrajectoryRationale,
CalibrationStatus: judge.ModeledStatus,
InputTokens: result.InputTokens,
OutputTokens: result.OutputTokens,
CostUsd: costNumeric,
}); err != nil {
failed++
continue
}
scored++
}
for _, task := range candidates {
taskID := task.ID.String()
decision := judge.ShouldSample(taskID, cfg.SampleRate)
if !decision {
// Record the "not sampled" decision immediately so the
// candidate window advances past this task; no retry is
// ever needed for a task that was never scored.
if err := q.InsertJudgeSampleDecision(ctx, db.InsertJudgeSampleDecisionParams{
TaskID: task.ID,
Sampled: false,
}); err != nil {
failed++
}
continue
}
sampled++
if in.Heartbeat != nil {
_ = in.Heartbeat(ctx)
}
trajectory, err := judge.BuildTrajectory(ctx, q, task)
if err != nil {
failed++
continue // decision not recorded — retried next tick
}
result, err := j.Score(ctx, judge.Input{Trajectory: trajectory})
if err != nil {
failed++
continue // decision not recorded — retried next tick
}
costUSD := judgeCallCostUSD(result)
var costNumeric pgtype.Numeric
if err := costNumeric.Scan(fmt.Sprintf("%.6f", costUSD)); err != nil {
failed++
continue
}
if _, err := q.InsertJudgeScore(ctx, db.InsertJudgeScoreParams{
TaskID: task.ID,
JudgeProvider: result.Provider,
JudgeModel: result.Model,
CorrectnessScore: int32(result.CorrectnessScore),
AdherenceScore: int32(result.AdherenceScore),
ToneScore: int32(result.ToneScore),
ClarityScore: int32(result.ClarityScore),
TrajectoryScore: int32(result.TrajectoryScore),
OverallScore: int32(result.OverallScore),
Rationale: result.Rationale,
TrajectoryRationale: result.TrajectoryRationale,
CalibrationStatus: judge.ModeledStatus,
InputTokens: result.InputTokens,
OutputTokens: result.OutputTokens,
CostUsd: costNumeric,
}); err != nil {
failed++
continue
}
if err := q.InsertJudgeSampleDecision(ctx, db.InsertJudgeSampleDecisionParams{
TaskID: task.ID,
Sampled: true,
}); err != nil {
failed++
}
scored++
}
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 143-143: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values.
Context: int32(result.CorrectnessScore)
Note: [CWE-190] Integer Overflow or Wraparound.

(integer-overflow-narrowing-conversion-go)


[warning] 144-144: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values.
Context: int32(result.AdherenceScore)
Note: [CWE-190] Integer Overflow or Wraparound.

(integer-overflow-narrowing-conversion-go)


[warning] 145-145: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values.
Context: int32(result.ToneScore)
Note: [CWE-190] Integer Overflow or Wraparound.

(integer-overflow-narrowing-conversion-go)


[warning] 146-146: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values.
Context: int32(result.ClarityScore)
Note: [CWE-190] Integer Overflow or Wraparound.

(integer-overflow-narrowing-conversion-go)


[warning] 147-147: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values.
Context: int32(result.TrajectoryScore)
Note: [CWE-190] Integer Overflow or Wraparound.

(integer-overflow-narrowing-conversion-go)


[warning] 148-148: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values.
Context: int32(result.OverallScore)
Note: [CWE-190] Integer Overflow or Wraparound.

(integer-overflow-narrowing-conversion-go)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/internal/scheduler/jobs_judge_score.go` around lines 94 - 161, Update
the sampling flow around InsertJudgeSampleDecision, BuildTrajectory, j.Score,
and InsertJudgeScore so sampled=true is recorded only after the score is
successfully persisted. Continue recording sampled=false immediately for tasks
that miss the sample decision, and ensure failures during trajectory building,
scoring, cost conversion, or score insertion leave no sampled=true marker so the
task can be retried on a later tick.

Comment on lines +182 to +190
func judgeCallCostUSD(r judge.Result) float64 {
price, ok := metrics.PriceForModelAlias(r.Model)
if !ok {
return 0
}
input := float64(r.InputTokens) * price.InputPerM / 1_000_000
output := float64(r.OutputTokens) * price.OutputPerM / 1_000_000
return input + output
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Silent zero-cost on unpriced judge model.

If metrics.PriceForModelAlias(r.Model) doesn't recognize the model, cost_usd is silently persisted as 0 with no warning — undermining the "judge's own cost is logged/bounded" goal without any signal that pricing is missing.

🔧 Suggested fix
 func judgeCallCostUSD(r judge.Result) float64 {
 	price, ok := metrics.PriceForModelAlias(r.Model)
 	if !ok {
+		slog.Warn("judge: no price table entry for model; cost_usd recorded as 0", "model", r.Model)
 		return 0
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func judgeCallCostUSD(r judge.Result) float64 {
price, ok := metrics.PriceForModelAlias(r.Model)
if !ok {
return 0
}
input := float64(r.InputTokens) * price.InputPerM / 1_000_000
output := float64(r.OutputTokens) * price.OutputPerM / 1_000_000
return input + output
}
func judgeCallCostUSD(r judge.Result) float64 {
price, ok := metrics.PriceForModelAlias(r.Model)
if !ok {
slog.Warn("judge: no price table entry for model; cost_usd recorded as 0", "model", r.Model)
return 0
}
input := float64(r.InputTokens) * price.InputPerM / 1_000_000
output := float64(r.OutputTokens) * price.OutputPerM / 1_000_000
return input + output
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/internal/scheduler/jobs_judge_score.go` around lines 182 - 190, Update
judgeCallCostUSD so an unrecognized judge model is not silently treated as a
valid zero-cost result: emit an appropriate warning or error identifying
r.Model, while preserving the existing zero return and normal priced-model
calculation.

Locks down the skip-when-not-completed, empty-result, API-rejection,
unmount-cancellation, and happy-path render behaviors of the
LLM-as-judge score section (DOS-1472).

Co-authored-by: multica-agent <github@multica.ai>
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