Skip to content

Commit 5a6b085

Browse files
iamzifeiclaude
andcommitted
feat: v0.2.0 — real DX scoring, configurable judge, deterministic tasks
Three improvements that move the framework from proof-of-concept to credible evaluation tool: Feature A: Real DX Scoring - New dx-scorer.ts computes DX from actual data (no LLM calls) - Schema quality (40%): typed properties, descriptions, required fields - Documentation (30%): tool descriptions, meaningful content - Error messages (30%): informative vs empty vs raw stack traces - Replaces hardcoded placeholder of 70 for all agents Feature B: Configurable Judge Model - New eval.judge.model config field (backward compatible, optional) - Threads through engine → scorer → callWithRetry - Report now records which model judged the results - Default remains claude-sonnet-4-20250514 Feature C: Deterministic Task Sets - Tasks cached to .agent-eval/tasks/ after first generation - Subsequent runs reuse cached tasks (same tools = same tasks) - Auto-versioned: 1.0.0 → 1.0.1 → 1.0.2 on regeneration - --regenerate-tasks CLI flag to force fresh generation - Tool mismatch detection triggers automatic regeneration Stats: 18 source files, 11 test files, 65 tests all passing. CI: lint + typecheck + test + build all green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d46d457 commit 5a6b085

14 files changed

Lines changed: 811 additions & 21 deletions

File tree

packages/agent-eval/homepage_snapshot.md

Whitespace-only changes.

