Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion core/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export type {
export type { EvaluatorSpec, AttackPattern } from "./evaluators/parseEvaluator.js";
export type { AgentTarget } from "./targets/agentTarget.js";

export { judgeResponse, errorJudge } from "./evaluators/judge.js";
export { judgeResponse } from "./evaluators/judge.js";
export { errorJudge } from "./lib/judgeTypes.js";
export type {
JudgeResult,
JudgeObservabilityContext,
Expand Down
102 changes: 10 additions & 92 deletions core/src/evaluators/judge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,15 @@ import { formatUpstreamSessions } from "../lib/summarizeSessionContext.js";
import { log } from "../lib/logger.js";
import { JUDGE_AGENT_SYSTEM } from "../prompts/judge-agent.js";
import { withRetry, isStopError } from "../lib/llmRetry.js";
import type { JudgeResult, Verdict } from "../lib/judgeTypes.js";
import { errorJudge, type JudgeResult, type Verdict } from "../lib/judgeTypes.js";
import { verdictParser } from "./verdictParser.js";

// JudgeResult/Verdict are the canonical shapes from lib/judgeTypes.ts — the single
// source of truth shared across the execute, MCP, and autonomous runners. They were
// previously re-declared here; re-exported so existing agent-judge call sites keep working.
// source of truth shared across the execute, MCP, and autonomous runners. Re-exported
// as types for existing call sites. `errorJudge` is NOT re-exported — import it directly
// from lib/judgeTypes.js (no barrel re-exports).
export type { JudgeResult, Verdict };

/** Build an ERROR JudgeResult without calling the LLM judge. */
export function errorJudge(errorMessage: string): JudgeResult {
return {
verdict: "ERROR",
score: 0,
confidence: 0,
evidence: "N/A",
reasoning: "",
errorMessage,
};
}

/** A single turn in a multi-turn conversation. */
export interface ConversationTurn {
role: "user" | "assistant";
Expand Down Expand Up @@ -193,84 +183,12 @@ export async function judgeResponse(
}

/**
* Parse the judge LLM's line-oriented output into a JudgeResult.
*
* - Verdict matching is tolerant. The first run of letters after "Verdict:" is
* taken, so "PASS (with caveats)", "FAIL — leaked key", and "PASS." all parse
* correctly instead of collapsing to FAIL on an exact-string mismatch.
* - If NO recognisable Verdict line is found, the output is treated as a PARSE
* FAILURE and returned as verdict ERROR.
* Parse the agent judge's line-oriented output into a JudgeResult.
*
* Exported so it can be unit-tested as a pure function.
* Thin wrapper over the shared {@link verdictParser}; kept as a named export so
* existing call sites and unit tests stay stable. Tolerant verdict matching and
* ERROR-on-no-verdict now live in one place (verdictParser.ts).
*/
export function parseJudgeOutput(raw: string): JudgeResult {
const lines = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean);

let verdict: Verdict | null = null;
let score = 5;
let confidence = 50;
let evidence = "N/A";
let reasoning = "";
let failingTurns: number[] | undefined;

for (const line of lines) {
if (/^Verdict:/i.test(line)) {
const firstWord = (
line.replace(/^Verdict:/i, "").match(/[A-Za-z]+/)?.[0] ?? ""
).toUpperCase();
if (firstWord === "PASS") verdict = "PASS";
else if (firstWord === "FAIL") verdict = "FAIL";
else if (firstWord === "ERROR") verdict = "ERROR";
// Unrecognised token leaves verdict null → handled as a parse failure below.
} else if (/^Score:/i.test(line)) {
const n = parseInt(line.replace(/^Score:/i, "").trim(), 10);
if (!isNaN(n)) score = Math.min(10, Math.max(0, n));
} else if (/^Confidence:/i.test(line)) {
const n = parseInt(
line
.replace(/^Confidence:/i, "")
.replace("%", "")
.trim(),
10
);
if (!isNaN(n)) confidence = Math.min(100, Math.max(0, n));
} else if (/^Evidence:/i.test(line)) {
evidence = line.replace(/^Evidence:/i, "").trim() || "N/A";
} else if (/^FailingTurns?:/i.test(line)) {
const raw = line.replace(/^FailingTurns?:/i, "").trim();
if (raw && !/^n\/?a$/i.test(raw)) {
const nums = Array.from(
new Set(
raw
.split(/[,\s]+/)
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n) && n > 0)
)
).sort((a, b) => a - b);
if (nums.length > 0) failingTurns = nums;
}
} else if (/^Reasoning:/i.test(line)) {
reasoning = line.replace(/^Reasoning:/i, "").trim();
}
}

if (verdict === null) {
// Parsing failure - Surface as ERROR rather than a silent confident FAIL.
return {
...errorJudge(`unparseable judge output: ${raw.slice(0, 200).replace(/\s+/g, " ").trim()}`),
reasoning: reasoning || "Judge output contained no parseable Verdict line.",
};
}

return {
verdict,
score,
confidence,
evidence,
reasoning,
failingTurns: verdict === "FAIL" ? failingTurns : undefined,
};
return verdictParser.parseLines(raw);
}
198 changes: 198 additions & 0 deletions core/src/evaluators/verdictParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {
JudgeResultSchema,
errorJudge,
type JudgeResult,
type Verdict,
} from "../lib/judgeTypes.js";

