first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,714 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { z } from 'zod';
import { AVAILABLE_MODELS, DEFAULT_MODEL, type ModelId } from '@/llm-config';
import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
import type { LangsmithExampleFilters } from '../harness/harness-types';
import { DEFAULTS } from '../support/constants';
import type { StageModels } from '../support/environment';
export type EvaluationSuite =
| 'llm-judge'
| 'pairwise'
| 'programmatic'
| 'similarity'
| 'introspection'
| 'binary-checks';
export type EvaluationBackend = 'local' | 'langsmith';
export type AgentType = 'multi-agent' | 'code-builder';
export type SubgraphName = 'responder' | 'discovery' | 'builder' | 'configurator';
export interface EvaluationArgs {
suite: EvaluationSuite;
backend: EvaluationBackend;
agent: AgentType;
verbose: boolean;
repetitions: number;
concurrency: number;
timeoutMs: number;
experimentName?: string;
outputDir?: string;
datasetName?: string;
maxExamples?: number;
filters?: LangsmithExampleFilters;
testCase?: string;
promptsCsv?: string;
prompt?: string;
dos?: string;
donts?: string;
numJudges: number;
featureFlags?: BuilderFeatureFlags;
/** Target a specific subgraph for evaluation (requires --dataset or --dataset-file) */
subgraph?: SubgraphName;
/** Path to a local JSON dataset file (alternative to --dataset for subgraph evals) */
datasetFile?: string;
/** Run full workflow generation from prompt instead of using pre-computed state */
regenerate?: boolean;
/** Write regenerated state back to the dataset source */
writeBack?: boolean;
/** URL to POST evaluation results to when complete */
webhookUrl?: string;
/** Secret for HMAC-SHA256 signature of webhook payload */
webhookSecret?: string;
/** CSV file path for evaluation results */
outputCsv?: string;
/** Comma-separated list of binary check names to run */
checks?: string[];
// Model configuration
/** Default model for all stages */
model: ModelId;
/** Model for LLM judge evaluation */
judgeModel?: ModelId;
/** Model for supervisor stage */
supervisorModel?: ModelId;
/** Model for responder stage */
responderModel?: ModelId;
/** Model for discovery stage */
discoveryModel?: ModelId;
/** Model for builder stage (structure and configuration) */
builderModel?: ModelId;
/** Model for parameter updater (within builder) */
parameterUpdaterModel?: ModelId;
}
type CliValueKind = 'boolean' | 'string';
type FlagGroup =
| 'input'
| 'eval'
| 'pairwise'
| 'langsmith'
| 'output'
| 'feature'
| 'model'
| 'advanced';
// Model ID validation schema
const modelIdSchema = z.enum(AVAILABLE_MODELS as [ModelId, ...ModelId[]]);
const cliSchema = z
.object({
suite: z
.enum([
'llm-judge',
'pairwise',
'programmatic',
'similarity',
'introspection',
'binary-checks',
])
.default('llm-judge'),
backend: z.enum(['local', 'langsmith']).default('local'),
agent: z.enum(['code-builder', 'multi-agent']).default('code-builder'),
verbose: z.boolean().default(false),
repetitions: z.coerce.number().int().positive().default(DEFAULTS.REPETITIONS),
concurrency: z.coerce.number().int().positive().default(DEFAULTS.CONCURRENCY),
timeoutMs: z.coerce.number().int().positive().default(DEFAULTS.TIMEOUT_MS),
experimentName: z.string().min(1).optional(),
outputDir: z.string().min(1).optional(),
outputCsv: z.string().min(1).optional(),
datasetName: z.string().min(1).optional(),
maxExamples: z.coerce.number().int().positive().optional(),
filter: z.array(z.string().min(1)).default([]),
notionId: z.string().min(1).optional(),
technique: z.string().min(1).optional(),
subgraph: z.enum(['responder', 'discovery', 'builder', 'configurator']).optional(),
datasetFile: z.string().min(1).optional(),
regenerate: z.boolean().default(false),
writeBack: z.boolean().default(false),
testCase: z.string().min(1).optional(),
promptsCsv: z.string().min(1).optional(),
prompt: z.string().min(1).optional(),
dos: z.string().min(1).optional(),
donts: z.string().min(1).optional(),
numJudges: z.coerce.number().int().positive().default(DEFAULTS.NUM_JUDGES),
checks: z.string().min(1).optional(),
langsmith: z.boolean().optional(),
templateExamples: z.boolean().default(false),
webhookUrl: z.string().url().optional(),
webhookSecret: z.string().min(16).optional(),
// Model configuration
model: modelIdSchema.default(DEFAULT_MODEL),
judgeModel: modelIdSchema.optional(),
supervisorModel: modelIdSchema.optional(),
responderModel: modelIdSchema.optional(),
discoveryModel: modelIdSchema.optional(),
builderModel: modelIdSchema.optional(),
parameterUpdaterModel: modelIdSchema.optional(),
})
.strict();
type CliKey = keyof z.infer<typeof cliSchema>;
type FlagDef = { key: CliKey; kind: CliValueKind; desc: string; group: FlagGroup };
const FLAG_DEFS: Record<string, FlagDef> = {
// Input sources
'--prompt': { key: 'prompt', kind: 'string', group: 'input', desc: 'Single prompt to evaluate' },
'--prompts-csv': {
key: 'promptsCsv',
kind: 'string',
group: 'input',
desc: 'CSV file with prompts',
},
'--test-case': {
key: 'testCase',
kind: 'string',
group: 'input',
desc: 'Run specific default test case by ID',
},
'--dataset': {
key: 'datasetName',
kind: 'string',
group: 'input',
desc: 'LangSmith dataset name',
},
'--subgraph': {
key: 'subgraph',
kind: 'string',
group: 'eval',
desc: 'Target subgraph (responder|discovery|builder|configurator). Requires --dataset or --dataset-file',
},
'--dataset-file': {
key: 'datasetFile',
kind: 'string',
group: 'input',
desc: 'Path to local JSON dataset file (for subgraph evals)',
},
'--regenerate': {
key: 'regenerate',
kind: 'boolean',
group: 'eval',
desc: 'Run full workflow generation from prompt instead of using pre-computed state',
},
'--write-back': {
key: 'writeBack',
kind: 'boolean',
group: 'eval',
desc: 'Write regenerated state back to dataset source (requires --regenerate)',
},
// Evaluation options
'--suite': {
key: 'suite',
kind: 'string',
group: 'eval',
desc: 'Evaluation suite (llm-judge|pairwise|programmatic|similarity|introspection|binary-checks)',
},
'--checks': {
key: 'checks',
kind: 'string',
group: 'eval',
desc: 'Comma-separated binary check names to run (binary-checks suite only)',
},
'--backend': { key: 'backend', kind: 'string', group: 'eval', desc: 'Backend (local|langsmith)' },
'--agent': {
key: 'agent',
kind: 'string',
group: 'eval',
desc: 'Agent type (code-builder|multi-agent)',
},
'--max-examples': {
key: 'maxExamples',
kind: 'string',
group: 'eval',
desc: 'Limit number of examples',
},
'--repetitions': {
key: 'repetitions',
kind: 'string',
group: 'eval',
desc: 'Repeat each example N times',
},
'--concurrency': {
key: 'concurrency',
kind: 'string',
group: 'eval',
desc: 'Max parallel evaluations',
},
'--timeout-ms': {
key: 'timeoutMs',
kind: 'string',
group: 'eval',
desc: 'Timeout per evaluation (ms)',
},
// Pairwise options
'--dos': {
key: 'dos',
kind: 'string',
group: 'pairwise',
desc: 'Requirements the workflow must satisfy',
},
'--donts': {
key: 'donts',
kind: 'string',
group: 'pairwise',
desc: 'Things the workflow must avoid',
},
// LangSmith options
'--langsmith': {
key: 'langsmith',
kind: 'boolean',
group: 'langsmith',
desc: 'Shorthand for --backend langsmith',
},
'--name': { key: 'experimentName', kind: 'string', group: 'langsmith', desc: 'Experiment name' },
'--filter': {
key: 'filter',
kind: 'string',
group: 'langsmith',
desc: 'Filter examples (key:value, repeatable)',
},
'--notion-id': {
key: 'notionId',
kind: 'string',
group: 'langsmith',
desc: 'Filter by Notion ID',
},
'--technique': {
key: 'technique',
kind: 'string',
group: 'langsmith',
desc: 'Filter by technique',
},
// Output
'--output-dir': {
key: 'outputDir',
kind: 'string',
group: 'output',
desc: 'Directory for artifacts',
},
'--output-csv': {
key: 'outputCsv',
kind: 'string',
group: 'output',
desc: 'CSV file for evaluation results - if pre-existing file found it will be overwritten',
},
'--verbose': { key: 'verbose', kind: 'boolean', group: 'output', desc: 'Verbose logging' },
'--webhook-url': {
key: 'webhookUrl',
kind: 'string',
group: 'output',
desc: 'URL to POST results to when complete',
},
'--webhook-secret': {
key: 'webhookSecret',
kind: 'string',
group: 'output',
desc: 'Secret for HMAC-SHA256 signature (min 16 chars)',
},
// Feature flags
'--template-examples': {
key: 'templateExamples',
kind: 'boolean',
group: 'feature',
desc: 'Enable template examples phase',
},
// Model configuration
'--model': {
key: 'model',
kind: 'string',
group: 'model',
desc: `Default model for all stages (default: ${DEFAULT_MODEL})`,
},
'--judge-model': {
key: 'judgeModel',
kind: 'string',
group: 'model',
desc: 'Model for LLM judge evaluation',
},
'--supervisor-model': {
key: 'supervisorModel',
kind: 'string',
group: 'model',
desc: 'Model for supervisor stage',
},
'--responder-model': {
key: 'responderModel',
kind: 'string',
group: 'model',
desc: 'Model for responder stage',
},
'--discovery-model': {
key: 'discoveryModel',
kind: 'string',
group: 'model',
desc: 'Model for discovery stage',
},
'--builder-model': {
key: 'builderModel',
kind: 'string',
group: 'model',
desc: 'Model for builder stage (structure and configuration)',
},
'--parameter-updater-model': {
key: 'parameterUpdaterModel',
kind: 'string',
group: 'model',
desc: 'Model for parameter updater',
},
// Advanced
'--judges': { key: 'numJudges', kind: 'string', group: 'advanced', desc: 'Number of LLM judges' },
};
// Aliases (not shown in help)
const FLAG_ALIASES: Record<string, string> = {
'--mode': '--suite',
'-v': '--verbose',
};
// Combined lookup for parsing
const FLAG_TO_KEY: Record<string, FlagDef> = {
...FLAG_DEFS,
...Object.fromEntries(
Object.entries(FLAG_ALIASES).map(([alias, target]) => [alias, FLAG_DEFS[target]]),
),
};
function formatValidFlags(): string {
return Object.keys(FLAG_TO_KEY)
.filter((f) => f.startsWith('--'))
.sort()
.join('\n ');
}
const GROUP_TITLES: Record<FlagGroup, string> = {
input: 'Input Sources',
eval: 'Evaluation Options',
pairwise: 'Pairwise Options',
langsmith: 'LangSmith Options',
output: 'Output',
feature: 'Feature Flags',
model: 'Model Configuration',
advanced: 'Advanced',
};
function formatHelp(): string {
const lines: string[] = [
'Usage: pnpm eval [options]',
'',
'Evaluation harness for AI Workflow Builder.',
'',
];
const groups: FlagGroup[] = [
'input',
'eval',
'pairwise',
'langsmith',
'output',
'feature',
'model',
'advanced',
];
for (const group of groups) {
const flags = Object.entries(FLAG_DEFS).filter(([, def]) => def.group === group);
if (flags.length === 0) continue;
lines.push(`${GROUP_TITLES[group]}:`);
for (const [flag, def] of flags) {
const valueHint = def.kind === 'string' ? ' <value>' : '';
const padded = ` ${flag}${valueHint}`.padEnd(28);
lines.push(`${padded}${def.desc}`);
}
lines.push('');
}
lines.push('Examples:');
lines.push(' pnpm eval --verbose');
lines.push(' pnpm eval --prompt "Create a Slack notification workflow"');
lines.push(' pnpm eval --prompts-csv my-prompts.csv --max-examples 5');
lines.push(' pnpm eval:langsmith --dataset "workflow-builder-canvas-prompts" --name "test-run"');
lines.push(
' pnpm eval:langsmith --subgraph responder --dataset "responder-eval-dataset" --verbose',
);
return lines.join('\n');
}
export function printHelp(): void {
console.log(formatHelp());
}
function ensureValue(argv: string[], i: number, flag: string): string {
const value = argv[i + 1];
if (value === undefined) throw new Error(`Flag ${flag} requires a value`);
return value;
}
function splitFlagToken(token: string): { flag: string; inlineValue?: string } {
if (!token.startsWith('--')) return { flag: token };
const equalsIndex = token.indexOf('=');
if (equalsIndex === -1) return { flag: token };
return { flag: token.slice(0, equalsIndex), inlineValue: token.slice(equalsIndex + 1) };
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v): v is string => typeof v === 'string');
}
function parseCli(argv: string[]): {
values: Partial<Record<CliKey, unknown>>;
seenKeys: Set<CliKey>;
} {
const values: Partial<Record<CliKey, unknown>> = {};
const seenKeys = new Set<CliKey>();
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (!token.startsWith('-')) continue;
const { flag, inlineValue } = splitFlagToken(token);
const def = FLAG_TO_KEY[flag];
if (!def) {
throw new Error(`Unknown flag: ${flag}\n\nValid flags:\n ${formatValidFlags()}`);
}
seenKeys.add(def.key);
if (def.kind === 'boolean') {
values[def.key] = true;
continue;
}
const value = inlineValue ?? ensureValue(argv, i, flag);
if (inlineValue === undefined) i++;
if (def.key === 'filter') {
const existing = values.filter;
values.filter = isStringArray(existing) ? [...existing, value] : [value];
continue;
}
values[def.key] = value;
}
return { values, seenKeys };
}
function parseFeatureFlags(args: {
templateExamples: boolean;
suite: EvaluationSuite;
}): BuilderFeatureFlags | undefined {
const templateExamplesFromEnv = process.env.EVAL_FEATURE_TEMPLATE_EXAMPLES === 'true';
const templateExamples = templateExamplesFromEnv || args.templateExamples;
// Auto-enable introspection for introspection suite
const enableIntrospection = args.suite === 'introspection';
if (!templateExamples && !enableIntrospection) return undefined;
return {
templateExamples: templateExamples || undefined,
enableIntrospection: enableIntrospection || undefined,
};
}
function parseFilters(args: {
filter: string[];
notionId?: string;
technique?: string;
}): LangsmithExampleFilters | undefined {
const filters: LangsmithExampleFilters = {};
for (const raw of args.filter) {
const match = raw.match(/^(\w+):(.+)$/);
if (!match) {
throw new Error('Invalid `--filter` format. Expected: --filter "key:value"');
}
const [, key, valueRaw] = match;
const value = valueRaw.trim();
if (value.length === 0) {
throw new Error(`Invalid \`--filter\` value for "${key}": value cannot be empty`);
}
switch (key) {
case 'do':
filters.doSearch = value;
break;
case 'dont':
filters.dontSearch = value;
break;
case 'technique':
filters.technique = value;
break;
case 'id':
filters.notionId = value;
break;
default:
throw new Error(`Unknown filter key "${key}". Expected one of: do, dont, technique, id`);
}
}
if (args.notionId && !filters.notionId) filters.notionId = args.notionId;
if (args.technique && !filters.technique) filters.technique = args.technique;
const hasAny = Object.values(filters).some((v) => typeof v === 'string' && v.length > 0);
return hasAny ? filters : undefined;
}
function validateSubgraphArgs(parsed: {
subgraph?: string;
datasetName?: string;
datasetFile?: string;
prompt?: string;
promptsCsv?: string;
testCase?: string;
writeBack: boolean;
regenerate: boolean;
}): void {
if (parsed.subgraph && !parsed.datasetName && !parsed.datasetFile) {
throw new Error(
'`--subgraph` requires `--dataset` or `--dataset-file`. Subgraph evaluation needs pre-computed state from a dataset.',
);
}
if (parsed.subgraph && (parsed.prompt || parsed.promptsCsv || parsed.testCase)) {
throw new Error(
'`--subgraph` cannot be combined with `--prompt`, `--prompts-csv`, or `--test-case`. Use `--dataset` instead.',
);
}
if (parsed.writeBack && !parsed.regenerate) {
throw new Error('`--write-back` requires `--regenerate`');
}
if (parsed.regenerate && !parsed.subgraph) {
throw new Error('`--regenerate` requires `--subgraph`');
}
}
export function parseEvaluationArgs(argv: string[] = process.argv.slice(2)): EvaluationArgs {
// Check for help flag before parsing
if (argv.includes('--help') || argv.includes('-h')) {
printHelp();
process.exit(0);
}
const { values, seenKeys } = parseCli(argv);
if (values.langsmith === true) {
const backendWasExplicit = seenKeys.has('backend');
if (backendWasExplicit && values.backend !== 'langsmith') {
throw new Error('Cannot combine `--langsmith` with `--backend local`');
}
values.backend = 'langsmith';
}
const parsed = cliSchema.parse(values);
const featureFlags = parseFeatureFlags({
templateExamples: parsed.templateExamples,
suite: parsed.suite,
});
const filters = parseFilters({
filter: parsed.filter,
notionId: parsed.notionId,
technique: parsed.technique,
});
validateSubgraphArgs(parsed);
if (parsed.suite !== 'pairwise' && (filters?.doSearch || filters?.dontSearch)) {
throw new Error(
'`--filter do:` and `--filter dont:` are only supported for `--suite pairwise`',
);
}
if (parsed.checks && parsed.suite !== 'binary-checks') {
throw new Error('`--checks` is only supported for `--suite binary-checks`');
}
return {
suite: parsed.suite,
backend: parsed.backend,
agent: parsed.agent,
verbose: parsed.verbose,
repetitions: parsed.repetitions,
concurrency: parsed.concurrency,
timeoutMs: parsed.timeoutMs,
experimentName: parsed.experimentName,
outputDir: parsed.outputDir,
outputCsv: parsed.outputCsv,
datasetName: parsed.datasetName,
maxExamples: parsed.maxExamples,
filters,
testCase: parsed.testCase,
promptsCsv: parsed.promptsCsv,
prompt: parsed.prompt,
dos: parsed.dos,
donts: parsed.donts,
numJudges: parsed.numJudges,
featureFlags,
subgraph: parsed.subgraph,
datasetFile: parsed.datasetFile,
regenerate: parsed.regenerate,
writeBack: parsed.writeBack,
webhookUrl: parsed.webhookUrl,
webhookSecret: parsed.webhookSecret,
checks: parsed.checks?.split(',').map((s) => s.trim()),
// Model configuration
model: parsed.model,
judgeModel: parsed.judgeModel,
supervisorModel: parsed.supervisorModel,
responderModel: parsed.responderModel,
discoveryModel: parsed.discoveryModel,
builderModel: parsed.builderModel,
parameterUpdaterModel: parsed.parameterUpdaterModel,
};
}
/**
* Converts EvaluationArgs to StageModels for use with environment setup.
*/
export function argsToStageModels(args: EvaluationArgs): StageModels {
return {
default: args.model,
supervisor: args.supervisorModel,
responder: args.responderModel,
discovery: args.discoveryModel,
builder: args.builderModel,
parameterUpdater: args.parameterUpdaterModel,
judge: args.judgeModel,
};
}
export function getDefaultExperimentName(suite: EvaluationSuite): string {
return suite === 'pairwise' ? DEFAULTS.EXPERIMENT_NAME : DEFAULTS.LLM_JUDGE_EXPERIMENT_NAME;
}
export function getDefaultDatasetName(suite: EvaluationSuite): string {
if (suite === 'pairwise') return DEFAULTS.DATASET_NAME;
return process.env.LANGSMITH_DATASET_NAME ?? 'workflow-builder-canvas-prompts';
}
@@ -0,0 +1,38 @@
/**
* CI Metadata for LangSmith experiments.
*
* Provides metadata to distinguish CI runs from local development runs
* and to track provenance of automated evaluation results.
*
* Note: Git info (commit SHA, branch) is not included here as LangSmith
* automatically tracks it via LANGSMITH_REVISION_ID and LANGSMITH_BRANCH.
*/
export interface CIMetadata {
/** Whether this run is from CI or local development */
source: 'ci' | 'local';
/** The GitHub Actions event that triggered this run (e.g., 'push', 'release', 'workflow_dispatch') */
trigger?: string;
/** The GitHub Actions run ID for linking back to the workflow run */
runId?: string;
}
/**
* Build CI metadata from environment variables.
*
* When running in GitHub Actions, populates metadata from standard GH environment variables.
* When running locally, only sets source to 'local'.
*/
export function buildCIMetadata(): CIMetadata {
const isCI = process.env.GITHUB_ACTIONS === 'true';
if (!isCI) {
return { source: 'local' };
}
return {
source: 'ci',
trigger: process.env.GITHUB_EVENT_NAME,
runId: process.env.GITHUB_RUN_ID,
};
}
@@ -0,0 +1,155 @@
import { parse } from 'csv-parse/sync';
import { existsSync, readFileSync } from 'node:fs';
import { join, isAbsolute, resolve } from 'node:path';
import type { TestCase } from '../harness/harness-types';
/** Path to the default prompts CSV fixture */
const DEFAULT_PROMPTS_PATH = join(__dirname, '..', 'fixtures', 'default-prompts.csv');
type ParsedCsvRow = string[];
function isHeaderRow(row: ParsedCsvRow) {
return row.some((cell) => cell.trim().toLowerCase() === 'prompt');
}
function detectColumnIndex(header: ParsedCsvRow, name: string) {
const normalized = name.toLowerCase();
const index = header.findIndex((cell) => cell.trim().toLowerCase() === normalized);
return index >= 0 ? index : undefined;
}
function sanitizeValue(value: string | undefined) {
return value?.trim() ?? '';
}
function parseCsv(content: string): ParsedCsvRow[] {
try {
const rows = parse(content.replace(/^\ufeff/, ''), {
columns: false,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
}) as ParsedCsvRow[];
return rows.map((row) => row.map((cell) => cell ?? ''));
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown parsing error';
throw new Error(`Failed to parse CSV file: ${message}`);
}
}
export function loadTestCasesFromCsv(csvPath: string): TestCase[] {
const resolvedPath = isAbsolute(csvPath) ? csvPath : resolve(process.cwd(), csvPath);
if (!existsSync(resolvedPath)) {
throw new Error(`CSV file not found at ${resolvedPath}`);
}
const rows = parseCsv(readFileSync(resolvedPath, 'utf8'));
if (rows.length === 0) {
throw new Error('The provided CSV file is empty');
}
const hasHeader = isHeaderRow(rows[0]);
const header = hasHeader ? rows[0] : undefined;
const dataRows = hasHeader ? rows.slice(1) : rows;
if (dataRows.length === 0) {
throw new Error('No prompt rows found in the provided CSV file');
}
// Find column index by name(s), returns undefined if no header
const findColumn = (...names: string[]): number | undefined => {
if (!header) return undefined;
for (const name of names) {
const idx = detectColumnIndex(header, name);
if (idx !== undefined) return idx;
}
return undefined;
};
const promptIdx = findColumn('prompt') ?? 0;
const idIdx = findColumn('id');
const dosIdx = findColumn('dos', 'do');
const dontsIdx = findColumn('donts', 'dont');
const annotationsIdx = findColumn('annotations');
const getCell = (row: ParsedCsvRow, idx: number | undefined): string =>
idx !== undefined ? sanitizeValue(row[idx]) : '';
const parseAnnotations = (raw: string, rowIndex: number): Record<string, unknown> | undefined => {
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
console.warn(
`Warning: invalid JSON in annotations column for row ${rowIndex + (hasHeader ? 1 : 0)}, skipping`,
);
}
return undefined;
};
const testCases: TestCase[] = [];
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
const prompt = getCell(row, promptIdx);
if (!prompt) continue;
const dos = getCell(row, dosIdx);
const donts = getCell(row, dontsIdx);
const testCase: TestCase = {
id: getCell(row, idIdx) || `csv-case-${i + 1}`,
prompt,
};
const annotationsRaw = getCell(row, annotationsIdx);
if (dos || donts || annotationsRaw) {
testCase.context = {};
if (dos) testCase.context.dos = dos;
if (donts) testCase.context.donts = donts;
if (annotationsRaw) {
const annotations = parseAnnotations(annotationsRaw, i + 1);
if (annotations) {
testCase.context.annotations = annotations;
}
}
}
testCases.push(testCase);
}
if (testCases.length === 0) {
throw new Error('No valid prompts found in the provided CSV file');
}
return testCases;
}
/** Cached default test cases */
let cachedDefaultTestCases: TestCase[] | null = null;
/**
* Load the default test cases from the bundled CSV fixture.
* Results are cached after first load.
*/
export function loadDefaultTestCases(): TestCase[] {
cachedDefaultTestCases ??= loadTestCasesFromCsv(DEFAULT_PROMPTS_PATH);
return cachedDefaultTestCases;
}
/**
* Get available test case IDs from the default fixture.
*/
export function getDefaultTestCaseIds(): string[] {
return loadDefaultTestCases()
.map((tc) => tc.id)
.filter((id): id is string => id !== undefined);
}
@@ -0,0 +1,116 @@
import * as fs from 'fs';
import { jsonParse } from 'n8n-workflow';
import * as path from 'path';
import type { CoordinationLogEntry } from '@/types/coordination';
import type { SimpleWorkflow } from '@/types/workflow';
import type { SerializedMessage } from '../harness/workflow-regenerator';
interface DatasetExample {
prompt: string;
messages: unknown[];
coordinationLog: unknown[];
workflowJSON: Record<string, unknown>;
responderEvals?: { type: string; criteria: string };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function validateExample(raw: unknown, index: number): DatasetExample {
if (!isRecord(raw)) {
throw new Error(`Dataset example at index ${index} is not an object`);
}
if (typeof raw.prompt !== 'string' || raw.prompt.length === 0) {
throw new Error(`Dataset example at index ${index} missing required "prompt" string`);
}
if (!Array.isArray(raw.messages)) {
throw new Error(`Dataset example at index ${index} missing required "messages" array`);
}
if (!Array.isArray(raw.coordinationLog)) {
throw new Error(`Dataset example at index ${index} missing required "coordinationLog" array`);
}
if (!isRecord(raw.workflowJSON)) {
throw new Error(`Dataset example at index ${index} missing required "workflowJSON" object`);
}
return raw as unknown as DatasetExample;
}
/**
* Load and validate a local JSON dataset file for subgraph evaluation.
* Returns parsed examples with their inputs structured for the subgraph runner.
*/
export function loadSubgraphDatasetFile(
filePath: string,
): Array<{ inputs: Record<string, unknown> }> {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`Dataset file not found: ${resolved}`);
}
const content = fs.readFileSync(resolved, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error(`Failed to parse dataset file as JSON: ${resolved}`);
}
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error(`Dataset file must contain a non-empty JSON array: ${resolved}`);
}
return parsed.map((raw, index) => {
const example = validateExample(raw, index);
return {
inputs: {
prompt: example.prompt,
messages: example.messages,
coordinationLog: example.coordinationLog,
workflowJSON: example.workflowJSON,
...(example.responderEvals ? { responderEvals: example.responderEvals } : {}),
},
};
});
}
/** Entry for write-back operations */
export interface DatasetWriteBackEntry {
index: number;
messages: SerializedMessage[];
coordinationLog: CoordinationLogEntry[];
workflowJSON: SimpleWorkflow;
}
/**
* Write regenerated state back to the dataset file.
* Preserves existing fields (prompt, responderEvals) and updates state fields.
*/
export function writeBackToDatasetFile(filePath: string, updates: DatasetWriteBackEntry[]): void {
const resolved = path.resolve(filePath);
const content = fs.readFileSync(resolved, 'utf-8');
const dataset = jsonParse<unknown[]>(content);
for (const update of updates) {
if (update.index < 0 || update.index >= dataset.length) {
throw new Error(
`Write-back index ${update.index} is out of bounds (dataset has ${dataset.length} examples)`,
);
}
const example = dataset[update.index] as Record<string, unknown>;
example.messages = update.messages;
example.coordinationLog = update.coordinationLog;
example.workflowJSON = update.workflowJSON;
}
fs.writeFileSync(resolved, JSON.stringify(dataset, null, '\t') + '\n', 'utf-8');
}
@@ -0,0 +1,621 @@
/**
* V2 CLI Entry Point
*
* Demonstrates how to use the v2 evaluation harness.
* Can be run directly or used as a reference for custom setups.
*/
import type { INodeTypeDescription } from 'n8n-workflow';
import pLimit from 'p-limit';
import { CodeWorkflowBuilder } from '@/code-builder';
import type { CoordinationLogEntry } from '@/types/coordination';
import type { StreamChunk, WorkflowUpdateChunk } from '@/types/streaming';
import type { SimpleWorkflow } from '@/types/workflow';
import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
/** Type guard for SimpleWorkflow */
function isSimpleWorkflow(value: unknown): value is SimpleWorkflow {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
'nodes' in value &&
'connections' in value
);
}
import {
argsToStageModels,
getDefaultDatasetName,
getDefaultExperimentName,
parseEvaluationArgs,
type EvaluationArgs,
} from './argument-parser';
import { buildCIMetadata } from './ci-metadata';
import {
loadTestCasesFromCsv,
loadDefaultTestCases,
getDefaultTestCaseIds,
} from './csv-prompt-loader';
import { loadSubgraphDatasetFile } from './dataset-file-loader';
import { sendWebhookNotification } from './webhook';
import { WorkflowGenerationError } from '../errors';
import {
consumeGenerator,
extractSubgraphMetrics,
getChatPayload,
} from '../harness/evaluation-helpers';
import { createLogger } from '../harness/logger';
import type { GenerationCollectors, SubgraphMetricsCollector } from '../harness/runner';
import { TokenUsageTrackingHandler } from '../harness/token-tracking-handler';
import {
runEvaluation,
createConsoleLifecycle,
mergeLifecycles,
createLLMJudgeEvaluator,
createProgrammaticEvaluator,
createPairwiseEvaluator,
createSimilarityEvaluator,
createExecutionEvaluator,
createBinaryChecksEvaluator,
type RunConfig,
type TestCase,
type Evaluator,
type EvaluationContext,
type GenerationResult,
createSubgraphRunner,
createResponderEvaluator,
type EvaluationLifecycle,
runLocalSubgraphEvaluation,
runSubgraphEvaluation,
} from '../index';
import { generateRunId, isWorkflowStateValues } from '../langsmith/types';
import { createIntrospectionAnalysisLifecycle } from '../lifecycles/introspection-analysis';
import { AGENT_TYPES, EVAL_TYPES, EVAL_USERS } from '../support/constants';
import {
setupTestEnvironment,
createAgent,
resolveNodesBasePath,
type ResolvedStageLLMs,
type TestEnvironment,
} from '../support/environment';
import { generateEvalPinData } from '../support/pin-data-generator';
/**
* Type guard for workflow update chunks from streaming output.
*/
function isWorkflowUpdateChunk(chunk: StreamChunk): chunk is WorkflowUpdateChunk {
return chunk.type === 'workflow-updated';
}
/**
* Type guard to check if state values contain a coordination log.
*/
function hasCoordinationLog(
values: unknown,
): values is { coordinationLog: CoordinationLogEntry[] } {
if (!values || typeof values !== 'object') return false;
const obj = values as Record<string, unknown>;
return Array.isArray(obj.coordinationLog);
}
/**
* Report subgraph metrics from coordination log and workflow.
*/
function reportSubgraphMetrics(
collector: SubgraphMetricsCollector,
stateValues: unknown,
workflow: SimpleWorkflow,
): void {
const coordinationLog = hasCoordinationLog(stateValues) ? stateValues.coordinationLog : undefined;
const nodeCount = workflow.nodes?.length;
const metrics = extractSubgraphMetrics(coordinationLog, nodeCount);
if (
metrics.discoveryDurationMs !== undefined ||
metrics.builderDurationMs !== undefined ||
metrics.responderDurationMs !== undefined ||
metrics.nodeCount !== undefined
) {
collector(metrics);
}
}
/**
* Create a workflow generator function for the multi-agent system.
* LangSmith tracing is handled via traceable() in the runner.
* Callbacks are passed explicitly from the runner to ensure correct trace context
* under high concurrency (avoids AsyncLocalStorage race conditions).
*/
function createWorkflowGenerator(
parsedNodeTypes: INodeTypeDescription[],
llms: ResolvedStageLLMs,
featureFlags?: BuilderFeatureFlags,
): (prompt: string, collectors?: GenerationCollectors) => Promise<SimpleWorkflow> {
return async (prompt: string, collectors?: GenerationCollectors): Promise<SimpleWorkflow> => {
const runId = generateRunId();
const agent = createAgent({
parsedNodeTypes,
llms,
featureFlags,
});
// Create token tracking handler to capture usage from all LLM calls
// (supervisor, discovery, builder, responder agents)
const tokenTracker = collectors?.tokenUsage ? new TokenUsageTrackingHandler() : undefined;
await consumeGenerator(
agent.chat(
getChatPayload({
evalType: EVAL_TYPES.LANGSMITH,
message: prompt,
workflowId: runId,
featureFlags,
}),
EVAL_USERS.LANGSMITH,
undefined, // abortSignal
tokenTracker ? [tokenTracker] : undefined, // externalCallbacks
),
);
const state = await agent.getState(runId, EVAL_USERS.LANGSMITH);
if (!state.values || !isWorkflowStateValues(state.values)) {
throw new Error('Invalid workflow state: workflow or messages missing');
}
const workflow = state.values.workflowJSON;
// Report accumulated token usage from all agents
if (collectors?.tokenUsage && tokenTracker) {
const usage = tokenTracker.getUsage();
if (usage.inputTokens > 0 || usage.outputTokens > 0) {
collectors.tokenUsage(usage);
}
}
// Extract and report subgraph metrics from coordination log
if (collectors?.subgraphMetrics) {
reportSubgraphMetrics(collectors.subgraphMetrics, state.values, workflow);
}
// Report introspection events
collectors?.introspectionEvents?.(state.values.introspectionEvents ?? []);
return workflow;
};
}
/**
* Create evaluators based on suite type.
*/
function createEvaluators(params: {
suite: string;
judgeLlm: ResolvedStageLLMs['judge'];
parsedNodeTypes: Parameters<typeof createProgrammaticEvaluator>[0];
numJudges: number;
checks?: string[];
}): Array<Evaluator<EvaluationContext>> {
const { suite, judgeLlm, parsedNodeTypes, numJudges, checks } = params;
const evaluators: Array<Evaluator<EvaluationContext>> = [];
switch (suite) {
case 'llm-judge':
evaluators.push(createLLMJudgeEvaluator(judgeLlm, parsedNodeTypes));
evaluators.push(createProgrammaticEvaluator(parsedNodeTypes));
break;
case 'pairwise':
evaluators.push(createPairwiseEvaluator(judgeLlm, { numJudges }));
evaluators.push(createProgrammaticEvaluator(parsedNodeTypes));
break;
case 'programmatic':
evaluators.push(createProgrammaticEvaluator(parsedNodeTypes));
break;
case 'similarity':
evaluators.push(createSimilarityEvaluator());
break;
case 'binary-checks':
evaluators.push(
createBinaryChecksEvaluator({
nodeTypes: parsedNodeTypes,
llm: judgeLlm,
checks,
}),
);
break;
}
return evaluators;
}
/**
* Create a CodeWorkflowBuilder generator function.
* Uses the CodeWorkflowBuilder which coordinates planning and coding agents to generate
* workflows via TypeScript SDK code and emits workflow JSON directly in the stream.
* Returns GenerationResult including the source code for artifact saving.
*
* @param timeoutMs - Optional timeout in milliseconds. When provided, the agent will be
* aborted if it exceeds this duration. This ensures the generator
* actually stops instead of continuing to run after timeout rejection.
*/
function createCodeWorkflowBuilderGenerator(
parsedNodeTypes: INodeTypeDescription[],
llms: ResolvedStageLLMs,
timeoutMs?: number,
nodeDefinitionDirs?: string[],
): (prompt: string, collectors?: GenerationCollectors) => Promise<GenerationResult> {
// Subgraph metrics are not applicable since CodeWorkflowBuilder doesn't use coordination logs.
return async (prompt: string, collectors?: GenerationCollectors): Promise<GenerationResult> => {
const runId = generateRunId();
// Accumulate token usage across all LLM calls
let totalInputTokens = 0;
let totalOutputTokens = 0;
const builder = new CodeWorkflowBuilder({
llm: llms.builder,
nodeTypes: parsedNodeTypes,
nodeDefinitionDirs,
onTokenUsage: collectors?.tokenUsage
? (usage) => {
totalInputTokens += usage.inputTokens;
totalOutputTokens += usage.outputTokens;
}
: undefined,
});
const payload = getChatPayload({
evalType: EVAL_TYPES.LANGSMITH,
message: prompt,
workflowId: runId,
featureFlags: { codeBuilder: true },
});
let workflow: SimpleWorkflow | null = null;
let generatedCode: string | undefined;
// Create an AbortController to properly cancel the agent on timeout or error.
// Without this, the agent continues running even after Promise.race rejects,
// causing the full timeout duration to elapse before the error surfaces.
const abortController = new AbortController();
let timeoutId: NodeJS.Timeout | undefined;
if (timeoutMs !== undefined && timeoutMs > 0) {
timeoutId = setTimeout(() => {
abortController.abort(new Error(`CodeWorkflowBuilder timed out after ${timeoutMs}ms`));
}, timeoutMs);
}
try {
for await (const output of builder.chat(
payload,
EVAL_USERS.LANGSMITH,
abortController.signal,
)) {
for (const message of output.messages) {
if (isWorkflowUpdateChunk(message)) {
const parsed: unknown = JSON.parse(message.codeSnippet);
if (isSimpleWorkflow(parsed)) {
workflow = parsed;
generatedCode = message.sourceCode;
}
}
}
}
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
if (!workflow) {
throw new WorkflowGenerationError('CodeWorkflowBuilder did not produce a workflow');
}
// Report accumulated token usage
if (collectors?.tokenUsage && (totalInputTokens > 0 || totalOutputTokens > 0)) {
collectors.tokenUsage({ inputTokens: totalInputTokens, outputTokens: totalOutputTokens });
}
return { workflow, generatedCode };
};
}
/**
* Load test cases from various sources.
*/
function loadTestCases(args: ReturnType<typeof parseEvaluationArgs>): TestCase[] {
// From CSV file
if (args.promptsCsv) {
const testCases = loadTestCasesFromCsv(args.promptsCsv);
return args.maxExamples ? testCases.slice(0, args.maxExamples) : testCases;
}
// Predefined test case by id
if (args.testCase) {
const defaultCases = loadDefaultTestCases();
const match = defaultCases.find((tc) => tc.id === args.testCase);
if (!match) {
const options = getDefaultTestCaseIds().join(', ');
throw new Error(`Unknown --test-case "${args.testCase}". Available: ${options}`);
}
const testCases: TestCase[] = [
{
prompt: match.prompt,
id: match.id,
context: { dos: args.dos, donts: args.donts },
},
];
return args.maxExamples ? testCases.slice(0, args.maxExamples) : testCases;
}
// Single prompt from CLI
if (args.prompt) {
const testCases: TestCase[] = [
{
prompt: args.prompt,
context: {
dos: args.dos,
donts: args.donts,
},
},
];
return args.maxExamples ? testCases.slice(0, args.maxExamples) : testCases;
}
// Default: use bundled test cases
const defaultCases = loadDefaultTestCases();
return args.maxExamples ? defaultCases.slice(0, args.maxExamples) : defaultCases;
}
/**
* Handle subgraph evaluation mode (--subgraph flag).
* Supports both local dataset files and LangSmith datasets.
*/
async function handleSubgraphMode(
args: EvaluationArgs,
env: TestEnvironment,
lifecycle: EvaluationLifecycle,
logger: ReturnType<typeof createLogger>,
): Promise<void> {
const { subgraph } = args;
if (!subgraph) throw new Error('subgraph is required');
const subgraphRunner = createSubgraphRunner({
subgraph,
llms: env.llms,
});
const evaluators: Array<Evaluator<EvaluationContext>> = [];
if (subgraph === 'responder') {
evaluators.push(createResponderEvaluator(env.llms.judge, { numJudges: args.numJudges }));
} else {
logger.warn(`Subgraph evaluation not supported for ${subgraph}`);
}
let summary: Awaited<ReturnType<typeof runSubgraphEvaluation>>;
if (args.datasetFile) {
const examples = loadSubgraphDatasetFile(args.datasetFile);
const slicedExamples = args.maxExamples ? examples.slice(0, args.maxExamples) : examples;
summary = await runLocalSubgraphEvaluation({
subgraph,
subgraphRunner,
evaluators,
examples: slicedExamples,
concurrency: args.concurrency,
lifecycle,
logger,
outputDir: args.outputDir,
timeoutMs: args.timeoutMs,
regenerate: args.regenerate,
writeBack: args.writeBack,
datasetFilePath: args.datasetFile,
llms: args.regenerate ? env.llms : undefined,
parsedNodeTypes: args.regenerate ? env.parsedNodeTypes : undefined,
});
} else {
if (!args.datasetName) {
throw new Error('`--subgraph` requires `--dataset` or `--dataset-file`');
}
if (!env.lsClient) {
throw new Error('LangSmith client not initialized - check LANGSMITH_API_KEY');
}
summary = await runSubgraphEvaluation({
subgraph,
subgraphRunner,
evaluators,
datasetName: args.datasetName,
langsmithClient: env.lsClient,
langsmithOptions: {
experimentName: args.experimentName ?? `${subgraph}-eval`,
repetitions: args.repetitions,
concurrency: args.concurrency,
maxExamples: args.maxExamples,
filters: args.filters,
experimentMetadata: {
...buildCIMetadata(),
subgraph,
},
},
lifecycle,
logger,
outputDir: args.outputDir,
timeoutMs: args.timeoutMs,
regenerate: args.regenerate,
writeBack: args.writeBack,
llms: args.regenerate ? env.llms : undefined,
parsedNodeTypes: args.regenerate ? env.parsedNodeTypes : undefined,
});
}
if (args.webhookUrl) {
await sendWebhookNotification({
webhookUrl: args.webhookUrl,
webhookSecret: args.webhookSecret,
summary,
dataset: args.datasetFile ?? args.datasetName ?? 'local-dataset',
suite: args.suite,
metadata: { ...buildCIMetadata(), subgraph },
logger,
});
}
}
/**
* Main entry point for v2 evaluation CLI.
*/
export async function runV2Evaluation(): Promise<void> {
const args = parseEvaluationArgs();
if (args.backend === 'langsmith' && (args.prompt || args.promptsCsv || args.testCase)) {
throw new Error(
'LangSmith mode requires `--dataset` and does not support `--prompt`, `--prompts-csv`, or `--test-case`',
);
}
// Setup environment with per-stage model configuration
const logger = createLogger(args.verbose);
const lifecycle = createConsoleLifecycle({ verbose: args.verbose, logger });
const stageModels = argsToStageModels(args);
const env = await setupTestEnvironment(stageModels, logger);
// Validate LangSmith client early if langsmith backend is requested
if (args.backend === 'langsmith' && !env.lsClient) {
throw new Error('LangSmith client not initialized - check LANGSMITH_API_KEY');
}
// Subgraph evaluation mode
if (args.subgraph) {
await handleSubgraphMode(args, env, lifecycle, logger);
process.exit(0);
}
// Create workflow generator based on agent type
const generateWorkflow =
args.agent === AGENT_TYPES.CODE_BUILDER
? createCodeWorkflowBuilderGenerator(
env.parsedNodeTypes,
env.llms,
args.timeoutMs,
env.nodeDefinitionDirs,
)
: createWorkflowGenerator(env.parsedNodeTypes, env.llms, args.featureFlags);
// Create evaluators based on suite type
const evaluators = createEvaluators({
suite: args.suite,
judgeLlm: env.llms.judge,
parsedNodeTypes: env.parsedNodeTypes,
numJudges: args.numJudges,
checks: args.checks,
});
// Execution evaluator runs for all suites — validates workflows execute with pin data
evaluators.push(createExecutionEvaluator());
const llmCallLimiter = pLimit(args.concurrency);
// Merge console lifecycle with optional introspection analysis lifecycle
const mergedLifecycle = mergeLifecycles(
createConsoleLifecycle({ verbose: args.verbose, logger }),
args.suite === 'introspection'
? createIntrospectionAnalysisLifecycle({
judgeLlm: env.llms.judge,
outputDir: args.outputDir,
logger,
})
: undefined,
);
// Create pin data generator for mocking service node outputs in evaluations
const nodesBasePath = resolveNodesBasePath();
const pinDataGenerator = async (workflow: SimpleWorkflow) =>
await generateEvalPinData(workflow, {
llm: env.llms.judge,
nodeTypes: env.parsedNodeTypes,
nodesBasePath,
logger,
});
const baseConfig = {
generateWorkflow,
evaluators,
lifecycle: mergedLifecycle,
logger,
outputDir: args.outputDir,
outputCsv: args.outputCsv,
suite: args.suite,
timeoutMs: args.timeoutMs,
context: { llmCallLimiter },
passThreshold: args.suite === 'introspection' || args.suite === 'binary-checks' ? 0 : undefined,
pinDataGenerator,
};
const config: RunConfig =
args.backend === 'langsmith'
? {
...baseConfig,
mode: 'langsmith',
dataset: args.datasetName ?? getDefaultDatasetName(args.suite),
langsmithClient: env.lsClient!,
langsmithOptions: {
experimentName: args.experimentName ?? getDefaultExperimentName(args.suite),
repetitions: args.repetitions,
concurrency: args.concurrency,
maxExamples: args.maxExamples,
filters: args.filters,
experimentMetadata: {
...buildCIMetadata(),
...(args.suite === 'pairwise' && {
numJudges: args.numJudges,
scoringMethod: 'hierarchical',
}),
},
},
}
: {
...baseConfig,
mode: 'local',
dataset: loadTestCases(args),
concurrency: args.concurrency,
};
// Run evaluation
const summary = await runEvaluation(config);
if (args.webhookUrl) {
const dataset =
args.backend === 'langsmith'
? (args.datasetName ?? getDefaultDatasetName(args.suite))
: 'local-dataset';
await sendWebhookNotification({
webhookUrl: args.webhookUrl,
webhookSecret: args.webhookSecret,
summary,
dataset,
suite: args.suite,
metadata: { ...buildCIMetadata() },
logger,
});
}
// Always exit 0 on successful completion - pass/fail is informational, not an error
process.exit(0);
}
// Run if called directly
if (require.main === module) {
runV2Evaluation().catch((error) => {
const logger = createLogger(true);
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
logger.error(`Evaluation failed: ${message}`);
process.exit(1);
});
}
@@ -0,0 +1,263 @@
/**
* Webhook utilities for sending evaluation results.
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
import dns from 'node:dns/promises';
import type { RunSummary } from '../harness/harness-types';
import type { EvalLogger } from '../harness/logger';
/**
* Mask a webhook URL for safe logging (hide potential tokens in path/query).
*/
function maskWebhookUrl(webhookUrl: string): string {
const url = new URL(webhookUrl);
return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}/***`;
}
/**
* Generate HMAC-SHA256 signature for webhook payload.
* Format: sha256=<hex-encoded-signature>
*/
export function generateWebhookSignature(payload: string, secret: string): string {
const signature = createHmac('sha256', secret).update(payload, 'utf8').digest('hex');
return `sha256=${signature}`;
}
/**
* Verify HMAC-SHA256 signature of a webhook payload.
* Uses timing-safe comparison to prevent timing attacks.
*/
export function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const expectedSignature = generateWebhookSignature(payload, secret);
// Ensure both signatures have same length before comparison
if (signature.length !== expectedSignature.length) {
return false;
}
return timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
}
/**
* Webhook payload sent after evaluation completes.
*/
export interface WebhookPayload {
suite: string;
summary: {
totalExamples: number;
passed: number;
failed: number;
errors: number;
averageScore: number;
};
evaluatorAverages?: Record<string, number>;
totalDurationMs: number;
metadata: Record<string, unknown>;
/** LangSmith IDs for constructing comparison URLs (only available in langsmith mode) */
langsmith?: {
experimentName: string;
experimentId: string;
datasetId: string;
datasetName: string;
};
}
/**
* Check if an IP address is private/internal.
*/
function isPrivateIp(ip: string): boolean {
const ipv4PrivatePatterns = [
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // 127.0.0.0/8 (loopback)
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // 10.0.0.0/8
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, // 172.16.0.0/12
/^192\.168\.\d{1,3}\.\d{1,3}$/, // 192.168.0.0/16
/^169\.254\.\d{1,3}\.\d{1,3}$/, // 169.254.0.0/16 (link-local)
/^0\.0\.0\.0$/, // 0.0.0.0
];
for (const pattern of ipv4PrivatePatterns) {
if (pattern.test(ip)) {
return true;
}
}
const ipLower = ip.toLowerCase();
if (
ipLower === '::1' || // loopback
ipLower.startsWith('fe80:') || // link-local
ipLower.startsWith('fc') || // unique local (fc00::/7)
ipLower.startsWith('fd') // unique local (fc00::/7)
) {
return true;
}
return false;
}
/**
* Validate webhook URL for security (hostname-based checks only).
* - Must be HTTPS
* - Must not target localhost or private/internal IP addresses (SSRF prevention)
*
* Note: This performs synchronous hostname string validation.
* For full SSRF protection, use validateWebhookUrlWithDns() which also resolves DNS.
*/
export function validateWebhookUrl(webhookUrl: string): void {
const url = new URL(webhookUrl);
if (url.protocol !== 'https:') {
throw new Error(`Webhook URL must use HTTPS. Got: ${url.protocol}`);
}
const hostname = url.hostname.toLowerCase();
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '[::1]'
) {
throw new Error('Webhook URL cannot target localhost');
}
if (isPrivateIp(hostname)) {
throw new Error('Webhook URL cannot target private/internal IP addresses');
}
const blockedHostnames = ['internal', 'intranet', 'corp', 'private', 'local'];
for (const blocked of blockedHostnames) {
if (hostname === blocked || hostname.endsWith(`.${blocked}`)) {
throw new Error(`Webhook URL cannot target internal hostname: ${hostname}`);
}
}
}
/**
* Validate webhook URL with DNS resolution for comprehensive SSRF protection.
* Resolves the hostname and validates that resolved IPs are not private/internal.
*/
export async function validateWebhookUrlWithDns(webhookUrl: string): Promise<void> {
validateWebhookUrl(webhookUrl);
const url = new URL(webhookUrl);
const hostname = url.hostname.toLowerCase();
if (isPrivateIp(hostname)) {
throw new Error('Webhook URL cannot target private/internal IP addresses');
}
try {
const addresses = await dns.resolve(hostname);
for (const ip of addresses) {
if (isPrivateIp(ip)) {
throw new Error('Webhook URL hostname resolves to a private/internal IP address');
}
}
try {
const ipv6Addresses = await dns.resolve6(hostname);
for (const ip of ipv6Addresses) {
if (isPrivateIp(ip)) {
throw new Error('Webhook URL hostname resolves to a private/internal IP address');
}
}
} catch {
// IPv6 resolution may fail if no AAAA records exist, which is fine
}
} catch (error) {
if (error instanceof Error && error.message.includes('private/internal')) {
throw error;
}
}
}
/** Header name for HMAC signature */
export const WEBHOOK_SIGNATURE_HEADER = 'X-Signature-256';
/** Header name for timestamp (for replay attack prevention) */
export const WEBHOOK_TIMESTAMP_HEADER = 'X-Timestamp';
/**
* Send evaluation results to a webhook URL.
*
* @param params.webhookUrl - The URL to POST results to
* @param params.webhookSecret - Optional secret for HMAC signature (recommended for production)
* @param params.summary - Evaluation run summary
* @param params.dataset - Dataset name
* @param params.suite - Evaluation suite name
* @param params.metadata - Additional metadata to include
* @param params.logger - Logger instance
*/
export async function sendWebhookNotification(params: {
webhookUrl: string;
webhookSecret?: string;
summary: RunSummary;
dataset: string;
suite: string;
metadata: Record<string, unknown>;
logger: EvalLogger;
}): Promise<void> {
const { webhookUrl, webhookSecret, summary, dataset, suite, metadata, logger } = params;
await validateWebhookUrlWithDns(webhookUrl);
const payload: WebhookPayload = {
suite,
summary: {
totalExamples: summary.totalExamples,
passed: summary.passed,
failed: summary.failed,
errors: summary.errors,
averageScore: summary.averageScore,
},
evaluatorAverages: summary.evaluatorAverages,
totalDurationMs: summary.totalDurationMs,
metadata,
langsmith: summary.langsmith
? {
...summary.langsmith,
datasetName: dataset,
}
: undefined,
};
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (webhookSecret) {
const timestamp = Date.now().toString();
const signaturePayload = `${timestamp}.${body}`;
headers[WEBHOOK_SIGNATURE_HEADER] = generateWebhookSignature(signaturePayload, webhookSecret);
headers[WEBHOOK_TIMESTAMP_HEADER] = timestamp;
logger.info('Webhook request will be signed with HMAC-SHA256');
} else {
logger.warn(
'No webhook secret provided - request will not be signed. ' +
'Consider using --webhook-secret for production use.',
);
}
// Log masked URL to avoid exposing potential tokens in path/query
logger.info(`Sending results to webhook: ${maskWebhookUrl(webhookUrl)}`);
const response = await fetch(webhookUrl, {
method: 'POST',
headers,
body,
});
if (!response.ok) {
throw new Error(`Webhook request failed: ${response.status} ${response.statusText}`);
}
logger.info(`Webhook notification sent successfully (status: ${response.status})`);
}