packages/agent-eval/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@agenthunter/eval",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Open-source AI agent evaluation framework — benchmark MCP servers, A2A agents, and API-first agent services",
55
"type": "module",
66
"bin": {

packages/agent-eval/src/commands/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ agent:
106106
107107
eval:
108108
runs: 20 # Number of test runs for reliability scoring
109+
# judge:
110+
# model: claude-sonnet-4-20250514 # Model used for LLM-as-judge scoring
109111
dimensions:
110112
capability:
111113
weight: 0.30

packages/agent-eval/src/commands/run.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export const runCommand = new Command("run")
4343
"--max-tools <number>",
4444
"Max tools to evaluate (randomly samples from discovered tools)",
4545
)
46+
.option(
47+
"--regenerate-tasks",
48+
"Force regeneration of test tasks (ignore cached task set)",
49+
)
4650
.option("--json", "Output results as JSON only")
4751
.option(
4852
"-o, --output <dir>",
@@ -71,6 +75,7 @@ export const runCommand = new Command("run")
7175
tasksPerTool,
7276
runsPerTask,
7377
maxTools,
78+
regenerateTasks: options.regenerateTasks ?? false,
7479
outputDir: options.output,
7580
onProgress: (_step, detail) => {
7681
spinner.text = detail;

packages/agent-eval/src/config/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ const DEFAULT_DIMENSIONS = {
2727
developer_experience: { weight: 0.1 },
2828
};
2929

30+
// Judge configuration for LLM-as-judge scoring
31+
const JudgeSchema = z
32+
.object({
33+
model: z.string().default("claude-sonnet-4-20250514"),
34+
})
35+
.optional();
36+
3037
// Full agent-eval.yaml config schema
3138
export const AgentEvalConfig = z.object({
3239
agent: z.object({
@@ -39,6 +46,7 @@ export const AgentEvalConfig = z.object({
3946
}),
4047
eval: z.object({
4148
runs: z.number().int().min(1).default(20),
49+
judge: JudgeSchema,
4250
dimensions: z
4351
.object({
4452
capability: DimensionSchema,
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Developer Experience (DX) scoring — evaluates MCP server quality
3+
* from a developer's perspective using data already collected during eval.
4+
*
5+
* Three sub-scores, no LLM calls needed:
6+
* 1. Schema Quality (40%) — are tool input schemas well-defined?
7+
* 2. Documentation (30%) — do tools have meaningful descriptions?
8+
* 3. Error Messages (30%) — are error messages helpful when tools fail?
9+
*/
10+
11+
import type { ToolInfo } from "../protocols/base.js";
12+
import type { TaskResult } from "./executor.js";
13+
14+
export interface DxSubScores {
15+
schemaQuality: number; // 0-100
16+
documentation: number; // 0-100
17+
errorMessages: number; // 0-100
18+
overall: number; // 0-100
19+
}
20+
21+
/**
22+
* Compute the DX score from tools metadata and execution results.
23+
* Pure function — no LLM calls, no async, no side effects.
24+
*/
25+
export function computeDxScore(
26+
tools: ToolInfo[],
27+
taskResults: TaskResult[],
28+
): DxSubScores {
29+
const schemaQuality = scoreSchemaQuality(tools);
30+
const documentation = scoreDocumentation(tools);
31+
const errorMessages = scoreErrorMessages(taskResults);
32+
33+
// Weighted combination: schema 40%, docs 30%, errors 30%
34+
const overall = Math.round(
35+
schemaQuality * 0.4 + documentation * 0.3 + errorMessages * 0.3,
36+
);
37+
38+
return { schemaQuality, documentation, errorMessages, overall };
39+
}
40+
41+
/**
42+
* Score how well tool input schemas are defined.
43+
* Checks: properties exist, types declared, descriptions present, required fields specified.
44+
*/
45+
export function scoreSchemaQuality(tools: ToolInfo[]): number {
46+
if (tools.length === 0) return 50; // Neutral for no tools
47+
48+
let totalScore = 0;
49+
50+
for (const tool of tools) {
51+
const schema = tool.inputSchema;
52+
let toolScore = 0;
53+
let checks = 0;
54+
55+
// Check 1: Has properties defined (not empty schema)
56+
const properties =
57+
(schema.properties as Record<string, Record<string, unknown>>) || {};
58+
const propCount = Object.keys(properties).length;
59+
checks++;
60+
if (propCount > 0) toolScore++;
61+
62+
// Check 2: Properties have types declared
63+
if (propCount > 0) {
64+
const withType = Object.values(properties).filter(
65+
(p) => p.type !== undefined,
66+
).length;
67+
checks++;
68+
toolScore += withType / propCount;
69+
}
70+
71+
// Check 3: Properties have descriptions
72+
if (propCount > 0) {
73+
const withDesc = Object.values(properties).filter(
74+
(p) => typeof p.description === "string" && p.description.length > 0,
75+
).length;
76+
checks++;
77+
toolScore += withDesc / propCount;
78+
}
79+
80+
// Check 4: Required fields specified
81+
const required = schema.required as string[] | undefined;
82+
checks++;
83+
if (Array.isArray(required) && required.length > 0) toolScore++;
84+
85+
totalScore += checks > 0 ? toolScore / checks : 0;
86+
}
87+
88+
return Math.round((totalScore / tools.length) * 100);
89+
}
90+
91+
/**
92+
* Score how well tools are documented.
93+
* Checks: description exists, is meaningful (>20 chars), explains behavior.
94+
*/
95+
export function scoreDocumentation(tools: ToolInfo[]): number {
96+
if (tools.length === 0) return 50;
97+
98+
let totalScore = 0;
99+
100+
for (const tool of tools) {
101+
let toolScore = 0;
102+
103+
// Check 1: Has a non-empty description
104+
const desc = tool.description || "";
105+
if (desc.length > 0) toolScore += 0.4;
106+
107+
// Check 2: Description is meaningful (> 20 chars)
108+
if (desc.length > 20) toolScore += 0.3;
109+
110+
// Check 3: Description explains behavior (contains action words)
111+
if (desc.length > 0) {
112+
const actionPatterns =
113+
/\b(returns?|creates?|gets?|sets?|lists?|reads?|writes?|deletes?|updates?|searches?|sends?|fetches?|generates?|computes?|validates?|converts?|handles?|manages?|provides?)\b/i;
114+
if (actionPatterns.test(desc)) toolScore += 0.3;
115+
}
116+
117+
totalScore += toolScore;
118+
}
119+
120+
return Math.round((totalScore / tools.length) * 100);
121+
}
122+
123+
/**
124+
* Score error message quality from failed task runs.
125+
* Checks: errors are informative (not empty), structured (not raw stack traces).
126+
* If no failures occurred, return neutral score (70) — no error data to evaluate.
127+
*/
128+
export function scoreErrorMessages(taskResults: TaskResult[]): number {
129+
// Collect all error messages from failed runs
130+
const errors: string[] = [];
131+
for (const tr of taskResults) {
132+
for (const run of tr.runs) {
133+
if (!run.invocation.success && run.invocation.error) {
134+
errors.push(run.invocation.error);
135+
}
136+
}
137+
}
138+
139+
// No failures — can't evaluate error quality, give neutral score
140+
if (errors.length === 0) return 70;
141+
142+
let totalScore = 0;
143+
144+
for (const error of errors) {
145+
let errorScore = 0;
146+
147+
// Check 1: Error message is not empty
148+
if (error.length > 0) errorScore += 0.3;
149+
150+
// Check 2: Error message is descriptive (> 10 chars)
151+
if (error.length > 10) errorScore += 0.3;
152+
153+
// Check 3: Error is structured (not a raw stack trace)
154+
// Raw stack traces contain "at " lines and file paths — penalize these
155+
const stackTraceLines = error
156+
.split("\n")
157+
.filter((line) => /^\s+at\s/.test(line)).length;
158+
const totalLines = error.split("\n").length;
159+
if (totalLines > 0 && stackTraceLines / totalLines < 0.5) {
160+
errorScore += 0.4;
161+
}
162+
163+
totalScore += errorScore;
164+
}
165+
166+
return Math.round((totalScore / errors.length) * 100);
167+
}

packages/agent-eval/src/eval/engine.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
} from "../report/generator.js";
1414
import { executeTasks } from "./executor.js";
1515
import { scoreResults } from "./scorer.js";
16+
import type { TestTask } from "./task-generator.js";
1617
import { generateTasks } from "./task-generator.js";
18+
import { loadTaskSet, saveTaskSet } from "./task-store.js";
1719

1820
/** Progress callback — receives step name and detail message */
1921
export type ProgressCallback = (step: string, detail: string) => void;
@@ -26,6 +28,10 @@ export interface EvalOptions {
2628
runsPerTask?: number;
2729
/** Max tools to evaluate — for large servers, randomly sample this many tools */
2830
maxTools?: number;
31+
/** Force regeneration of task set even if cached */
32+
regenerateTasks?: boolean;
33+
/** Base directory for task set storage (defaults to outputDir or cwd) */
34+
baseDir?: string;
2935
outputDir?: string;
3036
/** Called on each major step for progress reporting */
3137
onProgress?: ProgressCallback;
@@ -55,6 +61,8 @@ export async function runEvaluation(options: EvalOptions): Promise<EvalResult> {
5561
tasksPerTool = 3,
5662
runsPerTask = config.eval.runs,
5763
maxTools,
64+
regenerateTasks = false,
65+
baseDir = process.cwd(),
5866
outputDir,
5967
onProgress,
6068
} = options;
@@ -95,17 +103,44 @@ export async function runEvaluation(options: EvalOptions): Promise<EvalResult> {
95103
throw new Error("No tools found. Nothing to evaluate.");
96104
}
97105

98-
// Step 2: Generate test tasks using Claude API
99-
// The MCP connection stays open during this step — tested to survive 60s+ idle
100-
progress(
101-
"generate",
102-
`Generating ${tasksPerTool} tasks per tool (${tools.length} tools)...`,
103-
);
104-
const tasks = await generateTasks(tools, config.agent.capabilities, {
105-
tasksPerTool,
106-
apiKey,
107-
});
108-
progress("generate", `Generated ${tasks.length} test tasks`);
106+
// Step 2: Load cached tasks or generate new ones
107+
const toolNames = tools.map((t) => t.name);
108+
let tasks: TestTask[];
109+
110+
// Try loading cached task set (unless forced to regenerate)
111+
const cached = !regenerateTasks
112+
? loadTaskSet(baseDir, config.agent.name, toolNames)
113+
: null;
114+
115+
if (cached) {
116+
tasks = cached.tasks;
117+
progress(
118+
"generate",
119+
`Loaded ${tasks.length} cached tasks (v${cached.version})`,
120+
);
121+
} else {
122+
// Generate new tasks via Claude API
123+
// The MCP connection stays open during this step — tested to survive 60s+ idle
124+
progress(
125+
"generate",
126+
`Generating ${tasksPerTool} tasks per tool (${tools.length} tools)...`,
127+
);
128+
tasks = await generateTasks(tools, config.agent.capabilities, {
129+
tasksPerTool,
130+
apiKey,
131+
});
132+
const saved = saveTaskSet(
133+
baseDir,
134+
config.agent.name,
135+
toolNames,
136+
tasks,
137+
config.eval.judge?.model ?? "claude-sonnet-4-20250514",
138+
);
139+
progress(
140+
"generate",
141+
`Generated and saved ${tasks.length} tasks (v${saved.version})`,
142+
);
143+
}
109144

110145
// Step 3: Execute tasks
111146
const totalRuns = tasks.length * runsPerTask;
@@ -134,7 +169,9 @@ export async function runEvaluation(options: EvalOptions): Promise<EvalResult> {
134169
};
135170
const scores = await scoreResults(execution.taskResults, {
136171
apiKey,
172+
tools,
137173
weights,
174+
judgeModel: config.eval.judge?.model,
138175
onTaskScored: (completed, total) => {
139176
progress("score", `Scoring tasks... ${completed}/${total}`);
140177
},

0 commit comments

Comments
 (0)