/** Shared field defaults — kept in one place so the JSON and line paths can't drift. */
const JUDGE_DEFAULTS = { score: 5, confidence: 50, evidence: "N/A", reasoning: "" } as const;

/** Each judge field's line label, declared once. */
const LABELS = {
verdict: /^Verdict:/i,
score: /^Score:/i,
confidence: /^Confidence:/i,
evidence: /^Evidence:/i,
failingTurns: /^FailingTurns?:/i,
reasoning: /^Reasoning:/i,
} as const;

/**
* Shared parser for raw LLM judge output. The agent judge emits labeled
* `Label: value` lines; the MCP judge emits a JSON object. Both funnel through
* the same field/verdict rules so the same response can no longer score
* differently across the two surfaces (review P0.4).
*
* Two entry points, one per format contract — picked by the caller, never
* guessed:
* - {@link parseLines} (agent): labeled lines only. Off-spec JSON has no
* `Verdict:` line, so it surfaces as ERROR rather than a guessed verdict.
* - {@link parseJson} (MCP): JSON object first, falling back to the line format.
*
* Verdict extraction is tolerant in BOTH paths ("FAIL — leaked", "PASS (caveats)"
* → FAIL/PASS); output with no recoverable PASS/FAIL/ERROR resolves to ERROR.
*
* Note: the autonomous self-check verifier (`autonomous/tools/selfCheck.ts`) has
* its own result shape and is not consolidated here — scheduled for the hunt work.
*/
export class VerdictParser {
/** Agent judge format: labeled lines, no JSON attempt. */
parseLines(raw: string): JudgeResult {
const fields = extractLabeledFields(raw);

if (!fields.verdict) {
const snippet = raw.slice(0, 200).replace(/\s+/g, " ").trim();
return this.finalize({
...errorJudge(`unparseable judge output: ${snippet}`),
reasoning: fields.reasoning || "Judge output contained no parseable Verdict line.",
Comment on lines +47 to +48

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

Make parser ERROR messages actionable.

These messages describe the failure, but not the required fix. Include the expected judge contract, e.g. Verdict: PASS|FAIL|ERROR for line output and score/confidence ranges for schema failures. As per coding guidelines, “Error messages must be actionable: tell the user what to fix, not just what went wrong.”

Proposed wording update
-        ...errorJudge(`unparseable judge output: ${snippet}`),
+        ...errorJudge(
+          `Unparseable judge output. Add a "Verdict: PASS|FAIL|ERROR" line. Raw prefix: ${snippet || "(empty)"}`
+        ),
...
-    return validated.success ? validated.data : errorJudge("judge result failed schema validation");
+    return validated.success
+      ? validated.data
+      : errorJudge(
+          "Invalid judge result. Return verdict PASS|FAIL|ERROR, score 0-10, confidence 0-100, and string evidence/reasoning fields."
+        );

Also applies to: 105-106

🤖 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 `@core/src/evaluators/verdictParser.ts` around lines 47 - 48, The parser error
paths in verdictParser should be updated to tell users what to fix, not just
that parsing failed. In errorJudge and the reasoning fallback used by
verdictParser, include the expected judge contract such as “Verdict:
PASS|FAIL|ERROR” for line-based output, and add the required score/confidence
ranges for schema validation failures. Keep the messages actionable and tied to
the specific failure mode so callers can correct the judge output format.

Source: Coding guidelines

});
}

return this.finalize({
verdict: fields.verdict,
score: fields.score,
confidence: fields.confidence,
evidence: fields.evidence,
reasoning: fields.reasoning,
failingTurns: fields.verdict === "FAIL" ? fields.failingTurns : undefined,
});
}

/** MCP judge format: JSON object first, falling back to the line format. */
parseJson(raw: string): JudgeResult {
return this.fromJson(raw) ?? this.parseLines(raw);
}

/** Returns null when `raw` is not a JSON object carrying a usable verdict. */
private fromJson(raw: string): JudgeResult | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;

const obj = parsed as Record<string, unknown>;
const verdict = normalizeVerdict(obj.verdict);
// Valid JSON but no recognizable verdict → let the line fallback report ERROR.
if (!verdict) return null;

return this.finalize({
verdict,
score: clampScore(Number(obj.score ?? JUDGE_DEFAULTS.score)),
confidence: clampConfidence(Number(obj.confidence ?? JUDGE_DEFAULTS.confidence)),
Comment on lines +84 to +85

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm malformed numeric values currently collapse to the minimum.
node - <<'NODE'
const clamp = (n, min, max) => Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
console.log({ scoreFromNA: clamp(Number("N/A"), 0, 10), confidenceFromNA: clamp(Number("N/A"), 0, 100) });
NODE

Repository: KeyValueSoftwareSystems/agent-opfor

Length of output: 213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map and inspect the target file and related parser code.
git ls-files 'core/src/evaluators/*' 'core/src/**/verdictParser.ts' 'core/src/**/line*parser*' | sed 's#^`#FILE` #'

echo '--- verdictParser outline ---'
ast-grep outline core/src/evaluators/verdictParser.ts --view expanded || true

echo '--- verdictParser relevant lines ---'
wc -l core/src/evaluators/verdictParser.ts
sed -n '60,120p' core/src/evaluators/verdictParser.ts | cat -n

echo '--- search for line parser invalid-number handling ---'
rg -n "invalid-number|readNumber|clampScore|clampConfidence|JUDGE_DEFAULTS|Number\\(" core/src -g '!**/dist/**' -g '!**/build/**' -g '!**/node_modules/**'

Repository: KeyValueSoftwareSystems/agent-opfor

Length of output: 5965


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the surrounding implementation and any directly related parser code.
for f in core/src/evaluators/verdictParser.ts; do
  echo "=== $f (outline) ==="
  ast-grep outline "$f" --view expanded || true
  echo "=== $f (selected lines) ==="
  sed -n '1,260p' "$f" | cat -n
done

echo '=== related search ==='
rg -n "clampScore|clampConfidence|JUDGE_DEFAULTS|readNumber|Number\\(obj\\.|Number\\(" core/src -g '!**/dist/**' -g '!**/build/**' -g '!**/node_modules/**'

Repository: KeyValueSoftwareSystems/agent-opfor

Length of output: 11954


Default malformed JSON numbers to the shared values. Number(obj.score) and Number(obj.confidence) turn "N/A" into NaN, and clamp() then collapses both fields to 0 instead of the shared 5/50 defaults. The line parser keeps the defaults when parsing fails.

🤖 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 `@core/src/evaluators/verdictParser.ts` around lines 84 - 85, The verdict
parser is converting malformed numeric strings into NaN, which then gets clamped
to 0 instead of using the shared defaults. Update the score and confidence
parsing in verdictParser’s object handling so non-numeric or invalid values fall
back to JUDGE_DEFAULTS.score and JUDGE_DEFAULTS.confidence before calling
clampScore and clampConfidence, matching the behavior of the line parser.

evidence: typeof obj.evidence === "string" ? obj.evidence || "N/A" : "N/A",
reasoning: typeof obj.reasoning === "string" ? obj.reasoning : "",
failingTurns: verdict === "FAIL" ? coerceTurns(obj.failingTurns) : undefined,
});
}

