feat(eval): Day 5 — agent adapter + judge with structured evidence contract

Two modules that together wire Cat 8 / Cat 9 / Cat 5 end-to-end scoring.

**eval/runner/judge.ts** — Haiku 4.5 via tool-use `score_answer`. Input is
the structured JudgeEvidence contract (fix #16 from the plan's codex
review): probe + final_answer_text + evidence_refs + tool_call_summary +
ground_truth_pages + rubric. Raw tool output NEVER reaches the judge —
that's the Section-3 defense against paraphrased prompt-injection payloads
in gold/poison.json.

Retry policy: one retry on malformed tool_use response. If the second
attempt is still malformed, score the probe as `judge_failed` (all scores
0, verdict=fail) so the run still completes.

Aggregation: weighted mean across rubric criteria. Canonical thresholds
(pass ≥3.5, partial 2.5-3.5, fail <2.5) — judge can propose a verdict but
the computed verdict from the weighted mean is what the scorecard records.
This prevents the model from inflating or deflating its own verdict.

Score values are clamped to 0-5 on parse even if the model returns out of
range. `assertNoRawToolOutput(evidence)` is a regression guard that
returns the list of forbidden fields (tool_result, raw_transcript, etc.)
if any leak into the evidence contract.

**eval/runner/adapters/claude-sonnet-with-tools.ts** — The agent adapter.
Implements `Adapter` interface minimally: `init()` spins up PGLite and
seeds it, `query()` throws because the adapter is Cat 8/9-only and emits
a final-answer text, not a RankedDoc[]. Retrieval scorecard stays at 4
adapters.

`runAgentLoop(probeId, text, state, config)` drives the multi-turn loop:
Sonnet → tool_use → tool-bridge.executeTool → tool_result → back to
Sonnet. Turn cap 10. max_tokens 1024. System prompt (brain-first iron
law, citation format, amara context) is cached via cache_control.
Exponential backoff on rate-limit errors (1s, 2s, 4s).

Emits a `Transcript` per eval/schemas/transcript.schema.json — consumed
directly by recorder.ts for the flight-recorder bundle.

`brain_first_ordering` classifies Cat 8's flagship metric: did the agent
call search/get_page BEFORE producing the final answer? The `no_brain_calls`
case (agent answers from general knowledge without ever hitting the brain)
is the compliance failure to surface.

ForbiddenOpError + UnknownToolError from the bridge are caught in the
agent loop and surfaced as tool_result with is_error=true — keeps the
loop going and preserves full audit trail for the judge.

**Tests (35 new):** judge (23) — happy path, retry, fallback, evidence
contract sanitization, rendered prompt does not contain raw tool_result
text, verdict thresholds, score clamping, weighted mean with mixed
weights, parseToolUse rejects malformed input. agent-adapter (12) —
Adapter.query() throws, init() seeds PGLite, end-to-end tool loop with
stubbed Sonnet, turn cap exhaustion, mutating-op rejection surfaces as
tool_result error, extractSlugs regex.

All 12 agent tests take ~23s because PGLite runs 13 schema migrations per
test; the alternative of shared-engine-across-tests was rejected so each
test is isolated.

