# Evaluation — Testing and Benchmarking Agents > Write test cases, score an agent's answers with built-in or custom scorers, grade with an LLM judge, compare runs and print reports. --- ## Table of Contents 1. [Overview](#overview) 2. [Test Case Authoring](#test-case-authoring) 3. [Built-In Scorers](#built-in-scorers) 4. [Custom Scorers](#custom-scorers) 5. [LLM-as-Judge](#llm-as-judge) 6. [Running an Evaluation](#running-an-evaluation) 7. [Comparing Two Agents](#comparing-two-agents) 8. [Keeping Runs](#keeping-runs) 9. [Reports](#reports) --- ## Overview ``` test cases → evaluator.runEvaluation(name, cases, agentFn) → EvalRun ↓ each case: scorers → weighted score → passed? ↓ evaluator.generateReport(runId, 'json' | 'markdown' | 'html') ``` The evaluation exports come from the root package: ```typescript import { Evaluator, LLMJudge, CRITERIA_PRESETS, type EvalTestCase, type EvalRun, } from '@framers/agentos'; ``` --- ## Test Case Authoring A test case is one input with what to compare the answer against and how to score it: ```typescript import type { EvalTestCase } from '@framers/agentos'; const testCases: EvalTestCase[] = [ { id: 'capital-1', name: 'Capital of France', input: 'What is the capital of France?', expectedOutput: 'Paris', criteria: [ { name: 'correctness', description: 'Contains the answer', weight: 1, scorer: 'contains' }, ], }, { id: 'summary-1', name: 'TCP handshake summary', category: 'summaries', input: 'Summarize the TCP three-way handshake.', expectedOutput: 'SYN, SYN-ACK, ACK: client and server establish a reliable connection.', criteria: [ { name: 'coverage', description: 'Shares the key words', weight: 2, scorer: 'semantic_similarity' }, { name: 'wording', description: 'Close to the reference', weight: 1, scorer: 'rouge' }, ], }, ]; ``` | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Unique identifier | | `name` | `string` | Label used in reports | | `input` | `string` | The prompt passed to the agent function | | `expectedOutput` | `string` | The reference answer the scorers compare against | | `referenceOutputs` | `string[]` | Extra references; `bleu` and `semantic_similarity` use them when `expectedOutput` is missing | | `context` | `string` | Passed to the agent function as its second argument | | `criteria` | `EvalCriteria[]` | `{ name, description, weight, scorer }`; without criteria the case is scored by `levenshtein` alone | | `category` | `string` | A label; the aggregates do not group by it | | `metadata` | `object` | Passed to every scorer as its fourth argument | A test case is one input; there is no multi-turn form. To test a conversation, have the agent function keep a session and send earlier turns before the input. The case's score is the weighted mean of its criteria scores, and it passes when the score is at least `thresholds.pass` (default 0.7). A criterion naming an unknown scorer is skipped with a console warning. --- ## Built-In Scorers | Scorer | Score | What it computes | |--------|-------|------------------| | `exact_match` | 0 or 1 | Equal after trimming and lower-casing | | `contains` | 0 or 1 | The answer contains the expected text, ignoring case | | `levenshtein` | 0–1 | 1 minus the character edit distance over the longer length, ignoring case | | `semantic_similarity` | 0–1 | Word overlap: shared words longer than 2 characters over the larger word set. It uses no embeddings | | `bleu` | 0–1 | Unigram precision against the best reference, with a brevity penalty | | `rouge` | 0–1 | ROUGE-L F1 over whitespace-separated words | | `word_error_rate` | 0–1 | 1 minus the word error rate, never below 0 (see [Evaluation Framework](./EVALUATION_FRAMEWORK.md#word-error-rate)) | Every built-in scorer returns 0 when there is nothing to compare against. `llm_judge` appears in the `BuiltInScorer` type but is not registered: register a judge scorer under that name (see [LLM-as-Judge](#llm-as-judge)). ```typescript const evaluator = new Evaluator(); const similarity = await evaluator.score('levenshtein', 'actual output', 'expected output'); const rougeL = await evaluator.score('rouge', generatedSummary, referenceSummary); ``` --- ## Custom Scorers A scorer is `(actual, expected, references, metadata) => number | Promise`, registered by name: ```typescript const evaluator = new Evaluator(); evaluator.registerScorer('json_valid', (actual) => { try { JSON.parse(actual); return 1; } catch { return 0; } }); // Options travel in the test case's metadata evaluator.registerScorer('json_has_key', (actual, _expected, _refs, metadata) => { try { return String(metadata?.key) in JSON.parse(actual) ? 1 : 0; } catch { return 0; } }); const testCase: EvalTestCase = { id: 'json-1', name: 'JSON output', input: 'Return user data as JSON: name=Alice, age=30.', metadata: { key: 'name' }, criteria: [ { name: 'valid_json', description: 'Parses as JSON', weight: 2, scorer: 'json_valid' }, { name: 'has_name', description: 'Has a name field', weight: 1, scorer: 'json_has_key' }, ], }; ``` --- ## LLM-as-Judge `LLMJudge` grades an answer with a model through an `AIModelProviderManager`: ```typescript import { LLMJudge, CRITERIA_PRESETS } from '@framers/agentos'; const judge = new LLMJudge({ llmProvider: providerManager, // an initialized AIModelProviderManager modelId: 'gpt-4o', // default: the judge resolver's model (openai gpt-5.6) }); const result = await judge.judge( 'How do I fix a memory leak in Node.js?', // input answer, // actual output undefined, // expected output (optional) [ { name: 'helpfulness', description: 'Actionable steps and the root cause', weight: 0.6 }, { name: 'safety', description: 'No harmful or wrong advice', weight: 0.4 }, ], ); console.log(result.score, result.criteriaScores, result.reasoning, result.feedback); ``` - Without criteria, the judge uses its default set; `CRITERIA_PRESETS` has `codeGeneration`, `summarization`, `questionAnswering`, `creativeWriting` and `safety`. - `temperature` defaults to 0.1, and the judge asks for a JSON answer. A provider error or an answer that is not JSON scores `errorScore` (default 0); JSON without `overallScore` scores 0.5. - `AGENTOS_JUDGE_PROVIDER`, `AGENTOS_JUDGE_MODEL` and `AGENTOS_JUDGE_EFFORT` change the default judge model when the config pins none. To use the judge inside an evaluation, register its scorer. The scorer reads the input from the test case's `metadata.input`: ```typescript evaluator.registerScorer('llm_judge', judge.createScorer(CRITERIA_PRESETS.questionAnswering)); const judged: EvalTestCase = { id: 'qa-1', name: 'Photosynthesis', input: 'What is photosynthesis?', metadata: { input: 'What is photosynthesis?' }, criteria: [{ name: 'quality', description: 'Judge score', weight: 1, scorer: 'llm_judge' }], }; ``` --- ## Running an Evaluation ```typescript import { Evaluator, agent } from '@framers/agentos'; const assistant = agent({ provider: 'openai', instructions: 'You are a helpful assistant.' }); let caseNumber = 0; async function agentFn(input: string): Promise { // A session per case keeps one case's history out of the next. const reply = await assistant.session(`eval-${++caseNumber}`).send(input); return reply.text; } const evaluator = new Evaluator(); const run = await evaluator.runEvaluation('assistant v1.2', testCases, agentFn, { concurrency: 5, // cases per batch (default 3) timeoutMs: 30_000, // per attempt (default 60000) retries: 1, // extra attempts after an error or a timeout (default 1) thresholds: { pass: 0.8 }, }); const m = run.aggregateMetrics; console.log(`${m.passedTests}/${m.totalTests} passed, average ${m.avgScore.toFixed(3)}, p95 ${m.p95LatencyMs} ms`); ``` - Cases run in batches of `concurrency`; a batch waits for its slowest case. - A timeout rejects the attempt; it does not cancel the agent call. - A case whose attempts all fail gets score 0 with `error` set, and the run goes on. - `totalTokens` and `totalCostUsd` stay 0: the agent function returns text only. `byCategory` is not filled. - `EvalConfig.continueOnError`, `thresholds.warn`, `customScorers` and a criterion's `threshold` are declared and not read. --- ## Comparing Two Agents ```typescript const fast = agent({ provider: 'openai', model: 'gpt-4o-mini' }); const strong = agent({ provider: 'openai', model: 'gpt-4o' }); let n = 0; const runA = await evaluator.runEvaluation('baseline', testCases, (input) => fast.session(`a-${++n}`).send(input).then((r) => r.text)); const runB = await evaluator.runEvaluation('challenger', testCases, (input) => strong.session(`b-${++n}`).send(input).then((r) => r.text)); const comparison = await evaluator.compareRuns(runA.runId, runB.runId); for (const metric of comparison.metrics) { console.log(metric.name, metric.run1Value, metric.run2Value, metric.delta); } console.log(comparison.summary); // { improved, regressed, unchanged } ``` `compareRuns()` compares four aggregates: `passRate`, `avgScore`, `avgLatencyMs` and `p95LatencyMs`. It marks a metric `improved` when the second run's value is higher, latency included, so read a latency `delta` above 0 as slower. It does not compare test cases one by one; for that, match `runA.results` and `runB.results` by `testCaseId`. `judge.compare(input, outputA, outputB, criteria)` judges two answers to one input and names the winner (`'A'`, `'B'`, or `'tie'` when the scores differ by less than 0.05). --- ## Keeping Runs The evaluator keeps runs in memory: `getRun(runId)`, `listRuns(limit)` (newest first, default 50), `compareRuns()` and `generateReport()` see the runs of that instance only. To keep runs across processes, store `generateReport(runId, 'json')`, which serializes the whole `EvalRun`. --- ## Reports ```typescript import { writeFile } from 'node:fs/promises'; const md = await evaluator.generateReport(run.runId, 'markdown'); // summary, latency, one line per case const html = await evaluator.generateReport(run.runId, 'html'); // the same as HTML tables await writeFile('./eval-report.html', html); const run2 = JSON.parse(await evaluator.generateReport(run.runId, 'json')); // the EvalRun console.log(run2.aggregateMetrics.passRate); ``` --- ## Related Guides - [EVALUATION_FRAMEWORK.md](./EVALUATION_FRAMEWORK.md) — types, scorers and the word error rate in detail - [GETTING_STARTED.md](../getting-started/GETTING_STARTED.md) — first steps with AgentOS - [EXAMPLES.md](../getting-started/EXAMPLES.md) — runnable recipes