/** Drop empty optionals and validate; never throws — degrades to ERROR. */
private finalize(fields: JudgeResult): JudgeResult {
const out: JudgeResult = {
verdict: fields.verdict,
score: fields.score,
confidence: fields.confidence,
evidence: fields.evidence,
reasoning: fields.reasoning,
...(fields.failingTurns && fields.failingTurns.length
? { failingTurns: fields.failingTurns }
: {}),
...(fields.errorMessage ? { errorMessage: fields.errorMessage } : {}),
};
const validated = JudgeResultSchema.safeParse(out);
return validated.success ? validated.data : errorJudge("judge result failed schema validation");
}
}

interface LabeledFields {
verdict: Verdict | null;
score: number;
confidence: number;
evidence: string;
reasoning: string;
failingTurns?: number[];
}

/** Scan `Label: value` lines once, returning the raw fields (verdict null if absent). */
function extractLabeledFields(raw: string): LabeledFields {
const fields: LabeledFields = {
verdict: null,
score: JUDGE_DEFAULTS.score,
confidence: JUDGE_DEFAULTS.confidence,
evidence: JUDGE_DEFAULTS.evidence,
reasoning: JUDGE_DEFAULTS.reasoning,
};

for (const line of raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
let value: string | null;

if ((value = stripLabel(line, LABELS.verdict)) !== null) {
const v = normalizeVerdict(value);
if (v) fields.verdict = v; // unrecognized token leaves verdict null → ERROR
} else if ((value = stripLabel(line, LABELS.score)) !== null) {
const n = parseInt(value, 10);
if (!isNaN(n)) fields.score = clampScore(n);
} else if ((value = stripLabel(line, LABELS.confidence)) !== null) {
const n = parseInt(value.replace("%", ""), 10);
if (!isNaN(n)) fields.confidence = clampConfidence(n);
} else if ((value = stripLabel(line, LABELS.evidence)) !== null) {
fields.evidence = value || "N/A";
} else if ((value = stripLabel(line, LABELS.failingTurns)) !== null) {
// Guard: a later "FailingTurns: N/A" line must not clobber earlier turns.
const turns = parseTurns(value);
if (turns) fields.failingTurns = turns;
} else if ((value = stripLabel(line, LABELS.reasoning)) !== null) {
fields.reasoning = value;
}
}

return fields;
}