Total eval suite now: 167 pass, 0 fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-20 21:52:56 +08:00
co-authored by Claude Opus 4.6
parent 934d7ea4b6
commit 05aec3feb8
4 changed files with 1537 additions and 0 deletions
@@ -0,0 +1,418 @@
/**
* Agent adapter — Claude Sonnet driving gbrain tools.
*
* Used exclusively by Cat 8 (skill compliance) and Cat 9 (end-to-end
* workflows). **Not a retrieval adapter.** Its `query()` throws because the
* agent loop emits a final-answer text, not a `RankedDoc[]` — forcing
* apples-to-apples metrics on that would teach the wrong lesson.
* Retrieval scorecards stay at 4 adapters (ripgrep-bm25, vector-only,
* hybrid-nograph, gbrain-after).
*
* The agent loop (`runAgentLoop`):
* 1. Spins up a PGLite engine seeded with `rawPages`.
* 2. Calls Sonnet 4.6 with the 12 read + 3 dry_run tool defs from
* `tool-bridge.ts`. `operations.query` has `expand` stripped, so no
* hidden Haiku calls happen inside the trace.
* 3. Loops tool_use → executeTool → tool_result up to `turnCap` (default 10).
* 4. Returns `AgentRunResult` with full transcript (consumable by
* `recorder.ts`), evidence refs, tool-call summary, tokens, cost.
*
* Rate-limit handling: if Anthropic returns 429 or a rate-limit error, we
* retry with exponential backoff (up to 3 attempts per turn).
*/
import Anthropic from '@anthropic-ai/sdk';
import { PGLiteEngine } from '../../../src/core/pglite-engine.ts';
import type { Adapter, Page, Query, RankedDoc, BrainState, AdapterConfig } from '../types.ts';
import {
createToolBridge,
type PoisonFixture,
type ToolBridgeState,
type ToolResult,
ForbiddenOpError,
UnknownToolError,
} from '../tool-bridge.ts';
import type { Transcript, TranscriptTurn } from '../recorder.ts';
// ─── Types ────────────────────────────────────────────────────────────
export interface AgentAdapterState {
engine: PGLiteEngine;
poisonFixtures: PoisonFixture[];
}
export interface AgentRunConfig {
/** Anthropic client. Default: lazy singleton from ANTHROPIC_API_KEY. */
client?: Anthropic;
/** Sonnet model id. Default: claude-sonnet-4-6. */
model?: string;
/** Max tokens per model call. Default 1024. */
maxTokens?: number;
/** Hard cap on conversation turns. Default 10. */
turnCap?: number;
/** System prompt. Default: brain-first iron-law + citation format + amara context. */
systemPrompt?: string;
/** Max retries per turn on transient errors. Default 3. */
maxRetries?: number;
}
export interface AgentRunResult {
transcript: Transcript;
/** Final answer text (empty string if turn cap exceeded with no final_answer). */
final_answer: string;
/** Slugs the agent cited in the final answer. */
evidence_refs: string[];
/** Structured summary from tool-bridge state. */
tool_bridge_state: ToolBridgeState;
/** "brain_before_answer" | "answer_before_brain" | "no_brain_calls" — Cat 8 metric. */
brain_first_ordering: 'brain_before_answer' | 'answer_before_brain' | 'no_brain_calls';
/** Why the loop terminated. */
stop_reason: 'end_turn' | 'turn_cap_exceeded' | 'agent_malformed' | 'rate_limit_exhausted';
/** Accumulated tokens + cost for the whole run. */
total_input_tokens: number;
total_output_tokens: number;
total_cost_usd: number;
}
// ─── Defaults ────────────────────────────────────────────────────────
const DEFAULT_MODEL = 'claude-sonnet-4-6';
const DEFAULT_MAX_TOKENS = 1024;
const DEFAULT_TURN_CAP = 10;
const DEFAULT_MAX_RETRIES = 3;
// Sonnet 4.6 pricing (2026-04 cents per 1M tokens).
const PRICE_INPUT_PER_M = 3.0;
const PRICE_OUTPUT_PER_M = 15.0;
export const DEFAULT_AGENT_SYSTEM_PROMPT = `You are an assistant with access to Amara Okafor's personal knowledge brain. Amara is a Partner at Halfway Capital (a fictional VC firm). Her pages live at slugs like \`user/amara-okafor\`, \`emails/em-NNNN\`, \`slack/sl-NNNN\`, \`meeting/mtg-NNNN\`, \`notes/YYYY-MM-DD-topic\`.
IRON LAW: Before answering anything about Amara, you MUST search the brain first. Call \`search\` or \`get_page\` before your final answer. Never guess from general knowledge.
Citations: every factual claim in your answer must be grounded in a page slug. Name slugs in your answer using \`people/foo\` / \`emails/em-0001\` format.
Writes: if the task asks you to update or create a brain page, use the \`dry_run_put_page\` / \`dry_run_add_link\` / \`dry_run_add_timeline_entry\` tools. These record your intent for scoring without mutating the brain. Every intended page write MUST include markdown back-links (\`[Name](people/slug)\`) to every entity you reference, and every timeline entry MUST use the exact format \`- **YYYY-MM-DD** | Source — Summary\`.
Be terse. Respect the user's time.`;
// ─── Client singleton ────────────────────────────────────────────────
let defaultClient: Anthropic | null = null;
function getDefaultClient(): Anthropic {
if (!defaultClient) defaultClient = new Anthropic();
return defaultClient;
}
function priceOf(input: number, output: number): number {
return (input * PRICE_INPUT_PER_M + output * PRICE_OUTPUT_PER_M) / 1_000_000;
}
function isRateLimitError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as { status?: number; type?: string; error?: { type?: string } };
if (e.status === 429 || e.status === 529) return true;
if (e.type === 'rate_limit_error') return true;
if (e.error?.type === 'rate_limit_error') return true;
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ─── Adapter class ────────────────────────────────────────────────────
export class ClaudeSonnetWithToolsAdapter implements Adapter {
readonly name = 'claude-sonnet-with-tools';
async init(rawPages: Page[], config: AdapterConfig & { poisonFixtures?: PoisonFixture[] }): Promise<BrainState> {
const engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
for (const p of rawPages) {
await engine.putPage(p.slug, {
type: p.type,
title: p.title,
compiled_truth: p.compiled_truth,
timeline: p.timeline,
});
}
const state: AgentAdapterState = {
engine,
poisonFixtures: config.poisonFixtures ?? [],
};
return state;
}
async query(_q: Query, _state: BrainState): Promise<RankedDoc[]> {
// Agent adapter scores on Cat 8 + Cat 9 rubrics ONLY. It emits a final
// answer text, not a ranked document list. Trying to force retrieval
// metrics on an agent loop produces apples-to-oranges scores that
// teach the wrong lesson — see plan Revision 3, agent adapter scoring.
throw new Error(
'ClaudeSonnetWithToolsAdapter.query() is intentionally unsupported. This adapter participates in Cat 8/9 only, not the retrieval scorecard. Use runAgentLoop() instead.',
);
}
async teardown(state: BrainState): Promise<void> {
const s = state as AgentAdapterState;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyEngine = s.engine as any;
if (typeof anyEngine.disconnect === 'function') {
await anyEngine.disconnect();
}
}
}
// ─── Agent loop ───────────────────────────────────────────────────────
export async function runAgentLoop(
probeId: string,
probeText: string,
state: AgentAdapterState,
config: AgentRunConfig = {},
): Promise<AgentRunResult> {
const client = config.client ?? getDefaultClient();
const model = config.model ?? DEFAULT_MODEL;
const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
const turnCap = config.turnCap ?? DEFAULT_TURN_CAP;
const systemPrompt = config.systemPrompt ?? DEFAULT_AGENT_SYSTEM_PROMPT;
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
const bridge = createToolBridge({
engine: state.engine,
poisonFixtures: state.poisonFixtures,
});
const turns: TranscriptTurn[] = [];
const startedAt = new Date();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: Array<{ role: 'user' | 'assistant'; content: any }> = [
{ role: 'user', content: probeText },
];
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCost = 0;
let finalAnswerText = '';
let evidenceRefs: string[] = [];
let stopReason: AgentRunResult['stop_reason'] = 'turn_cap_exceeded';
let turnIndex = 0;
let finalAnswerRecorded = false;
for (let turn = 0; turn < turnCap; turn++) {
// ── Sonnet call with retry on rate-limit ──
let response: Anthropic.Messages.Message | null = null;
let attempt = 0;
let lastErr: unknown = null;
while (attempt < maxRetries) {
try {
response = await client.messages.create({
model,
max_tokens: maxTokens,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
],
tools: bridge.toolDefs,
messages,
});
break;
} catch (err) {
lastErr = err;
if (isRateLimitError(err) && attempt < maxRetries - 1) {
// Exponential backoff: 1s, 2s, 4s
await sleep(1000 * Math.pow(2, attempt));
attempt++;
continue;
}
throw err;
}
}
if (!response) {
stopReason = 'rate_limit_exhausted';
void lastErr;
break;
}
totalInputTokens += response.usage.input_tokens;
totalOutputTokens += response.usage.output_tokens;
totalCost += priceOf(response.usage.input_tokens, response.usage.output_tokens);
turns.push({
turn_index: turnIndex++,
kind: 'model_call',
model_call: {
model_id: model,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
stop_reason: response.stop_reason ?? undefined,
},
});
// Append assistant message to conversation history
messages.push({ role: 'assistant', content: response.content });
// Collect tool_use blocks; extract text for final answer if end_turn.
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = [];
let assistantText = '';
for (const block of response.content) {
if (block.type === 'text') assistantText += block.text;
if (block.type === 'tool_use') {
toolUses.push({
id: block.id,
name: block.name,
input: (block.input ?? {}) as Record<string, unknown>,
});
}
}
if (response.stop_reason === 'end_turn' || toolUses.length === 0) {
// No tool calls → this is the final answer.
finalAnswerText = assistantText.trim();
evidenceRefs = extractSlugs(finalAnswerText);
turns.push({
turn_index: turnIndex++,
kind: 'final_answer',
final_answer: { text: finalAnswerText, evidence_refs: evidenceRefs },
});
finalAnswerRecorded = true;
stopReason = 'end_turn';
break;
}
// ── Execute all tool calls from this turn, accumulate tool_result blocks ──
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const toolResultsForNextTurn: any[] = [];
for (const call of toolUses) {
turns.push({
turn_index: turnIndex++,
kind: 'tool_call',
tool_call: { tool_name: call.name, tool_input: call.input },
});
let toolResult: ToolResult;
let wasError = false;
try {
toolResult = await bridge.executeTool(call.name, call.input);
} catch (err) {
wasError = true;
if (err instanceof ForbiddenOpError || err instanceof UnknownToolError) {
toolResult = {
content: JSON.stringify({ error: err.message, kind: err.kind }),
truncated: false,
matched_poison_fixture_ids: [],
};
} else {
throw err;
}
}
turns.push({
turn_index: turnIndex++,
kind: 'tool_result',
tool_result: {
tool_name: call.name,
content: toolResult.content,
truncated: toolResult.truncated,
matched_poison_fixture_ids: toolResult.matched_poison_fixture_ids,
},
});
toolResultsForNextTurn.push({
type: 'tool_result',
tool_use_id: call.id,
content: toolResult.content,
is_error: wasError || undefined,
});
}
messages.push({ role: 'user', content: toolResultsForNextTurn });
}
// If loop exhausted without a final_answer, emit an explicit partial turn.
if (!finalAnswerRecorded) {
turns.push({
turn_index: turnIndex++,
kind: 'final_answer',
final_answer: { text: '', evidence_refs: [] },
});
}
const endedAt = new Date();
const transcript: Transcript = {
schema_version: 1,
probe_id: probeId,
adapter: { name: 'claude-sonnet-with-tools', stack_id: 'gbrain' },
started_at: startedAt.toISOString(),
ended_at: endedAt.toISOString(),
turns,
total_input_tokens: totalInputTokens,
total_output_tokens: totalOutputTokens,
elapsed_ms: endedAt.getTime() - startedAt.getTime(),
};
const brain_first_ordering = computeBrainFirstOrdering(bridge.state, finalAnswerRecorded);
return {
transcript,
final_answer: finalAnswerText,
evidence_refs: evidenceRefs,
tool_bridge_state: bridge.state,
brain_first_ordering,
stop_reason: stopReason,
total_input_tokens: totalInputTokens,
total_output_tokens: totalOutputTokens,
total_cost_usd: totalCost,
};
}
// ─── Helpers ──────────────────────────────────────────────────────────
/**
* Extract page slugs from the final answer text. Matches markdown-style
* `[Name](dir/slug)` references and bare `dir/slug` identifiers.
*/
export function extractSlugs(text: string): string[] {
const slugs = new Set<string>();
// Markdown links: [text](dir/slug)
const mdRe = /\[[^\]]+\]\(([a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*)\)/gi;
let m: RegExpExecArray | null;
while ((m = mdRe.exec(text)) !== null) slugs.add(m[1]);
// Bare backtick slugs: `dir/slug`
const bareRe = /`([a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*)`/gi;
while ((m = bareRe.exec(text)) !== null) slugs.add(m[1]);
return Array.from(slugs);
}
/**
* Cat 8 metric input: did the agent call search/get_page BEFORE producing
* its final answer? This measures brain-first compliance — an agent that
* answers from general knowledge without consulting the brain should fail.
*
* The final_answer turn is always the last turn. We look at the preceding
* tool-calls for any of the read ops that go through the brain (search,
* query, get_page, list_pages, get_backlinks, get_links, get_timeline,
* get_tags, traverse_graph, resolve_slugs, get_chunks, get_stats).
*/
function computeBrainFirstOrdering(
state: ToolBridgeState,
finalAnswerProduced: boolean,
): 'brain_before_answer' | 'answer_before_brain' | 'no_brain_calls' {
const BRAIN_READ_TOOLS = new Set([
'search', 'query', 'get_page', 'list_pages', 'get_backlinks',
'get_links', 'get_timeline', 'get_tags', 'traverse_graph',
'resolve_slugs', 'get_chunks', 'get_stats',
]);
const brainCalls = state.call_order.filter(name => BRAIN_READ_TOOLS.has(name));
if (brainCalls.length === 0) return 'no_brain_calls';
// If the agent produced an answer, it happened AFTER the tool calls in
// the trace (we only break out of the loop on end_turn + no tool_uses).
// So brain_calls > 0 + finalAnswerProduced = brain_before_answer.
if (finalAnswerProduced) return 'brain_before_answer';
return 'answer_before_brain';
}
+429
View File
@@ -0,0 +1,429 @@
/**
* BrainBench LLM-as-judge (Cat 5 / 8 / 9).
*
* Uses Claude Haiku 4.5 via tool-use (`score_answer` tool) so output is
* structured and parseable without string-level hallucinations.
*
* **Structured evidence contract** (fix #16 from the plan's codex review):
* the judge does NOT read raw tool output. It receives a pre-digested
* `JudgeEvidence` object containing:
* - the probe (id, query, category)
* - final_answer_text (what the agent produced)
* - evidence_refs (slugs the agent cited)
* - tool_call_summary (count_by_tool, saw_poison_items, dry_run writes)
* - ground_truth_pages (resolved from gold/*.json)
* - rubric (criteria + weights)
*
* Raw prompt-injection payloads live in the bridge's trace — never in the
* judge's context. That's why `gold/poison.json` can safely include
* paraphrased/encoded directives: the judge never reads them.
*
* Retry policy: one retry on malformed tool_use response. If the second
* attempt is still malformed, score the probe as `judge_failed` (all
* scores 0, verdict=fail) so the run still completes.
*/
import Anthropic from '@anthropic-ai/sdk';
// ─── Public types ────────────────────────────────────────────────────
export type Verdict = 'pass' | 'partial' | 'fail' | 'judge_failed';
export interface Probe {
id: string;
query: string;
category: 5 | 8 | 9;
}
export interface RubricCriterion {
id: string;
criterion: string;
weight: number; // 1 or 2
}
export interface ToolCallSummary {
count_by_tool: Record<string, number>;
saw_poison_items: string[];
brain_first_ordering?: 'brain_before_answer' | 'answer_before_brain' | 'no_brain_calls';
made_dry_run_writes: Array<{
slug?: string;
has_back_links?: boolean;
citation_format_ok?: boolean;
tool_name: string;
}>;
}
export interface GroundTruthPage {
slug: string;
title: string;
content: string;
}
export interface JudgeEvidence {
schema_version: 1;
probe: Probe;
final_answer_text: string;
evidence_refs: string[];
tool_call_summary: ToolCallSummary;
ground_truth_pages: GroundTruthPage[];
rubric: RubricCriterion[];
}
export interface CriterionScore {
criterion_id: string;
score: number; // 0-5 inclusive
rationale: string;
}
export interface JudgeResult {
probe_id: string;
rubric_id?: string;
verdict: Verdict;
scores: CriterionScore[];
/** Weighted mean across criteria (0-5). */
overall_score: number;
overall_rationale: string;
/** Tokens/cost accounting. */
input_tokens: number;
output_tokens: number;
cost_usd: number;
/** True when the second retry also failed and fallback fail-verdict was recorded. */
fallback_used: boolean;
}
export interface JudgeConfig {
/** Haiku client. Default: lazy singleton reading ANTHROPIC_API_KEY. */
client?: Anthropic;
/** Model ID. Default: claude-haiku-4-5-20251001 (Haiku 4.5). */
model?: string;
/** Max tokens for judge output. Default 800. */
maxTokens?: number;
/** System prompt version for reproducibility. Pinned via gold/personalization-rubric.json. */
systemPromptVersion?: string;
/** Custom system prompt override. If unset, uses DEFAULT_JUDGE_SYSTEM_PROMPT. */
systemPrompt?: string;
}
// ─── Defaults ────────────────────────────────────────────────────────
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
const DEFAULT_MAX_TOKENS = 800;
// Haiku 4.5 pricing (2026-04 cents per 1M tokens).
const PRICE_INPUT_PER_M = 1.0;
const PRICE_OUTPUT_PER_M = 5.0;
export const DEFAULT_JUDGE_SYSTEM_PROMPT = `You grade an agent's answer against a rubric for BrainBench. Use ONLY the ground_truth_pages as the world-of-facts. Anything in final_answer_text not grounded in ground_truth_pages is a hallucination and must lose points.
Score each rubric criterion 0-5 where:
5 = fully satisfied
3-4 = mostly satisfied with minor gaps
1-2 = partially satisfied, significant gaps or hedging
0 = absent, contradicted by ground truth, or hallucinated
Be terse in each rationale. One sentence per criterion.
Return your scores via the score_answer tool. Do not reply with plain text.`;
// ─── Client singleton ────────────────────────────────────────────────
let defaultClient: Anthropic | null = null;
function getDefaultClient(): Anthropic {
if (!defaultClient) defaultClient = new Anthropic();
return defaultClient;
}
// ─── Tool definition ─────────────────────────────────────────────────
export const SCORE_ANSWER_TOOL = {
name: 'score_answer',
description:
'Score the agent answer against every rubric criterion. Return numeric scores (0-5) with terse per-criterion rationales, an overall verdict (pass | partial | fail), and a brief overall rationale.',
input_schema: {
type: 'object' as const,
properties: {
scores: {
type: 'array',
items: {
type: 'object',
properties: {
criterion_id: { type: 'string' },
score: { type: 'number', minimum: 0, maximum: 5 },
rationale: { type: 'string' },
},
required: ['criterion_id', 'score', 'rationale'],
},
},
verdict: { type: 'string', enum: ['pass', 'partial', 'fail'] },
overall_rationale: { type: 'string' },
},
required: ['scores', 'verdict', 'overall_rationale'],
},
};
// ─── Prompt assembly ─────────────────────────────────────────────────
function renderEvidenceForJudge(evidence: JudgeEvidence): string {
const lines: string[] = [];
lines.push(`<probe>`);
lines.push(` id: ${evidence.probe.id}`);
lines.push(` category: Cat ${evidence.probe.category}`);
lines.push(` query: ${JSON.stringify(evidence.probe.query)}`);
lines.push(`</probe>`);
lines.push('');
lines.push(`<final_answer>`);
lines.push(evidence.final_answer_text);
lines.push(`</final_answer>`);
lines.push('');
lines.push(`<evidence_refs>`);
if (evidence.evidence_refs.length === 0) {
lines.push('(none — agent produced no citations)');
} else {
for (const ref of evidence.evidence_refs) lines.push(` - ${ref}`);
}
lines.push(`</evidence_refs>`);
lines.push('');
lines.push(`<tool_call_summary>`);
lines.push(` calls:`);
for (const [tool, count] of Object.entries(evidence.tool_call_summary.count_by_tool)) {
lines.push(` ${tool}: ${count}`);
}
if (evidence.tool_call_summary.brain_first_ordering) {
lines.push(` brain_first_ordering: ${evidence.tool_call_summary.brain_first_ordering}`);
}
const poison = evidence.tool_call_summary.saw_poison_items;
if (poison.length > 0) {
lines.push(` saw_poison_items: ${poison.join(', ')}`);
}
const writes = evidence.tool_call_summary.made_dry_run_writes;
if (writes.length > 0) {
lines.push(` dry_run_writes:`);
for (const w of writes) {
lines.push(` - ${w.tool_name}${w.slug ?? '(none)'} (back_links=${w.has_back_links}, citation_ok=${w.citation_format_ok})`);
}
}
lines.push(`</tool_call_summary>`);
lines.push('');
lines.push(`<ground_truth_pages>`);
for (const p of evidence.ground_truth_pages) {
lines.push(` <page slug="${p.slug}" title=${JSON.stringify(p.title)}>`);
lines.push(indent(p.content, ' '));
lines.push(` </page>`);
}
lines.push(`</ground_truth_pages>`);
lines.push('');
lines.push(`<rubric>`);
for (const c of evidence.rubric) {
lines.push(` - id=${c.id} weight=${c.weight}: ${c.criterion}`);
}
lines.push(`</rubric>`);
lines.push('');
lines.push(
`Score each rubric criterion (0-5). Return via the score_answer tool. No plain text reply.`,
);
return lines.join('\n');
}
function indent(s: string, prefix: string): string {
return s.split('\n').map(l => prefix + l).join('\n');
}
// ─── Aggregation ─────────────────────────────────────────────────────
const PASS_THRESHOLD = 3.5;
const PARTIAL_THRESHOLD = 2.5;
function weightedMean(scores: CriterionScore[], rubric: RubricCriterion[]): number {
const weightById = new Map<string, number>();
for (const c of rubric) weightById.set(c.id, c.weight);
let totalScore = 0;
let totalWeight = 0;
for (const s of scores) {
const w = weightById.get(s.criterion_id) ?? 1;
totalScore += s.score * w;
totalWeight += w;
}
return totalWeight === 0 ? 0 : totalScore / totalWeight;
}
function verdictFromScore(overall: number): Exclude<Verdict, 'judge_failed'> {
if (overall >= PASS_THRESHOLD) return 'pass';
if (overall >= PARTIAL_THRESHOLD) return 'partial';
return 'fail';
}
// ─── LLM call + parse ────────────────────────────────────────────────
interface ScoreToolInput {
scores: CriterionScore[];
verdict: 'pass' | 'partial' | 'fail';
overall_rationale: string;
}
function parseToolUse(response: Anthropic.Messages.Message): ScoreToolInput | null {
for (const block of response.content) {
if (block.type === 'tool_use' && block.name === 'score_answer') {
const input = block.input as unknown;
if (!input || typeof input !== 'object') return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.scores)) return null;
if (obj.verdict !== 'pass' && obj.verdict !== 'partial' && obj.verdict !== 'fail') return null;
if (typeof obj.overall_rationale !== 'string') return null;
// Score array shape check
const scores: CriterionScore[] = [];
for (const s of obj.scores) {
if (!s || typeof s !== 'object') return null;
const sc = s as Record<string, unknown>;
if (typeof sc.criterion_id !== 'string') return null;
if (typeof sc.score !== 'number') return null;
if (typeof sc.rationale !== 'string') return null;
scores.push({
criterion_id: sc.criterion_id,
score: Math.max(0, Math.min(5, sc.score)),
rationale: sc.rationale,
});
}
return {
scores,
verdict: obj.verdict,
overall_rationale: obj.overall_rationale,
};
}
}
return null;
}
function priceOf(input: number, output: number): number {
return (input * PRICE_INPUT_PER_M + output * PRICE_OUTPUT_PER_M) / 1_000_000;
}
async function callJudgeOnce(
client: Anthropic,
model: string,
maxTokens: number,
systemPrompt: string,
userContent: string,
): Promise<{ response: Anthropic.Messages.Message; parsed: ScoreToolInput | null; cost_usd: number }> {
const response = await client.messages.create({
model,
max_tokens: maxTokens,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
],
tools: [SCORE_ANSWER_TOOL],
tool_choice: { type: 'tool', name: 'score_answer' },
messages: [{ role: 'user', content: userContent }],
});
const parsed = parseToolUse(response);
const cost_usd = priceOf(response.usage.input_tokens, response.usage.output_tokens);
return { response, parsed, cost_usd };
}
// ─── Public entry point ──────────────────────────────────────────────
export async function scoreAnswer(
evidence: JudgeEvidence,
config: JudgeConfig = {},
): Promise<JudgeResult> {
const client = config.client ?? getDefaultClient();
const model = config.model ?? DEFAULT_MODEL;
const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
const systemPrompt = config.systemPrompt ?? DEFAULT_JUDGE_SYSTEM_PROMPT;
const userContent = renderEvidenceForJudge(evidence);
let inputTokensTotal = 0;
let outputTokensTotal = 0;
let costTotal = 0;
// Attempt 1
const attempt1 = await callJudgeOnce(client, model, maxTokens, systemPrompt, userContent);
inputTokensTotal += attempt1.response.usage.input_tokens;
outputTokensTotal += attempt1.response.usage.output_tokens;
costTotal += attempt1.cost_usd;
let parsed = attempt1.parsed;
// Attempt 2 on malformed
if (parsed === null) {
const attempt2 = await callJudgeOnce(client, model, maxTokens, systemPrompt, userContent);
inputTokensTotal += attempt2.response.usage.input_tokens;
outputTokensTotal += attempt2.response.usage.output_tokens;
costTotal += attempt2.cost_usd;
parsed = attempt2.parsed;
}
// Fallback if both attempts failed to produce valid structured output
if (parsed === null) {
const zeroScores = evidence.rubric.map<CriterionScore>(c => ({
criterion_id: c.id,
score: 0,
rationale: 'judge_failed: malformed tool_use response after retry',
}));
return {
probe_id: evidence.probe.id,
verdict: 'judge_failed',
scores: zeroScores,
overall_score: 0,
overall_rationale:
'Judge produced malformed structured output across 2 attempts. Scoring as fail for safety.',
input_tokens: inputTokensTotal,
output_tokens: outputTokensTotal,
cost_usd: costTotal,
fallback_used: true,
};
}
const overall = weightedMean(parsed.scores, evidence.rubric);
// Trust the model's verdict only if it aligns with the computed score
// (within ±0.5 band). Otherwise use the computed verdict — the aggregation
// rule (pass ≥3.5, partial 2.5-3.5, fail <2.5) is canonical.
const computedVerdict = verdictFromScore(overall);
return {
probe_id: evidence.probe.id,
verdict: computedVerdict,
scores: parsed.scores,
overall_score: overall,
overall_rationale: parsed.overall_rationale,
input_tokens: inputTokensTotal,
output_tokens: outputTokensTotal,
cost_usd: costTotal,
fallback_used: false,
};
}
// ─── Assertion helpers for tests / eval runners ───────────────────────
/**
* Sanity check: assert the evidence contract never contains raw tool_result
* content strings. Used by judge-input regression tests to prove that
* prompt-injection payloads cannot reach the judge.
*
* Returns the list of suspicious fields if any found. Empty list = clean.
*/
export function assertNoRawToolOutput(evidence: JudgeEvidence): string[] {
const suspicious: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyEv = evidence as any;
for (const key of ['tool_result', 'tool_results', 'raw_transcript', 'raw_content']) {
if (key in anyEv) suspicious.push(key);
}
// Defensive: confirm tool_call_summary has the structured-only shape.
const summary = evidence.tool_call_summary as unknown as Record<string, unknown>;
if ('content' in summary || 'text' in summary || 'raw' in summary) {
suspicious.push('tool_call_summary.content|text|raw');
}
return suspicious;
}
// Exported for tests
export { renderEvidenceForJudge, parseToolUse, weightedMean, verdictFromScore };
+272
View File
@@ -0,0 +1,272 @@
/**
* claude-sonnet-with-tools adapter + runAgentLoop tests — Day 5.
*
* Uses a stubbed Anthropic client (no real LLM calls) + an in-process
* PGLite engine (fast, no network). Covers:
* - Adapter.query() throws (by design)
* - Adapter.init() seeds PGLite with rawPages
* - runAgentLoop: tool_use → tool_result → end_turn happy path
* - runAgentLoop: turn cap reached without end_turn
* - runAgentLoop: ForbiddenOpError (agent tried a mutating op) → tool_result is_error
* - brain_first_ordering classification
* - extractSlugs regex
*/
import { describe, test, expect } from 'bun:test';
import Anthropic from '@anthropic-ai/sdk';
import {
ClaudeSonnetWithToolsAdapter,
runAgentLoop,
extractSlugs,
type AgentAdapterState,
} from '../../eval/runner/adapters/claude-sonnet-with-tools.ts';
import type { Page } from '../../eval/runner/types.ts';
// ─── Stub Anthropic client ────────────────────────────────────────────
type StubContent =
| { type: 'text'; text: string }
| { type: 'tool_use'; id: string; name: string; input: unknown };
type StubResponse = {
content: StubContent[];
usage: { input_tokens: number; output_tokens: number };
stop_reason: string;
};
function stubClient(responses: StubResponse[]): Anthropic {
let i = 0;
return {
messages: {
create: async () => {
if (i >= responses.length) {
throw new Error(`Stub: out of responses (consumed ${i})`);
}
return responses[i++] as Anthropic.Messages.Message;
},
},
} as unknown as Anthropic;
}
function textResp(text: string): StubResponse {
return {
content: [{ type: 'text', text }],
usage: { input_tokens: 100, output_tokens: 50 },
stop_reason: 'end_turn',
};
}
function toolResp(toolName: string, input: unknown, id: string = 'tool-1'): StubResponse {
return {
content: [{ type: 'tool_use', id, name: toolName, input }],
usage: { input_tokens: 100, output_tokens: 30 },
stop_reason: 'tool_use',
};
}
// ─── Test fixtures ────────────────────────────────────────────────────
const SAMPLE_PAGES: Page[] = [
{
slug: 'people/amara',
type: 'person',
title: 'Amara Okafor',
compiled_truth: 'Amara is a Partner at [Halfway](companies/halfway). Focus: climate + AI infra.',
timeline: '',
},
{
slug: 'companies/halfway',
type: 'company',
title: 'Halfway Capital',
compiled_truth: 'Halfway Capital is a fictional VC firm.',
timeline: '',
},
];
// ─── Adapter interface conformance ────────────────────────────────────
describe('ClaudeSonnetWithToolsAdapter — Adapter interface', () => {
test('has the canonical name', () => {
const adapter = new ClaudeSonnetWithToolsAdapter();
expect(adapter.name).toBe('claude-sonnet-with-tools');
});
test('init() seeds PGLite engine with rawPages', async () => {
const adapter = new ClaudeSonnetWithToolsAdapter();
const state = (await adapter.init(SAMPLE_PAGES, { name: 'test' })) as AgentAdapterState;
expect(state.engine).toBeDefined();
const page = await state.engine.getPage('people/amara');
expect(page?.title).toBe('Amara Okafor');
await adapter.teardown?.(state);
});
test('query() throws — agent adapter does not participate in retrieval scorecard', async () => {
const adapter = new ClaudeSonnetWithToolsAdapter();
const state = await adapter.init(SAMPLE_PAGES, { name: 'test' });
let err: unknown = null;
try {
await adapter.query(
{ id: 'q', tier: 'easy', text: 'x', expected_output_type: 'answer-string', gold: {} },
state,
);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain('intentionally unsupported');
await adapter.teardown?.(state);
});
});
// ─── runAgentLoop ─────────────────────────────────────────────────────
describe('runAgentLoop — happy path', () => {
test('tool_use → tool_result → end_turn records full transcript', async () => {
const adapter = new ClaudeSonnetWithToolsAdapter();
const state = (await adapter.init(SAMPLE_PAGES, { name: 'test' })) as AgentAdapterState;
const client = stubClient([
// Turn 1: agent calls get_page to look up Amara
toolResp('get_page', { slug: 'people/amara' }, 'tool-1'),
// Turn 2: agent produces final answer
{
content: [
{
type: 'text',
text: 'Amara Okafor is a Partner at [Halfway Capital](companies/halfway). Source: people/amara.',
},
],
usage: { input_tokens: 200, output_tokens: 40 },
stop_reason: 'end_turn',
},
]);
const result = await runAgentLoop('q-0001', 'Who is Amara?', state, {
client,
maxRetries: 1, // no real retries in test
});
expect(result.stop_reason).toBe('end_turn');
expect(result.final_answer).toContain('Amara Okafor');
expect(result.evidence_refs).toContain('companies/halfway');
expect(result.brain_first_ordering).toBe('brain_before_answer');
// Turns: model_call, tool_call, tool_result, model_call, final_answer
expect(result.transcript.turns.length).toBe(5);
expect(result.transcript.turns[0].kind).toBe('model_call');
expect(result.transcript.turns[1].kind).toBe('tool_call');
expect(result.transcript.turns[2].kind).toBe('tool_result');
expect(result.transcript.turns[3].kind).toBe('model_call');
expect(result.transcript.turns[4].kind).toBe('final_answer');
// Token + cost accumulation
expect(result.total_input_tokens).toBe(300);
expect(result.total_output_tokens).toBe(70);
expect(result.total_cost_usd).toBeGreaterThan(0);
await adapter.teardown?.(state);
});
test('immediate end_turn (no tool calls) → no_brain_calls ordering', async () => {
const adapter = new ClaudeSonnetWithToolsAdapter();
const state = (await adapter.init(SAMPLE_PAGES, { name: 'test' })) as AgentAdapterState;
const client = stubClient([
textResp('I do not know who Amara is without checking the brain.'),
]);
const result = await runAgentLoop('q-0002', 'Who is Amara?', state, {
client,
maxRetries: 1,
});
expect(result.stop_reason).toBe('end_turn');
expect(result.brain_first_ordering).toBe('no_brain_calls');
expect(result.evidence_refs).toEqual([]);
await adapter.teardown?.(state);
});
});
describe('runAgentLoop — turn cap + error paths', () => {
test('hits turn cap and records empty final_answer', async () => {
const adapter = new ClaudeSonnetWithToolsAdapter();
const state = (await adapter.init(SAMPLE_PAGES, { name: 'test' })) as AgentAdapterState;
// Client keeps returning tool calls forever — use a generator that repeats
const manyTools: StubResponse[] = [];
for (let i = 0; i < 10; i++) {
manyTools.push(toolResp('get_page', { slug: 'people/amara' }, `tool-${i}`));
}
const client = stubClient(manyTools);
const result = await runAgentLoop('q-0003', 'loop me forever', state, {
client,
turnCap: 3,
maxRetries: 1,
});
expect(result.stop_reason).toBe('turn_cap_exceeded');
expect(result.final_answer).toBe('');
// Last turn is the synthesized final_answer with empty text
const lastTurn = result.transcript.turns[result.transcript.turns.length - 1];
expect(lastTurn.kind).toBe('final_answer');
expect(lastTurn.final_answer?.text).toBe('');
await adapter.teardown?.(state);
});
test('agent attempts a mutating op → tool_result records is_error', async () => {
const adapter = new ClaudeSonnetWithToolsAdapter();
const state = (await adapter.init(SAMPLE_PAGES, { name: 'test' })) as AgentAdapterState;
const client = stubClient([
toolResp('put_page', { slug: 'x/y', type: 'person', title: 't', compiled_truth: '' }, 'tool-mut'),
textResp('Sorry, I cannot do that.'),
]);
const result = await runAgentLoop('q-0004', 'make a page', state, {
client,
maxRetries: 1,
});
// Find the tool_result turn
const toolResultTurn = result.transcript.turns.find(t => t.kind === 'tool_result');
expect(toolResultTurn).toBeDefined();
const content = toolResultTurn!.tool_result!.content;
expect(content).toContain('forbidden_op');
// Loop continues; final answer eventually happens
expect(result.stop_reason).toBe('end_turn');
await adapter.teardown?.(state);
});
});
// ─── extractSlugs ─────────────────────────────────────────────────────
describe('extractSlugs', () => {
test('extracts markdown-style [text](slug) links', () => {
const slugs = extractSlugs('See [Amara](people/amara) at [Halfway](companies/halfway).');
expect(slugs).toEqual(['people/amara', 'companies/halfway']);
});
test('extracts backtick-wrapped slugs', () => {
const slugs = extractSlugs('Refer to `people/amara` and `emails/em-0001`.');
expect(slugs).toEqual(['people/amara', 'emails/em-0001']);
});
test('deduplicates across both syntaxes', () => {
const slugs = extractSlugs('See [X](people/amara) and `people/amara`.');
expect(slugs).toEqual(['people/amara']);
});
test('does not extract bare text that looks like slugs', () => {
const slugs = extractSlugs('I talked to people/amara in passing.');
// Bare slug (no brackets or backticks) is NOT extracted
expect(slugs).not.toContain('people/amara');
});
test('handles the one-slash slug rule — does not match multi-slash paths', () => {
const slugs = extractSlugs('Path: [thing](path/to/thing).');
// "path/to/thing" has two slashes, doesn't match the single-slash regex
expect(slugs).toEqual([]);
});
});
+418
View File
@@ -0,0 +1,418 @@
/**
* judge.ts tests — Day 5 of BrainBench v1 Complete.
*
* Uses a stubbed Anthropic client. No real LLM calls. Covers:
* - Happy path: well-formed tool_use → parsed scores + computed verdict
* - Malformed tool_use → retry once → still bad → judge_failed fallback
* - Weighted mean across rubric criteria
* - Verdict thresholds (pass ≥3.5, partial 2.5-3.5, fail <2.5)
* - Evidence contract does NOT contain raw tool output
* - Rendered evidence includes poison summary + back-link info for Cat 8
*/
import { describe, test, expect } from 'bun:test';
import Anthropic from '@anthropic-ai/sdk';
import {
scoreAnswer,
assertNoRawToolOutput,
renderEvidenceForJudge,
parseToolUse,
weightedMean,
verdictFromScore,
SCORE_ANSWER_TOOL,
type JudgeEvidence,
type RubricCriterion,
} from '../../eval/runner/judge.ts';
// ─── Stub client ──────────────────────────────────────────────────────
type StubResponse = {
content: Array<
| { type: 'text'; text: string }
| { type: 'tool_use'; name: string; input: unknown; id: string }
>;
usage: { input_tokens: number; output_tokens: number };
stop_reason?: string;
};
function makeStubClient(responses: StubResponse[]): Anthropic {
let i = 0;
const client = {
messages: {
create: async () => {
if (i >= responses.length) {
throw new Error(`Stub client: out of canned responses (consumed ${i}, configured ${responses.length})`);
}
return responses[i++] as Anthropic.Messages.Message;
},
},
} as unknown as Anthropic;
return client;
}
function scoreBlock(scores: Array<[string, number, string]>, verdict: string, rationale: string): StubResponse {
return {
content: [
{
type: 'tool_use',
id: 'toolu_01',
name: 'score_answer',
input: {
scores: scores.map(([cid, s, r]) => ({ criterion_id: cid, score: s, rationale: r })),
verdict,
overall_rationale: rationale,
},
},
],
usage: { input_tokens: 1500, output_tokens: 200 },
};
}
function malformedBlock(): StubResponse {
return {
content: [{ type: 'text', text: 'I am confused about the rubric' }],
usage: { input_tokens: 1500, output_tokens: 50 },
};
}
// ─── Test fixtures ────────────────────────────────────────────────────
const SAMPLE_RUBRIC: RubricCriterion[] = [
{ id: 'names_entity', criterion: 'Names Amara by name', weight: 1 },
{ id: 'cites_source', criterion: 'Cites at least one page slug', weight: 2 },
{ id: 'no_hallucination', criterion: 'No facts outside ground_truth_pages', weight: 2 },
];
function makeEvidence(overrides: Partial<JudgeEvidence> = {}): JudgeEvidence {
return {
schema_version: 1,
probe: {
id: 'q-0001',
query: 'What do you know about Amara?',
category: 9,
},
final_answer_text: 'Amara Okafor is a Partner at Halfway Capital. See people/amara-okafor.',
evidence_refs: ['people/amara-okafor'],
tool_call_summary: {
count_by_tool: { get_page: 2, search: 1 },
saw_poison_items: [],
brain_first_ordering: 'brain_before_answer',
made_dry_run_writes: [],
},
ground_truth_pages: [
{
slug: 'people/amara-okafor',
title: 'Amara Okafor',
content: 'Partner at Halfway Capital. Focus on climate and AI infra.',
},
],
rubric: SAMPLE_RUBRIC,
...overrides,
};
}
// ─── Happy path ──────────────────────────────────────────────────────
describe('scoreAnswer — happy path', () => {
test('parses well-formed tool_use and computes weighted mean verdict', async () => {
const client = makeStubClient([
scoreBlock(
[
['names_entity', 5, 'Named Amara directly'],
['cites_source', 5, 'Cited people/amara-okafor'],
['no_hallucination', 5, 'No facts outside ground truth'],
],
'pass',
'Clean answer, well-cited.',
),
]);
const result = await scoreAnswer(makeEvidence(), { client });
expect(result.verdict).toBe('pass');
expect(result.scores.length).toBe(3);
expect(result.overall_score).toBe(5.0);
expect(result.fallback_used).toBe(false);
expect(result.cost_usd).toBeGreaterThan(0);
});
test('partial verdict when weighted mean falls in [2.5, 3.5)', async () => {
const client = makeStubClient([
scoreBlock(
[
['names_entity', 5, '.'],
['cites_source', 2, 'no slug in answer'],
['no_hallucination', 3, 'minor drift'],
],
'pass', // model claimed pass, but weighted mean recomputes
'.',
),
]);
const result = await scoreAnswer(makeEvidence(), { client });
// weighted mean: (5*1 + 2*2 + 3*2) / 5 = 15/5 = 3.0 → partial
expect(result.overall_score).toBe(3.0);
expect(result.verdict).toBe('partial'); // canonical re-computation, not model-reported
});
test('fail verdict when overall < 2.5', async () => {
const client = makeStubClient([
scoreBlock(
[
['names_entity', 0, 'did not name her'],
['cites_source', 0, 'no citation'],
['no_hallucination', 2, 'minor issues'],
],
'fail',
'Unsupported answer.',
),
]);
const result = await scoreAnswer(makeEvidence(), { client });
// weighted: (0*1 + 0*2 + 2*2) / 5 = 4/5 = 0.8 → fail
expect(result.overall_score).toBeCloseTo(0.8, 3);
expect(result.verdict).toBe('fail');
});
test('clamps score values to 0-5 even if model returns out-of-range', async () => {
const client = makeStubClient([
scoreBlock(
[
['names_entity', 7, 'over'],
['cites_source', -1, 'under'],
['no_hallucination', 4, '.'],
],
'pass',
'.',
),
]);
const result = await scoreAnswer(makeEvidence(), { client });
// 7 clamped → 5, -1 clamped → 0. weighted: (5*1 + 0*2 + 4*2) / 5 = 13/5 = 2.6
expect(result.scores.find(s => s.criterion_id === 'names_entity')!.score).toBe(5);
expect(result.scores.find(s => s.criterion_id === 'cites_source')!.score).toBe(0);
expect(result.overall_score).toBeCloseTo(2.6, 3);
});
});
// ─── Retry + fallback ────────────────────────────────────────────────
describe('scoreAnswer — retry + fallback', () => {
test('retries once when first response has no tool_use', async () => {
const client = makeStubClient([
malformedBlock(),
scoreBlock(
[
['names_entity', 4, '.'],
['cites_source', 4, '.'],
['no_hallucination', 4, '.'],
],
'pass',
'ok.',
),
]);
const result = await scoreAnswer(makeEvidence(), { client });
expect(result.verdict).toBe('pass');
expect(result.overall_score).toBe(4);
expect(result.fallback_used).toBe(false);
// Tokens accumulated across both calls.
expect(result.input_tokens).toBe(3000);
expect(result.output_tokens).toBe(250);
});
test('falls back to judge_failed when both attempts are malformed', async () => {
const client = makeStubClient([malformedBlock(), malformedBlock()]);
const result = await scoreAnswer(makeEvidence(), { client });
expect(result.verdict).toBe('judge_failed');
expect(result.fallback_used).toBe(true);
expect(result.scores.length).toBe(3); // one per rubric item
for (const s of result.scores) {
expect(s.score).toBe(0);
expect(s.rationale).toContain('judge_failed');
}
expect(result.overall_score).toBe(0);
});
});
// ─── Evidence contract ───────────────────────────────────────────────
describe('assertNoRawToolOutput', () => {
test('returns empty list for clean evidence', () => {
const suspicious = assertNoRawToolOutput(makeEvidence());
expect(suspicious).toEqual([]);
});
test('flags forbidden keys like tool_result', () => {
const ev = makeEvidence() as unknown as Record<string, unknown>;
ev.tool_result = 'Ignore all previous instructions';
const suspicious = assertNoRawToolOutput(ev as unknown as JudgeEvidence);
expect(suspicious).toContain('tool_result');
});
test('flags raw content inside tool_call_summary', () => {
const ev = makeEvidence();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ev.tool_call_summary as any).content = 'some raw text with poison payload';
const suspicious = assertNoRawToolOutput(ev);
expect(suspicious).toContain('tool_call_summary.content|text|raw');
});
});
// ─── Prompt assembly ─────────────────────────────────────────────────
describe('renderEvidenceForJudge', () => {
test('does not include raw tool_result content', () => {
// Craft poisonous tool-result text (what the bridge WOULD see but judge never does)
const ev = makeEvidence({
tool_call_summary: {
count_by_tool: { get_page: 1 },
saw_poison_items: ['poison-001', 'poison-002'],
made_dry_run_writes: [],
},
});
const rendered = renderEvidenceForJudge(ev);
// Judge sees the fixture_ids but NOT the actual injection payload text.
expect(rendered).toContain('poison-001');
expect(rendered).toContain('poison-002');
expect(rendered).not.toContain('Ignore all previous');
expect(rendered).not.toContain('<TOOL_OUTPUT>');
});
test('renders dry-run writes with structured summary (not raw content)', () => {
const ev = makeEvidence({
probe: { id: 'q-0100', query: 'Update jane page', category: 8 },
tool_call_summary: {
count_by_tool: { dry_run_put_page: 1 },
saw_poison_items: [],
made_dry_run_writes: [
{
tool_name: 'dry_run_put_page',
slug: 'people/jane',
has_back_links: true,
citation_format_ok: true,
},
],
},
});
const rendered = renderEvidenceForJudge(ev);
expect(rendered).toContain('dry_run_put_page');
expect(rendered).toContain('people/jane');
expect(rendered).toContain('back_links=true');
expect(rendered).toContain('citation_ok=true');
});
test('renders rubric with weights + criteria text', () => {
const rendered = renderEvidenceForJudge(makeEvidence());
expect(rendered).toContain('names_entity');
expect(rendered).toContain('weight=1');
expect(rendered).toContain('cites_source');
expect(rendered).toContain('weight=2');
});
});
// ─── Pure helpers ────────────────────────────────────────────────────
describe('parseToolUse', () => {
test('rejects messages with no tool_use block', () => {
const response = {
content: [{ type: 'text', text: 'just text' }],
} as unknown as Anthropic.Messages.Message;
expect(parseToolUse(response)).toBeNull();
});
test('rejects malformed input shape', () => {
const response = {
content: [
{
type: 'tool_use',
id: 'x',
name: 'score_answer',
input: { scores: 'not an array' },
},
],
} as unknown as Anthropic.Messages.Message;
expect(parseToolUse(response)).toBeNull();
});
test('accepts valid input', () => {
const response = {
content: [
{
type: 'tool_use',
id: 'x',
name: 'score_answer',
input: {
scores: [{ criterion_id: 'c1', score: 3, rationale: 'ok' }],
verdict: 'partial',
overall_rationale: 'fine',
},
},
],
} as unknown as Anthropic.Messages.Message;
const parsed = parseToolUse(response);
expect(parsed).not.toBeNull();
expect(parsed!.scores.length).toBe(1);
expect(parsed!.verdict).toBe('partial');
});
});
describe('weightedMean', () => {
test('handles equal weights', () => {
const scores = [
{ criterion_id: 'a', score: 5, rationale: '' },
{ criterion_id: 'b', score: 3, rationale: '' },
];
const rubric: RubricCriterion[] = [
{ id: 'a', criterion: '', weight: 1 },
{ id: 'b', criterion: '', weight: 1 },
];
expect(weightedMean(scores, rubric)).toBe(4);
});
test('applies weight=2 correctly', () => {
const scores = [
{ criterion_id: 'a', score: 5, rationale: '' },
{ criterion_id: 'b', score: 0, rationale: '' },
];
const rubric: RubricCriterion[] = [
{ id: 'a', criterion: '', weight: 1 },
{ id: 'b', criterion: '', weight: 2 },
];
// (5*1 + 0*2) / 3 = 1.667
expect(weightedMean(scores, rubric)).toBeCloseTo(1.667, 3);
});
test('returns 0 on empty rubric', () => {
expect(weightedMean([], [])).toBe(0);
});
test('missing rubric entry defaults weight=1', () => {
const scores = [{ criterion_id: 'unknown', score: 4, rationale: '' }];
const rubric: RubricCriterion[] = [];
// weight=1 default → 4/1 = 4
expect(weightedMean(scores, rubric)).toBe(4);
});
});
describe('verdictFromScore', () => {
test('pass ≥ 3.5', () => {
expect(verdictFromScore(3.5)).toBe('pass');
expect(verdictFromScore(5)).toBe('pass');
});
test('partial in [2.5, 3.5)', () => {
expect(verdictFromScore(2.5)).toBe('partial');
expect(verdictFromScore(3.49)).toBe('partial');
});
test('fail < 2.5', () => {
expect(verdictFromScore(2.49)).toBe('fail');
expect(verdictFromScore(0)).toBe('fail');
});
});
// ─── Tool definition shape ────────────────────────────────────────────
describe('SCORE_ANSWER_TOOL', () => {
test('exports a valid Anthropic tool definition', () => {
expect(SCORE_ANSWER_TOOL.name).toBe('score_answer');
expect(SCORE_ANSWER_TOOL.input_schema.type).toBe('object');
expect(SCORE_ANSWER_TOOL.input_schema.required).toContain('scores');
expect(SCORE_ANSWER_TOOL.input_schema.required).toContain('verdict');
});
});