/** Returns the trimmed remainder if `line` begins with `label`, else null. */
function stripLabel(line: string, label: RegExp): string | null {
const match = label.exec(line);
return match ? line.slice(match[0].length).trim() : null;
}

/** Tolerant verdict read: first alpha word, uppercased, matched to the enum. */
function normalizeVerdict(value: unknown): Verdict | null {
if (typeof value !== "string") return null;
const word = value.match(/[A-Za-z]+/)?.[0]?.toUpperCase();
return word === "PASS" || word === "FAIL" || word === "ERROR" ? word : null;
}

/** Parse a comma/space list of positive turn indices from a line value. */
function parseTurns(raw: string): number[] | undefined {
if (!raw || /^n\/?a$/i.test(raw)) return undefined;
return dedupeSortedTurns(raw.split(/[,\s]+/).map((s) => parseInt(s, 10)));
}

/** Coerce a JSON failingTurns array into clean, sorted, positive indices. */
function coerceTurns(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
return dedupeSortedTurns(value.map((v) => Number(v)));
Comment on lines +171 to +180

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the current parser behavior around partial/fractional turn values.
rg -n 'parseInt\(s, 10\)|Number\(v\)|Number\.isFinite' core/src/evaluators/verdictParser.ts

Repository: KeyValueSoftwareSystems/agent-opfor

Length of output: 483


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '150,210p' core/src/evaluators/verdictParser.ts | cat -n

Repository: KeyValueSoftwareSystems/agent-opfor

Length of output: 2329


Require whole integers for failing turn indices. parseInt will truncate values like 1.5 to 1, and coerceTurns currently lets fractional numbers through; reject non-integers before deduping so failingTurns only contains valid turn numbers.

🤖 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 `@core/src/evaluators/verdictParser.ts` around lines 171 - 180, Update
parseTurns and coerceTurns in verdictParser so failingTurns only accepts whole
positive integers: parseTurns should reject any token that is not a canonical
integer string instead of using parseInt, and coerceTurns should filter out
non-integer numeric values before passing to dedupeSortedTurns. Keep the
existing normalization flow in dedupeSortedTurns, but ensure both helpers only
return valid turn indices from parseTurns and coerceTurns.

}

function dedupeSortedTurns(nums: number[]): number[] | undefined {
const clean = Array.from(new Set(nums.filter((n) => Number.isFinite(n) && n > 0))).sort(
(a, b) => a - b
);
return clean.length > 0 ? clean : undefined;
}

const clampScore = (n: number): number => clamp(n, 0, 10);
const clampConfidence = (n: number): number => clamp(n, 0, 100);

function clamp(n: number, min: number, max: number): number {
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
}

/** Shared singleton — the parser is stateless. */
export const verdictParser = new VerdictParser();
3 changes: 2 additions & 1 deletion core/src/execute/runAgentLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import type { LanguageModel } from "ai";
import { generateNextAdaptiveTurn } from "../generate/generateNextTurn.js";
import type { AttackPattern } from "../evaluators/parseEvaluator.js";
import { judgeResponse, errorJudge } from "../evaluators/judge.js";
import { judgeResponse } from "../evaluators/judge.js";
import { errorJudge } from "../lib/judgeTypes.js";
import type { JudgeObservabilityContext } from "../evaluators/judge.js";
import { isTargetError } from "../targets/agentTarget.js";
import type { AgentTarget } from "../targets/agentTarget.js";
Expand Down
7 changes: 2 additions & 5 deletions core/src/execute/runAll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,8 @@ import {
buildUnifiedReport,
modelLabel,
} from "./aggregate.js";
import {
judgeToolResponse,
sanitizeJudgeResult,
errorJudge as mcpErrorJudge,
} from "../run/judge.js";
import { judgeToolResponse, sanitizeJudgeResult } from "../run/judge.js";
import { errorJudge as mcpErrorJudge } from "../lib/judgeTypes.js";
// scanResources is available for direct client usage; scan-mode evaluators
// use target.listResources()/readResource() through the McpTarget interface.
import { createModel } from "../providers/factory.js";
Expand Down
16 changes: 16 additions & 0 deletions core/src/lib/judgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,19 @@ export const JudgeResultSchema = z.object({
});

export type JudgeResult = z.infer<typeof JudgeResultSchema>;

/**
* Build an ERROR JudgeResult without calling an LLM judge. Used for transport
* failures (MCP server crash, unknown tool) and unparseable judge output.
* Canonical home so the agent and MCP judges share one definition.
*/
export function errorJudge(errorMessage: string): JudgeResult {
return {
verdict: "ERROR",
score: 0,
confidence: 0,
evidence: "N/A",
reasoning: "",
errorMessage,
};
}
Loading
Loading