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,436 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeResultsCsv } from '../csv-writer';
import type { ExampleResult } from '../harness-types';
describe('writeResultsCsv', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'csv-writer-test-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('writes sorted results with correct columns', () => {
const results: ExampleResult[] = [
{
index: 2,
prompt: 'Zebra workflow',
status: 'pass',
score: 0.9,
feedback: [
{
evaluator: 'llm-judge',
metric: 'functionality',
score: 0.95,
kind: 'metric',
comment: '',
},
{
evaluator: 'llm-judge',
metric: 'connections',
score: 0.85,
kind: 'metric',
comment: 'Minor issue',
},
],
durationMs: 5000,
generationDurationMs: 3000,
generationInputTokens: 1000,
generationOutputTokens: 500,
},
{
index: 1,
prompt: 'Alpha workflow',
status: 'fail',
score: 0.6,
feedback: [
{
evaluator: 'llm-judge',
metric: 'functionality',
score: 0.5,
kind: 'metric',
comment: '[CRITICAL] Missing trigger',
},
],
durationMs: 4000,
generationDurationMs: 2500,
generationInputTokens: 800,
generationOutputTokens: 400,
},
];
const outputPath = join(tempDir, 'results.csv');
writeResultsCsv(results, outputPath);
const content = readFileSync(outputPath, 'utf-8');
const lines = content.trim().split('\n');
// Check header
expect(lines[0]).toContain('prompt,overall_score,status,gen_latency_ms');
expect(lines[0]).toContain('functionality,functionality_detail');
// Check sorting (Alpha before Zebra)
expect(lines[1]).toContain('Alpha workflow');
expect(lines[2]).toContain('Zebra workflow');
// Check violation text is included
expect(lines[1]).toContain('[CRITICAL] Missing trigger');
});
it.each([
{
description: 'commas and quotes',
prompt: 'Workflow with "quotes" and, commas',
expected: '"Workflow with ""quotes"" and, commas"',
},
{
description: 'newlines',
prompt: 'Workflow with\nnewline',
expected: '"Workflow with\nnewline"',
},
])('escapes $description in values', ({ prompt, expected }) => {
const results: ExampleResult[] = [
{
index: 1,
prompt,
status: 'pass',
score: 0.8,
feedback: [],
durationMs: 1000,
},
];
const outputPath = join(tempDir, 'results.csv');
writeResultsCsv(results, outputPath);
const content = readFileSync(outputPath, 'utf-8');
expect(content).toContain(expected);
});
it('writes pairwise evaluation results with correct columns', () => {
const results: ExampleResult[] = [
{
index: 1,
prompt: 'Test pairwise workflow',
status: 'fail',
score: 0.5,
feedback: [
{
evaluator: 'pairwise',
metric: 'pairwise_primary',
score: 0,
kind: 'score',
comment: '0/3 judges passed',
},
{
evaluator: 'pairwise',
metric: 'pairwise_diagnostic',
score: 0.67,
kind: 'metric',
},
{
evaluator: 'pairwise',
metric: 'pairwise_judges_passed',
score: 0,
kind: 'detail',
},
{
evaluator: 'pairwise',
metric: 'pairwise_total_passes',
score: 6,
kind: 'detail',
},
{
evaluator: 'pairwise',
metric: 'pairwise_total_violations',
score: 3,
kind: 'detail',
},
{
evaluator: 'pairwise',
metric: 'judge1',
score: 0,
kind: 'detail',
comment: '[Spec violation] Missing required field',
},
{
evaluator: 'pairwise',
metric: 'judge2',
score: 1,
kind: 'detail',
comment: '',
},
{
evaluator: 'pairwise',
metric: 'judge3',
score: 0,
kind: 'detail',
comment: '[Spec violation] Wrong parameter value',
},
],
durationMs: 5000,
generationDurationMs: 3000,
generationInputTokens: 1000,
generationOutputTokens: 500,
},
];
const outputPath = join(tempDir, 'pairwise-results.csv');
writeResultsCsv(results, outputPath);
const content = readFileSync(outputPath, 'utf-8');
const lines = content.trim().split('\n');
// Check header has pairwise columns
expect(lines[0]).toContain('prompt,overall_score,status,gen_latency_ms');
expect(lines[0]).toContain('pairwise_primary');
expect(lines[0]).toContain('pairwise_diagnostic');
expect(lines[0]).toContain('pairwise_judges_passed');
expect(lines[0]).toContain('pairwise_total_passes');
expect(lines[0]).toContain('pairwise_total_violations');
expect(lines[0]).toContain('judge1,judge1_detail');
expect(lines[0]).toContain('judge2,judge2_detail');
expect(lines[0]).toContain('judge3,judge3_detail');
// Check data row contains judge violation details
expect(lines[1]).toContain('[Spec violation] Missing required field');
expect(lines[1]).toContain('[Spec violation] Wrong parameter value');
});
it('handles empty results array', () => {
const results: ExampleResult[] = [];
const outputPath = join(tempDir, 'empty-results.csv');
writeResultsCsv(results, outputPath);
const content = readFileSync(outputPath, 'utf-8');
expect(content).toBe('');
});
it('includes subgraph metrics columns (node_count, discovery_latency_ms, builder_latency_ms)', () => {
const results: ExampleResult[] = [
{
index: 1,
prompt: 'Test workflow with subgraph metrics',
status: 'pass',
score: 0.9,
feedback: [
{
evaluator: 'llm-judge',
metric: 'functionality',
score: 0.95,
kind: 'metric',
comment: '',
},
],
durationMs: 5000,
generationDurationMs: 3000,
generationInputTokens: 1000,
generationOutputTokens: 500,
subgraphMetrics: {
nodeCount: 8,
discoveryDurationMs: 450,
builderDurationMs: 1200,
},
},
{
index: 2,
prompt: 'Test workflow without subgraph metrics',
status: 'pass',
score: 0.8,
feedback: [
{
evaluator: 'llm-judge',
metric: 'functionality',
score: 0.8,
kind: 'metric',
comment: '',
},
],
durationMs: 4000,
generationDurationMs: 2500,
generationInputTokens: 900,
generationOutputTokens: 450,
// No subgraphMetrics
},
];
const outputPath = join(tempDir, 'subgraph-metrics.csv');
writeResultsCsv(results, outputPath);
const content = readFileSync(outputPath, 'utf-8');
const lines = content.trim().split('\n');
// Check header includes subgraph metrics columns
expect(lines[0]).toContain('node_count');
expect(lines[0]).toContain('discovery_latency_ms');
expect(lines[0]).toContain('builder_latency_ms');
// Check data rows (sorted by prompt alphabetically: "with" < "without")
// First row: with metrics (should contain the values)
expect(lines[1]).toContain('Test workflow with subgraph metrics');
expect(lines[1]).toContain('8'); // nodeCount
expect(lines[1]).toContain('450'); // discoveryDurationMs
expect(lines[1]).toContain('1200'); // builderDurationMs
// Second row: without metrics (empty values)
expect(lines[2]).toContain('Test workflow without subgraph metrics');
});
it('includes subgraph metrics in pairwise format', () => {
const results: ExampleResult[] = [
{
index: 1,
prompt: 'Pairwise with metrics',
status: 'pass',
score: 0.9,
feedback: [
{
evaluator: 'pairwise',
metric: 'pairwise_primary',
score: 1,
kind: 'score',
},
{
evaluator: 'pairwise',
metric: 'judge1',
score: 1,
kind: 'detail',
},
],
durationMs: 5000,
generationDurationMs: 3000,
generationInputTokens: 1000,
generationOutputTokens: 500,
subgraphMetrics: {
nodeCount: 5,
discoveryDurationMs: 300,
builderDurationMs: 800,
},
},
];
const outputPath = join(tempDir, 'pairwise-with-metrics.csv');
writeResultsCsv(results, outputPath);
const content = readFileSync(outputPath, 'utf-8');
const lines = content.trim().split('\n');
// Check header includes subgraph metrics columns for pairwise format too
expect(lines[0]).toContain('node_count');
expect(lines[0]).toContain('discovery_latency_ms');
expect(lines[0]).toContain('builder_latency_ms');
// Check data row contains the metrics
expect(lines[1]).toContain('5'); // nodeCount
expect(lines[1]).toContain('300'); // discoveryDurationMs
expect(lines[1]).toContain('800'); // builderDurationMs
});
it('uses explicit suite option to override auto-detection', () => {
// Results with runner errors (no pairwise feedback) should still use pairwise format
// when suite is explicitly specified
const results: ExampleResult[] = [
{
index: 1,
prompt: 'Failed during generation',
status: 'error',
score: 0,
feedback: [
{
evaluator: 'runner', // This would normally trigger unknown/llm-judge format
metric: 'error',
score: 0,
kind: 'score',
comment: 'Generation failed',
},
],
durationMs: 1000,
generationDurationMs: 500,
error: 'Generation failed',
},
];
const outputPath = join(tempDir, 'explicit-pairwise-suite.csv');
writeResultsCsv(results, outputPath, { suite: 'pairwise' });
const content = readFileSync(outputPath, 'utf-8');
const lines = content.trim().split('\n');
// Should use pairwise format headers despite having runner feedback
expect(lines[0]).toContain('pairwise_primary');
expect(lines[0]).toContain('pairwise_diagnostic');
// Should NOT have llm-judge format headers
expect(lines[0]).not.toContain('functionality_detail');
});
it('writes binary-checks format with correct columns', () => {
const results: ExampleResult[] = [
{
index: 1,
prompt: 'Test workflow',
status: 'pass',
score: 0,
feedback: [
{ evaluator: 'binary-checks', metric: 'has_nodes', score: 1, kind: 'metric' as const },
{ evaluator: 'binary-checks', metric: 'has_trigger', score: 0, kind: 'metric' as const },
],
durationMs: 1000,
generationDurationMs: 500,
},
];
const outputPath = join(tempDir, 'binary-results.csv');
writeResultsCsv(results, outputPath, { suite: 'binary-checks' });
const content = readFileSync(outputPath, 'utf-8');
const lines = content.trim().split('\n');
// Check header includes binary check names
expect(lines[0]).toContain('prompt,status,gen_latency_ms');
expect(lines[0]).toContain('has_nodes');
expect(lines[0]).toContain('has_trigger');
expect(lines[0]).toContain('descriptive_node_names');
// Check data row
expect(lines[1]).toContain('Test workflow');
});
it('falls back to auto-detection when suite option is not provided', () => {
// Results with runner errors should fall back to llm-judge format when no suite specified
const results: ExampleResult[] = [
{
index: 1,
prompt: 'Failed during generation',
status: 'error',
score: 0,
feedback: [
{
evaluator: 'runner',
metric: 'error',
score: 0,
kind: 'score',
},
],
durationMs: 1000,
generationDurationMs: 500,
},
];
const outputPath = join(tempDir, 'auto-detect-suite.csv');
writeResultsCsv(results, outputPath); // No suite option
const content = readFileSync(outputPath, 'utf-8');
const lines = content.trim().split('\n');
// Should fall back to llm-judge format when no suite detected
expect(lines[0]).toContain('functionality');
expect(lines[0]).toContain('functionality_detail');
});
});
@@ -0,0 +1,139 @@
import type { LLMResult } from '@langchain/core/outputs';
import { TokenUsageTrackingHandler } from '../token-tracking-handler';
describe('TokenUsageTrackingHandler', () => {
let handler: TokenUsageTrackingHandler;
beforeEach(() => {
handler = new TokenUsageTrackingHandler();
});
describe('handleLLMEnd', () => {
it.each([
{
format: 'Anthropic (input_tokens/output_tokens)',
usage: { input_tokens: 100, output_tokens: 50 },
expected: { inputTokens: 100, outputTokens: 50 },
},
{
format: 'OpenAI (prompt_tokens/completion_tokens)',
usage: { prompt_tokens: 150, completion_tokens: 75 },
expected: { inputTokens: 150, outputTokens: 75 },
},
])(
'should accumulate tokens from llmOutput.usage in $format format',
async ({ usage, expected }) => {
const result: LLMResult = {
generations: [[]],
llmOutput: { usage },
};
await handler.handleLLMEnd(result);
expect(handler.getUsage()).toEqual(expected);
},
);
it('should accumulate tokens from multiple LLM calls', async () => {
const result1: LLMResult = {
generations: [[]],
llmOutput: {
usage: { input_tokens: 100, output_tokens: 50 },
},
};
const result2: LLMResult = {
generations: [[]],
llmOutput: {
usage: { input_tokens: 200, output_tokens: 100 },
},
};
await handler.handleLLMEnd(result1);
await handler.handleLLMEnd(result2);
expect(handler.getUsage()).toEqual({
inputTokens: 300,
outputTokens: 150,
});
});
it.each([
{
format: 'Anthropic (input_tokens/output_tokens)',
usage: { input_tokens: 75, output_tokens: 25 },
expected: { inputTokens: 75, outputTokens: 25 },
},
{
format: 'OpenAI (prompt_tokens/completion_tokens)',
usage: { prompt_tokens: 80, completion_tokens: 40 },
expected: { inputTokens: 80, outputTokens: 40 },
},
])(
'should extract tokens from generationInfo in $format format when llmOutput is empty',
async ({ usage, expected }) => {
const result: LLMResult = {
generations: [
[
{
text: 'response',
generationInfo: { usage },
},
],
],
};
await handler.handleLLMEnd(result);
expect(handler.getUsage()).toEqual(expected);
},
);
it('should handle missing usage data gracefully', async () => {
const result: LLMResult = {
generations: [[{ text: 'response' }]],
};
await handler.handleLLMEnd(result);
expect(handler.getUsage()).toEqual({
inputTokens: 0,
outputTokens: 0,
});
});
});
describe('reset', () => {
it('should reset accumulated usage to zero', async () => {
const result: LLMResult = {
generations: [[]],
llmOutput: {
usage: {
input_tokens: 100,
output_tokens: 50,
},
},
};
await handler.handleLLMEnd(result);
expect(handler.getUsage().inputTokens).toBe(100);
handler.reset();
expect(handler.getUsage()).toEqual({
inputTokens: 0,
outputTokens: 0,
});
});
});
describe('getUsage', () => {
it('should return zero for new handler', () => {
expect(handler.getUsage()).toEqual({
inputTokens: 0,
outputTokens: 0,
});
});
});
});
@@ -0,0 +1,307 @@
import { writeFileSync } from 'node:fs';
import type { ExampleResult, Feedback } from './harness-types';
import { DETERMINISTIC_CHECKS } from '../evaluators/binary-checks/checks';
import { LLM_CHECKS } from '../evaluators/binary-checks/llm-checks';
/**
* Fixed columns that appear first in the CSV (in order).
*/
const FIXED_COLUMNS = [
'prompt',
'overall_score',
'status',
'gen_latency_ms',
'gen_input_tokens',
'gen_output_tokens',
'node_count',
'discovery_latency_ms',
'builder_latency_ms',
'responder_latency_ms',
] as const;
/**
* LLM Judge metrics to include (in order).
* Each metric gets a score column and a _detail column.
*/
const LLM_JUDGE_METRICS = [
'functionality',
'connections',
'expressions',
'nodeConfiguration',
'efficiency',
'dataFlow',
'maintainability',
'bestPractices',
] as const;
/**
* Pairwise evaluator metrics.
*/
const PAIRWISE_METRICS = [
'pairwise_primary',
'pairwise_diagnostic',
'pairwise_judges_passed',
'pairwise_total_passes',
'pairwise_total_violations',
] as const;
/**
* Binary check names derived from the check registries.
* Order: deterministic checks first, then LLM checks.
*/
const BINARY_CHECK_NAMES = [
...DETERMINISTIC_CHECKS.map((c) => c.name),
...LLM_CHECKS.map((c) => c.name),
];
type EvaluationSuite = 'llm-judge' | 'pairwise' | 'binary-checks' | 'unknown';
/**
* Escape a value for CSV output.
* Wraps in quotes if contains comma, quote, or newline.
*/
function escapeCsvValue(value: string | number | undefined): string {
if (value === undefined || value === null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
/**
* Detect the evaluation suite from feedback.
*/
function detectSuite(feedback: Feedback[]): EvaluationSuite {
if (feedback.some((f) => f.evaluator === 'llm-judge')) {
return 'llm-judge';
}
if (feedback.some((f) => f.evaluator === 'pairwise')) {
return 'pairwise';
}
if (feedback.some((f) => f.evaluator === 'binary-checks')) {
return 'binary-checks';
}
return 'unknown';
}
/**
* Extract the detail text from feedback for a given evaluator and metric.
*/
function extractMetricDetail(feedback: Feedback[], evaluator: string, metric: string): string {
const item = feedback.find((f) => f.evaluator === evaluator && f.metric === metric && f.comment);
return item?.comment ?? '';
}
/**
* Extract the score for a given evaluator and metric.
*/
function extractMetricScore(
feedback: Feedback[],
evaluator: string,
metric: string,
): number | undefined {
const item = feedback.find((f) => f.evaluator === evaluator && f.metric === metric);
return item?.score;
}
/**
* Get the number of judges used in pairwise evaluation.
*/
function getJudgeCount(feedback: Feedback[]): number {
const judgeMetrics = feedback.filter(
(f) => f.evaluator === 'pairwise' && f.metric.startsWith('judge'),
);
return judgeMetrics.length;
}
/**
* Build CSV row for LLM Judge suite.
*/
function buildLlmJudgeRow(result: ExampleResult): string[] {
const row: string[] = [];
// Fixed columns
row.push(escapeCsvValue(result.prompt));
row.push(escapeCsvValue(result.score));
row.push(escapeCsvValue(result.status));
row.push(escapeCsvValue(result.generationDurationMs));
row.push(escapeCsvValue(result.generationInputTokens));
row.push(escapeCsvValue(result.generationOutputTokens));
row.push(escapeCsvValue(result.subgraphMetrics?.nodeCount));
row.push(escapeCsvValue(result.subgraphMetrics?.discoveryDurationMs));
row.push(escapeCsvValue(result.subgraphMetrics?.builderDurationMs));
row.push(escapeCsvValue(result.subgraphMetrics?.responderDurationMs));
// LLM Judge metric columns (score + detail pairs)
for (const metric of LLM_JUDGE_METRICS) {
row.push(escapeCsvValue(extractMetricScore(result.feedback, 'llm-judge', metric)));
row.push(escapeCsvValue(extractMetricDetail(result.feedback, 'llm-judge', metric)));
}
return row;
}
/**
* Build CSV row for Pairwise suite.
*/
function buildPairwiseRow(result: ExampleResult, judgeCount: number): string[] {
const row: string[] = [];
// Fixed columns
row.push(escapeCsvValue(result.prompt));
row.push(escapeCsvValue(result.score));
row.push(escapeCsvValue(result.status));
row.push(escapeCsvValue(result.generationDurationMs));
row.push(escapeCsvValue(result.generationInputTokens));
row.push(escapeCsvValue(result.generationOutputTokens));
row.push(escapeCsvValue(result.subgraphMetrics?.nodeCount));
row.push(escapeCsvValue(result.subgraphMetrics?.discoveryDurationMs));
row.push(escapeCsvValue(result.subgraphMetrics?.builderDurationMs));
row.push(escapeCsvValue(result.subgraphMetrics?.responderDurationMs));
// Pairwise metrics (scores only, no detail)
for (const metric of PAIRWISE_METRICS) {
row.push(escapeCsvValue(extractMetricScore(result.feedback, 'pairwise', metric)));
}
// Individual judge results (score + violation detail)
for (let i = 1; i <= judgeCount; i++) {
const judgeMetric = `judge${i}`;
row.push(escapeCsvValue(extractMetricScore(result.feedback, 'pairwise', judgeMetric)));
row.push(escapeCsvValue(extractMetricDetail(result.feedback, 'pairwise', judgeMetric)));
}
return row;
}
/**
* Build CSV row for binary-checks suite.
*/
function buildBinaryChecksRow(result: ExampleResult): string[] {
const row: string[] = [];
row.push(escapeCsvValue(result.prompt));
row.push(escapeCsvValue(result.status));
row.push(escapeCsvValue(result.generationDurationMs));
for (const checkName of BINARY_CHECK_NAMES) {
row.push(escapeCsvValue(extractMetricScore(result.feedback, 'binary-checks', checkName)));
}
return row;
}
/**
* Build CSV header for LLM Judge suite.
*/
function buildLlmJudgeHeader(): string[] {
const header: string[] = [...FIXED_COLUMNS];
for (const metric of LLM_JUDGE_METRICS) {
header.push(metric);
header.push(`${metric}_detail`);
}
return header;
}
/**
* Build CSV header for Pairwise suite.
*/
function buildPairwiseHeader(judgeCount: number): string[] {
const header: string[] = [...FIXED_COLUMNS];
// Pairwise metrics
for (const metric of PAIRWISE_METRICS) {
header.push(metric);
}
// Individual judge columns
for (let i = 1; i <= judgeCount; i++) {
header.push(`judge${i}`);
header.push(`judge${i}_detail`);
}
return header;
}
/**
* Build CSV header for binary-checks suite.
*/
function buildBinaryChecksHeader(): string[] {
return ['prompt', 'status', 'gen_latency_ms', ...BINARY_CHECK_NAMES];
}
export interface WriteResultsCsvOptions {
/** Explicitly specify the evaluation suite. If not provided, auto-detects from feedback. */
suite?: 'llm-judge' | 'pairwise' | 'binary-checks';
}
/**
* Write evaluation results to a CSV file.
* Results are sorted by prompt for consistent ordering across runs.
* Automatically detects evaluation suite and formats accordingly,
* unless explicitly specified via options.
*/
export function writeResultsCsv(
results: ExampleResult[],
outputPath: string,
options?: WriteResultsCsvOptions,
): void {
if (results.length === 0) {
writeFileSync(outputPath, '', 'utf-8');
return;
}
// Sort by prompt for consistent ordering
const sorted = [...results].sort((a, b) => a.prompt.localeCompare(b.prompt));
// Use explicit suite if provided, otherwise detect from feedback
let suite: EvaluationSuite;
if (options?.suite) {
suite = options.suite;
} else {
// Detect suite from first result with feedback (excluding runner errors)
const firstWithFeedback = sorted.find((r) =>
r.feedback.some((f) => f.evaluator !== 'runner' && f.evaluator !== 'programmatic'),
);
suite = firstWithFeedback ? detectSuite(firstWithFeedback.feedback) : 'unknown';
}
const lines: string[] = [];
if (suite === 'pairwise') {
// Determine max judge count across all results
const judgeCount = Math.max(...sorted.map((r) => getJudgeCount(r.feedback)), 0);
// Header
lines.push(buildPairwiseHeader(judgeCount).join(','));
// Data rows
for (const result of sorted) {
lines.push(buildPairwiseRow(result, judgeCount).join(','));
}
} else if (suite === 'binary-checks') {
// Header
lines.push(buildBinaryChecksHeader().join(','));
// Data rows
for (const result of sorted) {
lines.push(buildBinaryChecksRow(result).join(','));
}
} else {
// Default to LLM Judge format (also handles unknown)
// Header
lines.push(buildLlmJudgeHeader().join(','));
// Data rows
for (const result of sorted) {
lines.push(buildLlmJudgeRow(result).join(','));
}
}
// Write file (overwrites if exists)
writeFileSync(outputPath, lines.join('\n') + '\n', 'utf-8');
}
@@ -0,0 +1,201 @@
import type { Callbacks } from '@langchain/core/callbacks/manager';
import { getLangchainCallbacks } from 'langsmith/langchain';
import { v4 as uuid } from 'uuid';
import type { Evaluator, EvaluationContext, Feedback, LlmCallLimiter } from './harness-types';
import type { SimpleWorkflow } from '../../src/types/workflow';
import type { BuilderFeatureFlags, ChatPayload } from '../../src/workflow-builder-agent';
import { DEFAULTS } from '../support/constants';
/**
* Get LangChain callbacks that bridge the current traceable context.
* Returns undefined if not in a traceable context.
*/
export async function getTracingCallbacks(): Promise<Callbacks | undefined> {
try {
return await getLangchainCallbacks();
} catch {
return undefined;
}
}
export async function consumeGenerator<T>(gen: AsyncGenerator<T>) {
for await (const _ of gen) {
/* consume all */
}
}
export async function runWithOptionalLimiter<T>(
fn: () => Promise<T>,
limiter?: LlmCallLimiter,
): Promise<T> {
return limiter ? await limiter(fn) : await fn();
}
export async function withTimeout<T>(args: {
promise: Promise<T>;
timeoutMs?: number;
label: string;
}): Promise<T> {
// NOTE:
// - This is a best-effort timeout. It does NOT cancel/abort the underlying work.
// - If the underlying work supports cancellation (e.g. AbortSignal), plumb that through instead.
// - When combined with `p-limit`, prefer applying the timeout *inside* the limited function so the
// limiter slot is released when the timeout triggers.
const { promise, timeoutMs, label } = args;
if (typeof timeoutMs !== 'number') return await promise;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error(`Invalid timeoutMs (${String(timeoutMs)}) for ${label}`);
}
let timer: NodeJS.Timeout | undefined;
try {
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`Timed out after ${timeoutMs}ms in ${label}`)),
timeoutMs,
);
});
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
export interface GetChatPayloadOptions {
evalType: string;
message: string;
workflowId: string;
featureFlags?: BuilderFeatureFlags;
}
export function getChatPayload(options: GetChatPayloadOptions): ChatPayload {
const { evalType, message, workflowId, featureFlags } = options;
return {
id: `${evalType}-${uuid()}`,
featureFlags: featureFlags ?? DEFAULTS.FEATURE_FLAGS,
message,
workflowContext: {
currentWorkflow: { id: workflowId, nodes: [], connections: {} },
},
};
}
/**
* Coordination log entry for subgraph timing extraction.
* Matches the CoordinationLogEntry type from src/types/coordination.ts
*/
interface CoordinationLogEntry {
phase: 'discovery' | 'builder' | 'assistant' | 'state_management' | 'responder' | 'planner';
status: 'completed' | 'in_progress' | 'error';
timestamp: number;
}
/**
* Subgraph metrics extracted from coordination log.
*/
export interface ExtractedSubgraphMetrics {
discoveryDurationMs?: number;
builderDurationMs?: number;
responderDurationMs?: number;
nodeCount?: number;
}
/**
* Calculate duration for a specific phase from coordination log entries.
* Looks for the first 'in_progress' and terminal ('completed' or 'error') status for the phase.
*/
function calculatePhaseDuration(
coordinationLog: CoordinationLogEntry[],
phase: 'discovery' | 'builder' | 'responder',
): number | undefined {
const phaseEntries = coordinationLog.filter((entry) => entry.phase === phase);
if (phaseEntries.length === 0) return undefined;
const inProgress = phaseEntries.find((e) => e.status === 'in_progress');
// Accept either 'completed' or 'error' as the terminal status
const terminal = phaseEntries.find((e) => e.status === 'completed' || e.status === 'error');
if (inProgress && terminal) {
return terminal.timestamp - inProgress.timestamp;
}
// If no in_progress entry, try to calculate from first to last entry
if (phaseEntries.length >= 2) {
const sorted = [...phaseEntries].sort((a, b) => a.timestamp - b.timestamp);
return sorted[sorted.length - 1].timestamp - sorted[0].timestamp;
}
return undefined;
}
/**
* Extract subgraph metrics from coordination log and workflow.
*/
export function extractSubgraphMetrics(
coordinationLog: CoordinationLogEntry[] | undefined,
nodeCount: number | undefined,
): ExtractedSubgraphMetrics {
const metrics: ExtractedSubgraphMetrics = {};
// Include node count
if (nodeCount !== undefined) {
metrics.nodeCount = nodeCount;
}
// Extract timing from coordination log
if (coordinationLog && coordinationLog.length > 0) {
const discoveryDuration = calculatePhaseDuration(coordinationLog, 'discovery');
const builderDuration = calculatePhaseDuration(coordinationLog, 'builder');
const responderDuration = calculatePhaseDuration(coordinationLog, 'responder');
if (discoveryDuration !== undefined) {
metrics.discoveryDurationMs = discoveryDuration;
}
if (builderDuration !== undefined) {
metrics.builderDurationMs = builderDuration;
}
if (responderDuration !== undefined) {
metrics.responderDurationMs = responderDuration;
}
}
return metrics;
}
/**
* Run all evaluators on a workflow + context pair, with per-evaluator timeouts.
* Returns flattened feedback; errors are captured as feedback items.
*/
export async function runEvaluatorsOnExample(
evaluators: Array<Evaluator<EvaluationContext>>,
workflow: SimpleWorkflow,
context: EvaluationContext,
timeoutMs?: number,
): Promise<Feedback[]> {
return (
await Promise.all(
evaluators.map(async (evaluator): Promise<Feedback[]> => {
try {
return await withTimeout({
promise: evaluator.evaluate(workflow, context),
timeoutMs,
label: `evaluator:${evaluator.name}`,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return [
{
evaluator: evaluator.name,
metric: 'error',
score: 0,
kind: 'score' as const,
comment: msg,
},
];
}
}),
)
).flat();
}
@@ -0,0 +1,72 @@
import type { Feedback } from './harness-types';
export interface LangsmithEvaluationResultLike {
key: string;
score: number;
comment?: string;
}
export function feedbackKey(feedback: Feedback): string {
return `${feedback.evaluator}.${feedback.metric}`;
}
function isPairwiseV1Metric(metric: string): boolean {
return metric.startsWith('pairwise_');
}
/**
* Metric key mapping for LangSmith.
*
* Goal: keep keys comparable with historical runs.
* - Programmatic: keep evaluator prefix (e.g. `programmatic.trigger`)
* - LLM-judge: keep metrics unprefixed (e.g. `overallScore`, `connections`, `maintainability.nodeNamingQuality`)
* - Pairwise: keep v1 metrics unprefixed (e.g. `pairwise_primary`), but namespace non-v1 details.
* - Metrics: keep evaluator prefix (e.g. `metrics.discovery_latency_s`, `metrics.node_count`)
*/
export function langsmithMetricKey(feedback: Feedback): string {
if (feedback.evaluator === 'pairwise') {
return isPairwiseV1Metric(feedback.metric) ? feedback.metric : feedbackKey(feedback);
}
if (feedback.evaluator === 'programmatic') {
return feedbackKey(feedback);
}
if (feedback.evaluator === 'llm-judge') {
return feedback.metric;
}
if (feedback.evaluator === 'metrics') {
return feedbackKey(feedback);
}
if (feedback.evaluator === 'responder-judge') {
// Dimension & overall metrics unprefixed (like llm-judge), judge details prefixed.
return feedback.kind === 'detail' ? feedbackKey(feedback) : feedback.metric;
}
// Default: prefix unknown evaluators to avoid collisions with unprefixed `llm-judge` metrics.
return feedbackKey(feedback);
}
/**
* LangSmith score limits.
*/
const LANGSMITH_SCORE_MIN = -99999.9999;
const LANGSMITH_SCORE_MAX = 99999.9999;
/**
* Clamp a score to LangSmith's valid range.
* LangSmith rejects scores outside [-99999.9999, 99999.9999].
*/
function clampScoreForLangsmith(score: number): number {
return Math.max(LANGSMITH_SCORE_MIN, Math.min(LANGSMITH_SCORE_MAX, score));
}
export function toLangsmithEvaluationResult(feedback: Feedback): LangsmithEvaluationResultLike {
return {
key: langsmithMetricKey(feedback),
score: clampScoreForLangsmith(feedback.score),
...(feedback.comment ? { comment: feedback.comment } : {}),
};
}
@@ -0,0 +1,308 @@
import type { Client as LangsmithClient } from 'langsmith/client';
import type { IPinData } from 'n8n-workflow';
import type pLimit from 'p-limit';
import type { EvalLogger } from './logger';
import type { GenerationCollectors } from './runner';
import type { IntrospectionEvent } from '../../src/tools/introspect.tool.js';
import type { SimpleWorkflow } from '../../src/types/workflow';
export type LlmCallLimiter = ReturnType<typeof pLimit>;
/**
* Shared context passed to all evaluators.
*
* Keep this as the single "base" context so callers (CLI/runner) never need casts.
* Evaluators should validate required fields at runtime when optional fields are needed.
*/
export interface EvaluationContext {
/** The original user prompt for this example */
prompt: string;
/** Pairwise criteria: required behaviors */
dos?: string;
/** Pairwise criteria: forbidden behaviors */
donts?: string;
/** Optional reference workflows for similarity-based checks (best match wins) */
referenceWorkflows?: SimpleWorkflow[];
/**
* Optional limiter for LLM-bound work (generation + evaluators).
* When provided, treat it as the global knob for overall parallel LLM calls.
*/
llmCallLimiter?: LlmCallLimiter;
/**
* Optional timeout used for LLM-bound work (generation + evaluators).
* Note: timeouts are best-effort unless underlying calls support cancellation (AbortSignal).
*/
timeoutMs?: number;
/**
* Generated TypeScript SDK code for code-level evaluators.
* Populated from GenerationResult when available.
*/
generatedCode?: string;
/** Pin data for service nodes (used by execution evaluator) */
pinData?: IPinData;
/** Per-example annotations (e.g., code_necessary) from CSV or LangSmith dataset */
annotations?: Record<string, unknown>;
}
/** Context attached to an individual test case (prompt is provided separately). */
export type TestCaseContext = Omit<Partial<EvaluationContext>, 'prompt'>;
/** Global context attached to a run (prompt is provided per test case). */
export type GlobalRunContext = Omit<Partial<EvaluationContext>, 'prompt'>;
/**
* A styled line for verbose display output.
* Evaluators can provide these in `details.displayLines` for custom formatting.
*/
export interface DisplayLine {
text: string;
color?: 'yellow' | 'red' | 'dim';
}
/**
* What evaluators return - a single piece of feedback.
*/
export interface Feedback {
/** Evaluator name emitting this feedback (e.g. `llm-judge`, `programmatic`) */
evaluator: string;
/** Metric name within the evaluator (e.g. `functionality`, `efficiency.nodeCountEfficiency`) */
metric: string;
score: number;
comment?: string;
/**
* Classification of this feedback item.
*
* - `score`: the single score used for overall scoring for this evaluator
* - `metric`: stable category-level metrics (useful for dashboards)
* - `detail`: unstable/verbose metrics that should not affect scoring
*/
kind: 'score' | 'metric' | 'detail';
/**
* Optional structured details for rich display.
* Evaluators can provide structured data here for better logging.
* The `comment` field remains the primary text for LangSmith.
*/
details?: { displayLines?: DisplayLine[] } & Record<string, unknown>;
}
/**
* An evaluator that can assess a generated workflow.
* Optionally typed with context for evaluator-specific data.
*/
export interface Evaluator<TContext = EvaluationContext> {
name: string;
evaluate(workflow: SimpleWorkflow, ctx: TContext): Promise<Feedback[]>;
}
/**
* A single test case for evaluation.
*/
export interface TestCase {
prompt: string;
id?: string;
/** Context passed to evaluators (e.g., pairwise dos/donts) */
context?: TestCaseContext;
/** Reference workflows for similarity comparison (best match wins) */
referenceWorkflows?: SimpleWorkflow[];
}
/** Evaluation suite types supported by the harness */
export type EvaluationSuite =
| 'llm-judge'
| 'pairwise'
| 'programmatic'
| 'similarity'
| 'introspection'
| 'binary-checks';
/**
* Configuration for an evaluation run.
*/
export interface RunConfigBase {
/** Function to generate workflow from prompt. May return GenerationResult with source code. Optional collectors receive metrics. */
generateWorkflow: (
prompt: string,
collectors?: GenerationCollectors,
) => Promise<SimpleWorkflow | GenerationResult>;
/** Evaluators to run on each generated workflow */
evaluators: Array<Evaluator<EvaluationContext>>;
/** Global context available to all evaluators */
context?: GlobalRunContext;
/** Directory for JSON output files */
outputDir?: string;
/** CSV file path for evaluation results */
outputCsv?: string;
/** Evaluation suite (used for CSV formatting). If not set, auto-detected from feedback. */
suite?: EvaluationSuite;
/** Threshold for pass/fail classification of an example score (0-1). */
passThreshold?: number;
/** Timeout for generation/evaluator operations (ms). */
timeoutMs?: number;
/** Lifecycle hooks for logging and monitoring */
lifecycle?: Partial<EvaluationLifecycle>;
/** Logger for all output (use `createQuietLifecycle()` to suppress output in tests) */
logger: EvalLogger;
/** Optional pin data generator. When provided, generates mock data for service nodes after workflow generation. */
pinDataGenerator?: (workflow: SimpleWorkflow) => Promise<IPinData>;
}
export interface LocalRunConfig extends RunConfigBase {
mode: 'local';
/** Local mode requires an in-memory dataset */
dataset: TestCase[];
langsmithOptions?: never;
/** Number of examples to run in parallel (default: 1 for sequential) */
concurrency?: number;
}
export interface LangsmithRunConfig extends RunConfigBase {
mode: 'langsmith';
/** LangSmith mode uses a remote dataset name */
dataset: string;
langsmithOptions: LangsmithOptions;
/** LangSmith client used by both evaluate() and traceable() */
langsmithClient: LangsmithClient;
}
export type RunConfig = LocalRunConfig | LangsmithRunConfig;
/**
* LangSmith-specific configuration.
*/
export interface LangsmithOptions {
experimentName: string;
repetitions: number;
concurrency: number;
/** Maximum number of examples to evaluate from the dataset */
maxExamples?: number;
/** Optional dataset filtering (requires pre-loading examples). */
filters?: LangsmithExampleFilters;
/** Enable trace filtering to reduce payload sizes (default: true) */
enableTraceFiltering?: boolean;
/** Arbitrary metadata passed to LangSmith experiment (e.g., numJudges, scoringMethod) */
experimentMetadata?: Record<string, unknown>;
}
export interface LangsmithExampleFilters {
/** Filter by `example.metadata.notion_id`. */
notionId?: string;
/** Filter by `example.metadata.categories` (contains). */
technique?: string;
/** Filter by `example.inputs.evals.dos` (substring match, case-insensitive). */
doSearch?: string;
/** Filter by `example.inputs.evals.donts` (substring match, case-insensitive). */
dontSearch?: string;
}
/**
* Subgraph timing metrics extracted from coordination log.
*/
export interface SubgraphMetrics {
/** Time spent in discovery subgraph (ms) */
discoveryDurationMs?: number;
/** Time spent in builder subgraph (ms) */
builderDurationMs?: number;
/** Time spent in responder generating the final response (ms) */
responderDurationMs?: number;
/** Number of nodes in the final workflow */
nodeCount?: number;
}
/**
* Result of evaluating a single example.
*/
export interface ExampleResult {
index: number;
prompt: string;
status: 'pass' | 'fail' | 'error';
/** Example-level score (0-1). In v2 this should be scoring-strategy aware (not key-count dependent). */
score: number;
feedback: Feedback[];
durationMs: number;
/** Time spent generating the workflow, when known. */
generationDurationMs?: number;
/** Time spent running evaluators, when known. */
evaluationDurationMs?: number;
/** Input tokens used during workflow generation */
generationInputTokens?: number;
/** Output tokens used during workflow generation */
generationOutputTokens?: number;
/** Subgraph timing and workflow metrics */
subgraphMetrics?: SubgraphMetrics;
/** Introspection events reported by the agent during workflow generation */
introspectionEvents?: IntrospectionEvent[];
workflow?: SimpleWorkflow;
/** Subgraph output (e.g., responder text). Present in subgraph eval mode. */
subgraphOutput?: SubgraphExampleOutput;
/** Generated source code (e.g., TypeScript SDK code from coding agent) */
generatedCode?: string;
error?: string;
}
/**
* Output from a subgraph evaluation example.
*/
export interface SubgraphExampleOutput {
/** The text response from the subgraph (e.g., responder output) */
response?: string;
/** The workflow produced by the subgraph (for builder/configurator) */
workflow?: SimpleWorkflow;
};
/**
* Result from workflow generation that may include source code.
* Used by generators that produce code (e.g., coding agent).
*/
export interface GenerationResult {
workflow: SimpleWorkflow;
/** Source code that generated the workflow (e.g., TypeScript SDK code) */
generatedCode?: string;
}
/**
* Type guard to check if a generation result is a GenerationResult object.
*/
export function isGenerationResult(
value: SimpleWorkflow | GenerationResult,
): value is GenerationResult {
return (
typeof value === 'object' &&
value !== null &&
'workflow' in value &&
typeof value.workflow === 'object'
);
}
/**
* Summary of an entire evaluation run.
*/
export interface RunSummary {
totalExamples: number;
passed: number;
failed: number;
errors: number;
averageScore: number;
totalDurationMs: number;
evaluatorAverages?: Record<string, number>;
/** LangSmith IDs for constructing comparison URLs (only available in langsmith mode) */
langsmith?: {
experimentName: string;
experimentId: string;
datasetId: string;
datasetName?: string;
};
}
/**
* Lifecycle hooks for centralized logging and monitoring.
*/
export interface EvaluationLifecycle {
onStart(config: RunConfig): void;
onExampleStart(index: number, total: number, prompt: string): void;
onWorkflowGenerated(workflow: SimpleWorkflow, durationMs: number): void;
onEvaluatorComplete(name: string, feedback: Feedback[]): void;
onEvaluatorError(name: string, error: Error): void;
onExampleComplete(index: number, result: ExampleResult): void;
onEnd(summary: RunSummary): void | Promise<void>;
}
@@ -0,0 +1,53 @@
/**
* LangSmith dataset write-back utilities.
*
* Provides functions to update examples in a LangSmith dataset
* with regenerated state.
*/
import type { Client as LangsmithClient } from 'langsmith/client';
import type { CoordinationLogEntry } from '@/types/coordination';
import type { SimpleWorkflow } from '@/types/workflow';
import type { EvalLogger } from './logger';
import type { SerializedMessage } from './workflow-regenerator';
/** Entry for LangSmith write-back operations */
export interface LangSmithWriteBackEntry {
exampleId: string;
messages: SerializedMessage[];
coordinationLog: CoordinationLogEntry[];
workflowJSON: SimpleWorkflow;
}
/**
* Write regenerated state back to a LangSmith dataset.
* Updates the inputs field of each example while preserving other fields.
*/
export async function writeBackToLangSmithDataset(
client: LangsmithClient,
updates: LangSmithWriteBackEntry[],
logger?: EvalLogger,
): Promise<void> {
if (updates.length === 0) {
logger?.verbose('No updates to write back to LangSmith');
return;
}
logger?.info(`Writing back ${updates.length} examples to LangSmith dataset...`);
// Use batch update for efficiency
const exampleUpdates = updates.map((update) => ({
id: update.exampleId,
inputs: {
messages: update.messages,
coordinationLog: update.coordinationLog,
workflowJSON: update.workflowJSON,
},
}));
await client.updateExamples(exampleUpdates);
logger?.info(`Successfully updated ${updates.length} examples in LangSmith`);
}
@@ -0,0 +1,507 @@
import pc from 'picocolors';
import type {
DisplayLine,
EvaluationLifecycle,
RunConfig,
Feedback,
ExampleResult,
RunSummary,
} from './harness-types';
import type { EvalLogger } from './logger';
import { groupByEvaluator, selectScoringItems, calculateFiniteAverage } from './score-calculator';
import type { SimpleWorkflow } from '../../src/types/workflow';
/**
* Truncate a string for display.
*/
function truncate(str: string, maxLen = 50): string {
const cleaned = str.replace(/\s+/g, ' ').trim();
return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + '...' : cleaned;
}
function truncateForSingleLine(str: string, maxLen: number): string {
return truncate(str.replace(/\n/g, ' '), maxLen);
}
function exampleLabel(mode: RunConfig['mode'] | undefined): 'call' | 'ex' {
return mode === 'langsmith' ? 'call' : 'ex';
}
/**
* Format a score as percentage.
*/
function formatScore(score: number): string {
if (!Number.isFinite(score)) return 'N/A';
return `${(score * 100).toFixed(0)}%`;
}
/**
* Format duration in seconds.
*/
function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
/**
* Critical metrics to always show in verbose mode.
*/
const CRITICAL_METRICS = [
'functionality',
'connections',
'expressions',
'nodeConfiguration',
'overallScore',
'overall', // programmatic uses 'overall' not 'overallScore'
'trigger',
];
const DISPLAY_METRICS_BY_EVALUATOR: Record<string, string[]> = {
'llm-judge': ['functionality', 'connections', 'expressions', 'nodeConfiguration', 'overallScore'],
programmatic: ['overall', 'connections', 'trigger'],
pairwise: [
'pairwise_primary',
'pairwise_diagnostic',
'pairwise_judges_passed',
'pairwise_total_passes',
'pairwise_total_violations',
],
'responder-judge': [
'relevance',
'accuracy',
'completeness',
'clarity',
'criteriaMatch',
'forbiddenPhrases',
'overallScore',
],
};
const PAIRWISE_COUNT_METRICS = new Set([
'pairwise_judges_passed',
'pairwise_total_passes',
'pairwise_total_violations',
]);
const PAIRWISE_DISPLAY_NAMES: Record<string, string> = {
pairwise_primary: 'primary',
pairwise_diagnostic: 'diagnostic',
pairwise_judges_passed: 'judges_passed',
pairwise_total_passes: 'total_passes',
pairwise_total_violations: 'total_violations',
};
function getDisplayMetricName(evaluator: string, metric: string): string {
if (evaluator === 'pairwise') {
return PAIRWISE_DISPLAY_NAMES[metric] ?? metric;
}
return metric;
}
function isDisplayLine(item: unknown): item is DisplayLine {
if (typeof item !== 'object' || item === null) return false;
if (!('text' in item)) return false;
return typeof item.text === 'string';
}
function getDisplayLines(details?: Feedback['details']): DisplayLine[] | undefined {
if (!details?.displayLines || !Array.isArray(details.displayLines)) return undefined;
// Validate each item matches DisplayLine shape
if (!details.displayLines.every(isDisplayLine)) return undefined;
return details.displayLines;
}
function formatMetricValue(evaluator: string, metric: string, score: number): string {
if (evaluator === 'pairwise' && PAIRWISE_COUNT_METRICS.has(metric)) {
if (!Number.isFinite(score)) return 'N/A';
return Number.isInteger(score) ? String(score) : score.toFixed(0);
}
return formatScore(score);
}
function hasSeverityMarker(comment: string): boolean {
const lower = comment.toLowerCase();
return lower.includes('[critical]') || lower.includes('[major]') || lower.includes('[minor]');
}
function extractIssuesForLogs(evaluator: string, feedback: Feedback[]): Feedback[] {
const withComments = feedback.filter(
(f) => typeof f.comment === 'string' && f.comment.trim().length > 0 && f.metric !== 'error',
);
if (evaluator === 'llm-judge') {
return withComments.filter((f) => (f.comment ? hasSeverityMarker(f.comment) : false));
}
if (evaluator === 'pairwise') {
const isJudgeMetric = (metric: string) =>
/^judge\d+$/u.test(metric) || /^gen\d+\.judge\d+$/u.test(metric);
return withComments.filter((f) => {
if (isJudgeMetric(f.metric)) return true;
// Only show high-level status summaries when not fully passing.
if (f.metric === 'pairwise_primary' && f.score < 1) return true;
if (f.metric === 'pairwise_generation_correctness' && f.score < 1) return true;
return false;
});
}
if (evaluator === 'responder-judge') {
return withComments.filter((f) => {
// Show per-judge detail summaries
if (/^judge\d+$/u.test(f.metric)) return true;
// Show dimensions that scored below threshold
if (f.kind === 'metric' && f.score < 0.7) return true;
return false;
});
}
return withComments;
}
function formatExampleHeaderLines(args: {
mode: RunConfig['mode'] | undefined;
index: number;
status: string;
score: number;
prompt: string;
durationMs: number;
generationDurationMs?: number;
evaluationDurationMs?: number;
nodeCount: number;
}): string[] {
const {
mode,
index,
status,
score,
prompt,
durationMs,
generationDurationMs,
evaluationDurationMs,
nodeCount,
} = args;
const promptSnippet = truncateForSingleLine(prompt, 80);
const genStr =
typeof generationDurationMs === 'number' ? formatDuration(generationDurationMs) : '?';
const evalStr =
typeof evaluationDurationMs === 'number' ? formatDuration(evaluationDurationMs) : '?';
return [
`${pc.dim(`[${exampleLabel(mode)} ${index}]`)} ${status} ${formatScore(score)} ${pc.dim(
`prompt="${promptSnippet}"`,
)}`,
pc.dim(
` gen=${genStr} eval=${evalStr} total=${formatDuration(durationMs)} nodes=${nodeCount}`,
),
];
}
function splitEvaluatorFeedback(feedback: Feedback[]): {
errors: Feedback[];
nonErrorFeedback: Feedback[];
} {
return {
errors: feedback.filter((f) => f.metric === 'error'),
nonErrorFeedback: feedback.filter((f) => f.metric !== 'error'),
};
}
function formatEvaluatorLines(args: {
evaluatorName: string;
feedback: Feedback[];
}): string[] {
const { evaluatorName, feedback } = args;
const { errors, nonErrorFeedback } = splitEvaluatorFeedback(feedback);
const scoringItems = selectScoringItems(feedback);
const avgScore = calculateFiniteAverage(scoringItems);
const colorFn = scoreColor(avgScore);
const lines: string[] = [];
lines.push(
pc.dim(` ${evaluatorName}: `) +
colorFn(formatScore(avgScore)) +
pc.dim(
errors.length > 0
? ` (metrics=${nonErrorFeedback.length}, errors=${errors.length})`
: ` (metrics=${feedback.length})`,
),
);
const displayMetrics = DISPLAY_METRICS_BY_EVALUATOR[evaluatorName] ?? CRITICAL_METRICS;
const picked = nonErrorFeedback.filter((f) => displayMetrics.includes(f.metric));
if (picked.length > 0) {
const metricsLine = picked
.map((f) => {
const color = scoreColor(f.score);
const displayName = getDisplayMetricName(evaluatorName, f.metric);
return `${displayName}: ${color(formatMetricValue(evaluatorName, f.metric, f.score))}`;
})
.join(pc.dim(' | '));
lines.push(pc.dim(' ') + metricsLine);
}
if (errors.length > 0) {
const topErrors = errors.slice(0, 2);
lines.push(pc.dim(` errors(top=${topErrors.length}):`));
for (const errorItem of topErrors) {
const comment = truncateForSingleLine(errorItem.comment ?? '', 240);
lines.push(pc.dim(' - ') + pc.red(comment));
}
if (errors.length > topErrors.length) {
lines.push(pc.dim(` ... and ${errors.length - topErrors.length} more`));
}
}
const issues = extractIssuesForLogs(evaluatorName, feedback);
if (issues.length > 0) {
const top = issues.slice(0, 3);
lines.push(pc.dim(` issues(top=${top.length}):`));
for (const issue of top) {
const displayMetric = getDisplayMetricName(evaluatorName, issue.metric);
const displayLines = getDisplayLines(issue.details);
if (displayLines && displayLines.length > 0) {
// Evaluator provided custom display lines with optional color
lines.push(pc.dim(` - [${displayMetric}]`));
for (const dl of displayLines) {
const truncated = truncateForSingleLine(dl.text, 300);
const colorFn = dl.color === 'yellow' ? pc.yellow : dl.color === 'dim' ? pc.dim : pc.red;
lines.push(pc.dim(' ') + colorFn(truncated));
}
} else {
const comment = truncateForSingleLine(issue.comment ?? '', 320);
lines.push(pc.dim(` - [${displayMetric}] `) + pc.red(comment));
}
}
if (issues.length > top.length) {
lines.push(pc.dim(` ... and ${issues.length - top.length} more`));
}
}
return lines;
}
/**
* Get color based on score.
*/
function scoreColor(score: number): (s: string) => string {
if (score >= 0.9) return pc.green;
if (score >= 0.7) return pc.yellow;
return pc.red;
}
function formatExampleStatus(status: ExampleResult['status']): string {
switch (status) {
case 'pass':
return pc.green('PASS');
case 'fail':
return pc.yellow('FAIL');
case 'error':
return pc.red('ERROR');
}
}
/**
* Options for creating a console lifecycle.
*/
export interface ConsoleLifecycleOptions {
verbose: boolean;
logger: EvalLogger;
}
/**
* Create a lifecycle that logs to console.
* Verbose mode shows detailed progress, non-verbose shows summary only.
*/
export function createConsoleLifecycle(options: ConsoleLifecycleOptions): EvaluationLifecycle {
const { verbose, logger } = options;
let runMode: RunConfig['mode'] | undefined;
let evaluatorOrder: string[] = [];
return {
onStart(config: RunConfig): void {
runMode = config.mode;
evaluatorOrder = config.evaluators.map((e) => e.name);
logger.info(`\nStarting evaluation in ${pc.cyan(config.mode)} mode`);
if (typeof config.dataset === 'string') {
logger.info(`Dataset: ${pc.dim(config.dataset)}`);
} else {
logger.info(`Test cases: ${pc.dim(String(config.dataset.length))}`);
}
logger.info(
`Evaluators: ${pc.dim(config.evaluators.map((e) => e.name).join(', ') || 'none')}`,
);
logger.info('');
},
onExampleStart(index: number, total: number, prompt: string): void {
if (!verbose) return;
const totalStr = total > 0 ? String(total) : '?';
const prefix = pc.dim(`[${exampleLabel(runMode)} ${index}/${totalStr}]`);
const status = pc.yellow('START');
const promptStr = pc.dim(`prompt="${truncateForSingleLine(prompt, 80)}"`);
logger.info(`${prefix} ${status} ${promptStr}`);
},
onWorkflowGenerated: () => {},
onEvaluatorComplete: () => {},
onEvaluatorError(name: string, error: Error): void {
if (!verbose) return;
logger.error(` ERROR in ${name}: ${error.message}`);
},
onExampleComplete(index: number, result: ExampleResult): void {
if (!verbose) return;
const status = formatExampleStatus(result.status);
const nodeCount = result.workflow?.nodes?.length ?? 0;
const lines: string[] = formatExampleHeaderLines({
mode: runMode,
index,
status,
score: result.score,
prompt: result.prompt,
durationMs: result.durationMs,
generationDurationMs: result.generationDurationMs,
evaluationDurationMs: result.evaluationDurationMs,
nodeCount,
});
if (result.error) {
lines.push(pc.red(` error: ${result.error}`));
logger.info(lines.join('\n'));
return;
}
const grouped = groupByEvaluator(result.feedback);
const orderedEvaluators = [
...evaluatorOrder.filter((name) => name in grouped),
...Object.keys(grouped).filter((name) => !evaluatorOrder.includes(name)),
];
for (const evaluatorName of orderedEvaluators) {
const feedback = grouped[evaluatorName] ?? [];
lines.push(...formatEvaluatorLines({ evaluatorName, feedback }));
}
logger.info(lines.join('\n'));
},
onEnd(summary: RunSummary): void {
if (runMode === 'langsmith') return;
logger.info('\n' + pc.bold('═══════════════════ SUMMARY ═══════════════════'));
logger.info(
` Total: ${summary.totalExamples} | ` +
`Pass: ${pc.green(String(summary.passed))} | ` +
`Fail: ${pc.yellow(String(summary.failed))} | ` +
`Error: ${pc.red(String(summary.errors))}`,
);
const passRate = summary.totalExamples > 0 ? summary.passed / summary.totalExamples : 0;
logger.info(` Pass rate: ${formatScore(passRate)}`);
logger.info(` Average score: ${formatScore(summary.averageScore)}`);
logger.info(` Total time: ${formatDuration(summary.totalDurationMs)}`);
if (summary.evaluatorAverages && Object.keys(summary.evaluatorAverages).length > 0) {
logger.info(pc.dim(' Evaluator averages:'));
for (const [name, avg] of Object.entries(summary.evaluatorAverages)) {
const color = scoreColor(avg);
logger.info(` ${pc.dim(name + ':')} ${color(formatScore(avg))}`);
}
}
if (summary.langsmith) {
logger.info(pc.dim(` Experiment: ${summary.langsmith.experimentName}`));
}
logger.info(pc.bold('═══════════════════════════════════════════════\n'));
},
};
}
/**
* Create a quiet lifecycle that does nothing.
* Useful for testing or when no output is desired.
*/
export function createQuietLifecycle(): EvaluationLifecycle {
return {
onStart: () => {},
onExampleStart: () => {},
onWorkflowGenerated: () => {},
onEvaluatorComplete: () => {},
onEvaluatorError: () => {},
onExampleComplete: () => {},
onEnd: () => {},
};
}
/** Type predicate for filtering undefined values */
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}
/**
* Merge multiple partial lifecycles into a single complete lifecycle.
* All hooks will be called in order.
*/
export function mergeLifecycles(
...lifecycles: Array<Partial<EvaluationLifecycle> | undefined>
): EvaluationLifecycle {
const validLifecycles = lifecycles.filter(isDefined);
return {
onStart(config: RunConfig): void {
for (const lc of validLifecycles) {
lc.onStart?.(config);
}
},
onExampleStart(index: number, total: number, prompt: string): void {
for (const lc of validLifecycles) {
lc.onExampleStart?.(index, total, prompt);
}
},
onWorkflowGenerated(workflow: SimpleWorkflow, durationMs: number): void {
for (const lc of validLifecycles) {
lc.onWorkflowGenerated?.(workflow, durationMs);
}
},
onEvaluatorComplete(name: string, feedback: Feedback[]): void {
for (const lc of validLifecycles) {
lc.onEvaluatorComplete?.(name, feedback);
}
},
onEvaluatorError(name: string, error: Error): void {
for (const lc of validLifecycles) {
lc.onEvaluatorError?.(name, error);
}
},
onExampleComplete(index: number, result: ExampleResult): void {
for (const lc of validLifecycles) {
lc.onExampleComplete?.(index, result);
}
},
async onEnd(summary: RunSummary): Promise<void> {
for (const lc of validLifecycles) {
await lc.onEnd?.(summary);
}
},
};
}
@@ -0,0 +1,42 @@
import pc from 'picocolors';
/**
* Simple evaluation logger with verbose mode support.
*
* Usage:
* const log = createLogger(isVerbose);
* log.info('Always shown');
* log.verbose('Only shown in verbose mode');
*/
export interface EvalLogger {
/** Always shown - important info */
info: (message: string) => void;
/** Only shown in verbose mode - debug details */
verbose: (message: string) => void;
/** Success messages (green) */
success: (message: string) => void;
/** Warning messages (yellow) */
warn: (message: string) => void;
/** Error messages (red) */
error: (message: string) => void;
/** Dimmed text for secondary info */
dim: (message: string) => void;
/** Check if verbose mode is enabled */
isVerbose: boolean;
}
export function createLogger(verbose: boolean = false): EvalLogger {
return {
isVerbose: verbose,
// Keep info plain so lifecycle can apply its own formatting without double-coloring.
info: (message: string) => console.log(message),
verbose: (message: string) => {
if (verbose) console.log(pc.dim(message));
},
success: (message: string) => console.log(pc.green(message)),
warn: (message: string) => console.warn(pc.yellow(message)),
error: (message: string) => console.error(pc.red(message)),
dim: (message: string) => console.log(pc.dim(message)),
};
}
@@ -0,0 +1,241 @@
/**
* Artifact saving for v2 evaluation harness.
*
* Saves evaluation results to disk in JSON format for later analysis.
*/
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { feedbackKey } from './feedback';
import type { ExampleResult, Feedback, RunSummary } from './harness-types';
import type { EvalLogger } from './logger';
import { selectScoringItems, calculateFiniteAverage } from './score-calculator';
import type { SimpleWorkflow } from '../../src/types/workflow';
/**
* Interface for saving evaluation artifacts to disk.
*/
export interface ArtifactSaver {
/** Save a single example result */
saveExample(result: ExampleResult): void;
/** Save the final summary */
saveSummary(summary: RunSummary, results: ExampleResult[]): void;
}
/**
* Options for creating an artifact saver.
*/
export interface ArtifactSaverOptions {
/** Directory to save artifacts to */
outputDir: string;
/** Logger for optional save logs */
logger: EvalLogger;
}
/**
* Create an artifact saver for persisting evaluation results to disk.
*
* Directory structure:
* ```
* outputDir/
* ├── example-001/
* │ ├── prompt.txt
* │ ├── workflow.json
* │ └── feedback.json
* ├── example-002/
* │ └── ...
* └── summary.json
* ```
*
* @param options - Configuration options
* @returns ArtifactSaver instance or null if outputDir is not provided
*/
export function createArtifactSaver(options: ArtifactSaverOptions): ArtifactSaver {
const { outputDir, logger } = options;
// Create output directory if it doesn't exist
fs.mkdirSync(outputDir, { recursive: true });
return {
saveExample(result: ExampleResult): void {
const exampleDir = path.join(outputDir, getExampleDirName(result));
fs.mkdirSync(exampleDir, { recursive: true });
// Save prompt
fs.writeFileSync(path.join(exampleDir, 'prompt.txt'), result.prompt, 'utf-8');
// Save workflow if available
if (result.workflow) {
const workflowForExport = formatWorkflowForExport(result.workflow);
fs.writeFileSync(
path.join(exampleDir, 'workflow.json'),
JSON.stringify(workflowForExport, null, 2),
'utf-8',
);
}
// Save generated code if available (e.g., TypeScript SDK code from coding agent)
if (result.generatedCode) {
fs.writeFileSync(path.join(exampleDir, 'code.ts'), result.generatedCode, 'utf-8');
}
// Save feedback
const feedbackOutput = formatFeedbackForExport(result);
fs.writeFileSync(
path.join(exampleDir, 'feedback.json'),
JSON.stringify(feedbackOutput, null, 2),
'utf-8',
);
// Save error if present
if (result.error) {
fs.writeFileSync(path.join(exampleDir, 'error.txt'), result.error, 'utf-8');
}
logger.verbose(`Saved example ${result.index} to ${exampleDir}`);
},
saveSummary(summary: RunSummary, results: ExampleResult[]): void {
const summaryOutput = formatSummaryForExport(summary, results);
fs.writeFileSync(
path.join(outputDir, 'summary.json'),
JSON.stringify(summaryOutput, null, 2),
'utf-8',
);
logger.verbose(`Saved summary to ${path.join(outputDir, 'summary.json')}`);
},
};
}
function getExampleDirName(result: ExampleResult): string {
const index = String(result.index).padStart(3, '0');
const id = shortId(`${result.prompt}\n${result.index}`);
return `example-${index}-${id}`;
}
function shortId(input: string): string {
// Small deterministic id to avoid collisions when example folders are written concurrently
// and to keep folder names stable across reruns with the same prompts.
return createHash('md5').update(input).digest('hex').slice(0, 8);
}
/**
* Format a workflow for export (n8n-importable format).
*/
function formatWorkflowForExport(workflow: SimpleWorkflow): object {
return {
name: workflow.name ?? 'Generated Workflow',
nodes: workflow.nodes ?? [],
connections: workflow.connections ?? {},
};
}
/**
* Format feedback for export.
*/
function formatFeedbackForExport(result: ExampleResult): object {
// Group feedback by evaluator
const byEvaluator: Record<string, Feedback[]> = {};
for (const fb of result.feedback) {
const evaluator = fb.evaluator;
if (!byEvaluator[evaluator]) {
byEvaluator[evaluator] = [];
}
byEvaluator[evaluator].push(fb);
}
return {
index: result.index,
status: result.status,
durationMs: result.durationMs,
generationDurationMs: result.generationDurationMs,
evaluationDurationMs: result.evaluationDurationMs,
generationInputTokens: result.generationInputTokens,
generationOutputTokens: result.generationOutputTokens,
score: result.score,
// Include subgraph metrics if available
...(result.subgraphMetrics && {
subgraphMetrics: {
nodeCount: result.subgraphMetrics.nodeCount,
discoveryDurationMs: result.subgraphMetrics.discoveryDurationMs,
builderDurationMs: result.subgraphMetrics.builderDurationMs,
responderDurationMs: result.subgraphMetrics.responderDurationMs,
},
}),
evaluators: Object.entries(byEvaluator).map(([name, items]) => ({
name,
feedback: items.map((f) => ({
key: feedbackKey(f),
metric: f.metric,
score: f.score,
kind: f.kind,
...(f.comment ? { comment: f.comment } : {}),
})),
averageScore: calculateFiniteAverage(selectScoringItems(items)),
})),
allFeedback: result.feedback,
};
}
/**
* Format summary for export.
*/
function formatSummaryForExport(summary: RunSummary, results: ExampleResult[]): object {
const resultsSorted = [...results].sort((a, b) => a.index - b.index);
// Calculate per-evaluator statistics
const evaluatorStats: Record<string, { scores: number[] }> = {};
for (const result of resultsSorted) {
const byEvaluator: Record<string, Feedback[]> = {};
for (const fb of result.feedback) {
const evaluator = fb.evaluator;
if (!byEvaluator[evaluator]) byEvaluator[evaluator] = [];
byEvaluator[evaluator].push(fb);
}
for (const [evaluator, items] of Object.entries(byEvaluator)) {
if (!evaluatorStats[evaluator]) {
evaluatorStats[evaluator] = { scores: [] };
}
const scoringItems = selectScoringItems(items);
const avg = calculateFiniteAverage(scoringItems);
evaluatorStats[evaluator].scores.push(avg);
}
}
const evaluatorAverages: Record<string, number> = {};
for (const [name, stats] of Object.entries(evaluatorStats)) {
evaluatorAverages[name] = stats.scores.reduce((a, b) => a + b, 0) / stats.scores.length;
}
return {
timestamp: new Date().toISOString(),
totalExamples: summary.totalExamples,
passed: summary.passed,
failed: summary.failed,
errors: summary.errors,
passRate: summary.totalExamples > 0 ? summary.passed / summary.totalExamples : 0,
averageScore: summary.averageScore,
totalDurationMs: summary.totalDurationMs,
evaluatorAverages,
results: resultsSorted.map((r) => ({
index: r.index,
prompt: r.prompt.slice(0, 100) + (r.prompt.length > 100 ? '...' : ''),
status: r.status,
score: r.score,
durationMs: r.durationMs,
generationDurationMs: r.generationDurationMs,
generationInputTokens: r.generationInputTokens,
generationOutputTokens: r.generationOutputTokens,
...(r.subgraphMetrics && {
nodeCount: r.subgraphMetrics.nodeCount,
discoveryDurationMs: r.subgraphMetrics.discoveryDurationMs,
builderDurationMs: r.subgraphMetrics.builderDurationMs,
responderDurationMs: r.subgraphMetrics.responderDurationMs,
}),
...(r.error ? { error: r.error } : {}),
})),
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
/**
* Score Calculation Utilities
*
* Provides functions for calculating weighted scores and aggregating
* feedback from multiple evaluators.
*/
import type { Feedback } from './harness-types';
/**
* Weights for each evaluator type.
*/
export interface ScoreWeights {
[evaluatorPrefix: string]: number;
}
/**
* Result of score aggregation.
*/
export interface AggregatedScore {
/** Weighted overall score (0-1) */
overall: number;
/** Average score per evaluator */
byEvaluator: Record<string, number>;
/** Average score per category */
byCategory: Record<string, number>;
}
/**
* Parsed feedback key structure.
*/
export interface FeedbackKeyParts {
evaluator: string;
category: string;
subcategory?: string;
}
/**
* Default weights for standard evaluators (cross-evaluator weighting).
*
* This is the *harness-level* weighting between evaluators like `llm-judge`,
* `programmatic`, and `pairwise`. It is independent from any evaluator-internal
* weighting (e.g. LLM judge category weights).
* Weights should sum to approximately 1.0.
*/
export const DEFAULT_EVALUATOR_WEIGHTS: ScoreWeights = {
'llm-judge': 0.35,
programmatic: 0.25,
pairwise: 0.25,
similarity: 0.15,
'binary-checks': 0,
};
/**
* @deprecated Use `DEFAULT_EVALUATOR_WEIGHTS` (kept for backwards compatibility within the package).
*/
export const DEFAULT_WEIGHTS: ScoreWeights = DEFAULT_EVALUATOR_WEIGHTS;
/** Default weight for unknown evaluators */
const UNKNOWN_EVALUATOR_WEIGHT = 0.1;
/**
* Parse a feedback key into its component parts.
*
* @example
* parseFeedbackKey('llm-judge.functionality')
* // => { evaluator: 'llm-judge', category: 'functionality' }
*
* parseFeedbackKey('pairwise.gen1.majorityPass')
* // => { evaluator: 'pairwise', category: 'gen1', subcategory: 'majorityPass' }
*/
export function parseFeedbackKey(key: string): FeedbackKeyParts {
const parts = key.split('.');
return {
evaluator: parts[0],
category: parts[1] ?? '',
subcategory: parts[2],
};
}
/**
* Extract the category from a feedback key.
*
* @example
* extractCategory('llm-judge.functionality') // => 'functionality'
* extractCategory('programmatic.trigger') // => 'trigger'
*/
export function extractCategory(key: string): string {
return parseFeedbackKey(key).category;
}
/**
* Group feedback items by their evaluator prefix.
*
* @example
* groupByEvaluator([
* { evaluator: 'llm-judge', metric: 'a', score: 0.8 },
* { evaluator: 'programmatic', metric: 'b', score: 0.6 },
* ])
* // => { 'llm-judge': [...], 'programmatic': [...] }
*/
export function groupByEvaluator(feedback: Feedback[]): Record<string, Feedback[]> {
const grouped: Record<string, Feedback[]> = {};
for (const item of feedback) {
const evaluator = item.evaluator;
if (!grouped[evaluator]) {
grouped[evaluator] = [];
}
grouped[evaluator].push(item);
}
return grouped;
}
/**
* Calculate average score for an array of feedback items.
*/
export function calculateFiniteAverage(items: Feedback[]): number {
if (items.length === 0) return 0;
const finiteScores = items.map((f) => f.score).filter((s) => Number.isFinite(s));
if (finiteScores.length === 0) return 0;
const total = finiteScores.reduce((sum, s) => sum + s, 0);
return total / finiteScores.length;
}
/**
* Pick which feedback items should be used for evaluator-level scoring.
*
* Order of preference:
* - `kind: 'score'` (single authoritative score)
* - `kind: 'metric'` (stable category metrics)
* - any non-`detail` items
* - otherwise, all items
*/
export function selectScoringItems(items: Feedback[]): Feedback[] {
const scoreItems = items.filter((i) => i.kind === 'score');
if (scoreItems.length > 0) return scoreItems;
const metricItems = items.filter((i) => i.kind === 'metric');
if (metricItems.length > 0) return metricItems;
const nonDetailItems = items.filter((i) => i.kind !== 'detail');
if (nonDetailItems.length > 0) return nonDetailItems;
return items;
}
/**
* Calculate weighted overall score from feedback.
*
* Each evaluator's average score is weighted according to the weights map.
* Unknown evaluators receive the default weight.
*
* @param feedback - Array of feedback items
* @param weights - Weight per evaluator (defaults to DEFAULT_WEIGHTS)
* @returns Weighted average score (0-1)
*/
export function calculateWeightedScore(
feedback: Feedback[],
weights: ScoreWeights = DEFAULT_EVALUATOR_WEIGHTS,
): number {
if (feedback.length === 0) return 0;
const byEvaluator = groupByEvaluator(feedback);
let totalWeight = 0;
let weightedSum = 0;
for (const [evaluator, items] of Object.entries(byEvaluator)) {
const avgScore = calculateFiniteAverage(selectScoringItems(items));
const weight = weights[evaluator] ?? UNKNOWN_EVALUATOR_WEIGHT;
weightedSum += avgScore * weight;
totalWeight += weight;
}
return totalWeight > 0 ? weightedSum / totalWeight : 0;
}
/**
* Compute per-evaluator average scores from a set of example results.
*
* Groups feedback by evaluator, selects scoring items, and averages
* across all examples. Shared between local and LangSmith evaluation runners.
*/
export function computeEvaluatorAverages(
results: Array<{ feedback: Feedback[] }>,
): Record<string, number> {
const evaluatorStats: Record<string, number[]> = {};
for (const result of results) {
const byEvaluator = groupByEvaluator(result.feedback);
for (const [evaluator, items] of Object.entries(byEvaluator)) {
if (!evaluatorStats[evaluator]) evaluatorStats[evaluator] = [];
const scoringItems = selectScoringItems(items);
evaluatorStats[evaluator].push(calculateFiniteAverage(scoringItems));
}
}
const averages: Record<string, number> = {};
for (const [name, scores] of Object.entries(evaluatorStats)) {
averages[name] = scores.reduce((a, b) => a + b, 0) / scores.length;
}
return averages;
}
/**
* Aggregate scores by evaluator and category.
*
* @param feedback - Array of feedback items
* @returns Aggregated scores with overall, by-evaluator, and by-category breakdowns
*/
export function aggregateScores(feedback: Feedback[]): AggregatedScore {
if (feedback.length === 0) {
return {
overall: 0,
byEvaluator: {},
byCategory: {},
};
}
// Calculate overall weighted score
const overall = calculateWeightedScore(feedback);
// Calculate by-evaluator averages
const byEvaluator: Record<string, number> = {};
const grouped = groupByEvaluator(feedback);
for (const [evaluator, items] of Object.entries(grouped)) {
byEvaluator[evaluator] = calculateFiniteAverage(selectScoringItems(items));
}
// Calculate by-category averages
const byCategory: Record<string, number> = {};
const categoryGroups: Record<string, Feedback[]> = {};
for (const item of feedback) {
if (item.kind === 'detail') continue;
const category = item.metric.split('.')[0] ?? '';
if (category) {
if (!categoryGroups[category]) {
categoryGroups[category] = [];
}
categoryGroups[category].push(item);
}
}
for (const [category, items] of Object.entries(categoryGroups)) {
byCategory[category] = calculateFiniteAverage(items);
}
return {
overall,
byEvaluator,
byCategory,
};
}
@@ -0,0 +1,326 @@
/**
* Local subgraph evaluation runner.
*
* Runs subgraph evaluations against a local dataset (JSON file)
* without requiring LangSmith.
*/
import type { INodeTypeDescription } from 'n8n-workflow';
import pLimit from 'p-limit';
import { runWithOptionalLimiter, withTimeout, runEvaluatorsOnExample } from './evaluation-helpers';
import type {
Evaluator,
EvaluationContext,
Feedback,
RunSummary,
ExampleResult,
EvaluationLifecycle,
} from './harness-types';
import type { EvalLogger } from './logger';
import { createArtifactSaver } from './output';
import { calculateWeightedScore, computeEvaluatorAverages } from './score-calculator';
import {
extractPreComputedState,
deserializeMessages,
type SubgraphRunFn,
type PreComputedState,
type SubgraphName,
} from './subgraph-runner';
import { regenerateWorkflowState } from './workflow-regenerator';
import type { SimpleWorkflow } from '../../src/types/workflow';
import { writeBackToDatasetFile, type DatasetWriteBackEntry } from '../cli/dataset-file-loader';
import type { ResponderEvalCriteria } from '../evaluators/responder/responder-judge.prompt';
import type { ResolvedStageLLMs } from '../support/environment';
const DEFAULT_PASS_THRESHOLD = 0.7;
interface LocalSubgraphEvaluationConfig {
subgraph: SubgraphName;
subgraphRunner: SubgraphRunFn;
evaluators: Array<Evaluator<EvaluationContext>>;
examples: Array<{ inputs: Record<string, unknown> }>;
concurrency: number;
lifecycle?: Partial<EvaluationLifecycle>;
logger: EvalLogger;
outputDir?: string;
timeoutMs?: number;
passThreshold?: number;
/** Run full workflow generation from prompt instead of using pre-computed state */
regenerate?: boolean;
/** Write regenerated state back to dataset file */
writeBack?: boolean;
/** Path to the dataset file (required for write-back) */
datasetFilePath?: string;
/** LLMs for regeneration (required if regenerate is true) */
llms?: ResolvedStageLLMs;
/** Parsed node types for regeneration (required if regenerate is true) */
parsedNodeTypes?: INodeTypeDescription[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function extractPromptFromInputs(inputs: Record<string, unknown>): string {
if (typeof inputs.prompt === 'string') return inputs.prompt;
if (Array.isArray(inputs.messages) && inputs.messages.length > 0) {
const first: unknown = inputs.messages[0];
if (isRecord(first) && typeof first.content === 'string') return first.content;
}
throw new Error('No prompt found in inputs');
}
function extractResponderEvals(
inputs: Record<string, unknown>,
logger?: EvalLogger,
index?: number,
): ResponderEvalCriteria | undefined {
const raw = inputs.responderEvals;
if (!isRecord(raw)) {
logger?.verbose(
`[${index ?? '?'}] Example missing responderEvals field - evaluator will report an error`,
);
return undefined;
}
if (typeof raw.type !== 'string' || typeof raw.criteria !== 'string') {
logger?.verbose(
`[${index ?? '?'}] Example has invalid responderEvals (missing type or criteria) - evaluator will report an error`,
);
return undefined;
}
return { type: raw.type, criteria: raw.criteria } as ResponderEvalCriteria;
}
/**
* Run a subgraph evaluation against a local dataset (no LangSmith required).
*/
export async function runLocalSubgraphEvaluation(
config: LocalSubgraphEvaluationConfig,
): Promise<RunSummary> {
const {
subgraph,
subgraphRunner,
evaluators,
examples,
concurrency,
lifecycle,
logger,
outputDir,
timeoutMs,
passThreshold = DEFAULT_PASS_THRESHOLD,
regenerate,
writeBack,
datasetFilePath,
llms,
parsedNodeTypes,
} = config;
if (regenerate && (!llms || !parsedNodeTypes)) {
throw new Error('`regenerate` mode requires `llms` and `parsedNodeTypes`');
}
if (writeBack && !datasetFilePath) {
throw new Error('`writeBack` requires `datasetFilePath`');
}
const llmCallLimiter = pLimit(concurrency);
const artifactSaver = outputDir ? createArtifactSaver({ outputDir, logger }) : null;
const capturedResults: ExampleResult[] = [];
const writeBackEntries: DatasetWriteBackEntry[] = [];
const stats = {
total: 0,
passed: 0,
failed: 0,
errors: 0,
scoreSum: 0,
durationSumMs: 0,
};
logger.info(
`Starting local subgraph "${subgraph}" evaluation with ${examples.length} examples...`,
);
const evalStartTime = Date.now();
const limit = pLimit(concurrency);
await Promise.all(
examples.map(
async (example, idx) =>
await limit(async () => {
const index = idx + 1;
const { inputs } = example;
const prompt = extractPromptFromInputs(inputs);
const startTime = Date.now();
try {
let state: PreComputedState;
if (regenerate && llms && parsedNodeTypes) {
// Regenerate state from prompt
logger.verbose(`[${index}] Regenerating workflow state from prompt...`);
const regenStart = Date.now();
const regenerated = await regenerateWorkflowState({
prompt,
llms,
parsedNodeTypes,
timeoutMs,
logger,
});
const regenDurationMs = Date.now() - regenStart;
logger.verbose(`[${index}] Regeneration completed in ${regenDurationMs}ms`);
state = {
messages: deserializeMessages(regenerated.messages),
coordinationLog: regenerated.coordinationLog,
workflowJSON: regenerated.workflowJSON,
discoveryContext: regenerated.discoveryContext,
previousSummary: regenerated.previousSummary,
};
if (writeBack) {
writeBackEntries.push({
index: idx,
messages: regenerated.messages,
coordinationLog: regenerated.coordinationLog,
workflowJSON: regenerated.workflowJSON,
});
}
} else {
// Use pre-computed state from dataset
state = extractPreComputedState(inputs);
}
const genStart = Date.now();
const subgraphResult = await runWithOptionalLimiter(async () => {
return await withTimeout({
promise: subgraphRunner(state),
timeoutMs,
label: `subgraph:${subgraph}`,
});
}, llmCallLimiter);
const genDurationMs = Date.now() - genStart;
const context: EvaluationContext & Record<string, unknown> = {
prompt,
llmCallLimiter,
timeoutMs,
};
if (subgraph === 'responder' && subgraphResult.response) {
context.responderOutput = subgraphResult.response;
context.workflowJSON = state.workflowJSON;
const evalCriteria = extractResponderEvals(inputs, logger, index);
if (evalCriteria) {
context.responderEvals = evalCriteria;
}
}
const emptyWorkflow: SimpleWorkflow = { name: '', nodes: [], connections: {} };
const evalStart = Date.now();
const feedback = await runEvaluatorsOnExample(
evaluators,
emptyWorkflow,
context,
timeoutMs,
);
const evalDurationMs = Date.now() - evalStart;
const totalDurationMs = Date.now() - startTime;
const score = calculateWeightedScore(feedback);
const hasError = feedback.some((f) => f.metric === 'error');
const status = hasError ? 'error' : score >= passThreshold ? 'pass' : 'fail';
stats.total++;
stats.scoreSum += score;
stats.durationSumMs += totalDurationMs;
if (status === 'pass') stats.passed++;
else if (status === 'fail') stats.failed++;
else stats.errors++;
const result: ExampleResult = {
index,
prompt,
status,
score,
feedback,
durationMs: totalDurationMs,
generationDurationMs: genDurationMs,
evaluationDurationMs: evalDurationMs,
subgraphOutput: {
response: subgraphResult.response,
workflow: subgraphResult.workflow,
},
};
artifactSaver?.saveExample(result);
capturedResults.push(result);
lifecycle?.onExampleComplete?.(index, result);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const totalDurationMs = Date.now() - startTime;
const feedback: Feedback[] = [
{
evaluator: 'runner',
metric: 'error',
score: 0,
kind: 'score',
comment: errorMessage,
},
];
stats.total++;
stats.errors++;
stats.durationSumMs += totalDurationMs;
const result: ExampleResult = {
index,
prompt,
status: 'error',
score: 0,
feedback,
durationMs: totalDurationMs,
error: errorMessage,
};
artifactSaver?.saveExample(result);
capturedResults.push(result);
lifecycle?.onExampleComplete?.(index, result);
}
}),
),
);
logger.info(
`Local subgraph evaluation completed in ${((Date.now() - evalStartTime) / 1000).toFixed(1)}s`,
);
const evaluatorAverages = computeEvaluatorAverages(capturedResults);
const summary: RunSummary = {
totalExamples: stats.total,
passed: stats.passed,
failed: stats.failed,
errors: stats.errors,
averageScore: stats.total > 0 ? stats.scoreSum / stats.total : 0,
totalDurationMs: stats.durationSumMs,
evaluatorAverages,
};
if (artifactSaver) {
artifactSaver.saveSummary(summary, capturedResults);
}
// Write back regenerated state if requested
if (writeBack && datasetFilePath && writeBackEntries.length > 0) {
logger.info(`Writing back ${writeBackEntries.length} examples to ${datasetFilePath}...`);
writeBackToDatasetFile(datasetFilePath, writeBackEntries);
logger.info('Write-back complete');
}
await lifecycle?.onEnd?.(summary);
return summary;
}
@@ -0,0 +1,501 @@
/**
* Subgraph evaluation runner.
*
* Orchestrates running evaluations targeting a specific subgraph (e.g., responder)
* using pre-computed state from LangSmith dataset examples.
*/
import type { Client as LangsmithClient } from 'langsmith/client';
import { evaluate } from 'langsmith/evaluation';
import type { Run, Example } from 'langsmith/schemas';
import { traceable } from 'langsmith/traceable';
import type { INodeTypeDescription } from 'n8n-workflow';
import pLimit from 'p-limit';
import { runWithOptionalLimiter, withTimeout, runEvaluatorsOnExample } from './evaluation-helpers';
import { toLangsmithEvaluationResult } from './feedback';
import type {
Evaluator,
EvaluationContext,
Feedback,
RunSummary,
EvaluationLifecycle,
LangsmithOptions,
ExampleResult,
} from './harness-types';
import {
writeBackToLangSmithDataset,
type LangSmithWriteBackEntry,
} from './langsmith-dataset-writer';
import type { EvalLogger } from './logger';
import { createArtifactSaver } from './output';
import { calculateWeightedScore, computeEvaluatorAverages } from './score-calculator';
import {
extractPreComputedState,
deserializeMessages,
type SubgraphRunFn,
type SubgraphResult,
type PreComputedState,
type SubgraphName,
} from './subgraph-runner';
import { regenerateWorkflowState } from './workflow-regenerator';
import type { SimpleWorkflow } from '../../src/types/workflow';
import type { ResponderEvalCriteria } from '../evaluators/responder/responder-judge.prompt';
import type { ResolvedStageLLMs } from '../support/environment';
const DEFAULT_PASS_THRESHOLD = 0.7;
interface SubgraphEvaluationConfig {
subgraph: SubgraphName;
subgraphRunner: SubgraphRunFn;
evaluators: Array<Evaluator<EvaluationContext>>;
datasetName: string;
langsmithClient: LangsmithClient;
langsmithOptions: LangsmithOptions;
lifecycle?: Partial<EvaluationLifecycle>;
logger: EvalLogger;
outputDir?: string;
timeoutMs?: number;
passThreshold?: number;
/** Run full workflow generation from prompt instead of using pre-computed state */
regenerate?: boolean;
/** Write regenerated state back to LangSmith dataset */
writeBack?: boolean;
/** LLMs for regeneration (required if regenerate is true) */
llms?: ResolvedStageLLMs;
/** Parsed node types for regeneration (required if regenerate is true) */
parsedNodeTypes?: INodeTypeDescription[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isFeedback(value: unknown): value is Feedback {
const kinds = new Set(['score', 'metric', 'detail'] as const);
return (
isRecord(value) &&
typeof value.evaluator === 'string' &&
typeof value.metric === 'string' &&
typeof value.score === 'number' &&
typeof value.kind === 'string' &&
kinds.has(value.kind as 'score' | 'metric' | 'detail')
);
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function extractPromptFromInputs(inputs: Record<string, unknown>): string {
if (typeof inputs.prompt === 'string') return inputs.prompt;
if (Array.isArray(inputs.messages) && inputs.messages.length > 0) {
const first: unknown = inputs.messages[0];
if (isRecord(first) && typeof first.content === 'string') return first.content;
}
throw new Error('No prompt found in inputs');
}
function extractResponderEvals(
inputs: Record<string, unknown>,
logger?: EvalLogger,
index?: number,
): ResponderEvalCriteria | undefined {
const raw = inputs.responderEvals;
if (!isRecord(raw)) {
logger?.verbose(
`[${index ?? '?'}] Example missing responderEvals field - evaluator will report an error`,
);
return undefined;
}
if (typeof raw.type !== 'string' || typeof raw.criteria !== 'string') {
logger?.verbose(
`[${index ?? '?'}] Example has invalid responderEvals (missing type or criteria) - evaluator will report an error`,
);
return undefined;
}
return { type: raw.type, criteria: raw.criteria } as ResponderEvalCriteria;
}
interface SubgraphTargetOutput {
response?: string;
workflow?: SimpleWorkflow;
prompt: string;
feedback: Feedback[];
/** Example ID for write-back (only present when regenerate is used) */
exampleId?: string;
}
interface ResolveStateResult {
state: PreComputedState;
writeBackEntry?: LangSmithWriteBackEntry;
}
/**
* Resolve the pre-computed state for a subgraph evaluation example.
* Either regenerates from prompt or uses the pre-computed state from the dataset.
*/
async function resolveState(args: {
inputs: Record<string, unknown>;
regenerate?: boolean;
llms?: ResolvedStageLLMs;
parsedNodeTypes?: INodeTypeDescription[];
timeoutMs?: number;
logger: EvalLogger;
index: number;
prompt: string;
exampleId?: string;
writeBack?: boolean;
}): Promise<ResolveStateResult> {
if (args.regenerate && args.llms && args.parsedNodeTypes) {
args.logger.verbose(`[${args.index}] Regenerating workflow state from prompt...`);
const regenStart = Date.now();
const regenerated = await regenerateWorkflowState({
prompt: args.prompt,
llms: args.llms,
parsedNodeTypes: args.parsedNodeTypes,
timeoutMs: args.timeoutMs,
logger: args.logger,
});
args.logger.verbose(`[${args.index}] Regeneration completed in ${Date.now() - regenStart}ms`);
const state: PreComputedState = {
messages: deserializeMessages(regenerated.messages),
coordinationLog: regenerated.coordinationLog,
workflowJSON: regenerated.workflowJSON,
discoveryContext: regenerated.discoveryContext,
previousSummary: regenerated.previousSummary,
};
const writeBackEntry =
args.writeBack && args.exampleId
? {
exampleId: args.exampleId,
messages: regenerated.messages,
coordinationLog: regenerated.coordinationLog,
workflowJSON: regenerated.workflowJSON,
}
: undefined;
return { state, writeBackEntry };
}
return { state: extractPreComputedState(args.inputs) };
}
/**
* Pre-load example IDs from a LangSmith dataset for write-back tracking.
*/
async function preloadExampleIds(
lsClient: LangsmithClient,
datasetName: string,
logger: EvalLogger,
): Promise<string[]> {
logger.verbose('Pre-loading example IDs for write-back tracking...');
const dataset = await lsClient.readDataset({ datasetName });
const examples = lsClient.listExamples({ datasetId: dataset.id });
const ids: string[] = [];
for await (const example of examples) {
ids.push(example.id);
}
logger.verbose(`Loaded ${ids.length} example IDs for write-back`);
return ids;
}
/**
* Extract experiment and dataset IDs from LangSmith evaluate() results.
*/
async function extractExperimentIds(
experimentResults: Awaited<ReturnType<typeof evaluate>>,
logger: EvalLogger,
): Promise<{ experimentId?: string; datasetId?: string }> {
try {
const manager = (
experimentResults as unknown as {
manager?: { _getExperiment?: () => { id: string }; datasetId?: Promise<string> };
}
).manager;
return {
experimentId: manager?._getExperiment?.()?.id,
datasetId: manager?.datasetId ? await manager.datasetId : undefined,
};
} catch {
logger.verbose('Could not extract LangSmith IDs from experiment results');
return {};
}
}
/**
* Run a subgraph evaluation against a LangSmith dataset.
*/
export async function runSubgraphEvaluation(config: SubgraphEvaluationConfig): Promise<RunSummary> {
const {
subgraph,
subgraphRunner,
evaluators,
datasetName,
langsmithClient: lsClient,
langsmithOptions,
lifecycle,
logger,
outputDir,
timeoutMs,
passThreshold = DEFAULT_PASS_THRESHOLD,
regenerate,
writeBack,
llms,
parsedNodeTypes,
} = config;
if (regenerate && (!llms || !parsedNodeTypes)) {
throw new Error('`regenerate` mode requires `llms` and `parsedNodeTypes`');
}
process.env.LANGSMITH_TRACING = 'true';
lifecycle?.onStart?.({
mode: 'langsmith',
dataset: datasetName,
generateWorkflow: async () => ({ name: '', nodes: [], connections: {} }),
evaluators,
langsmithOptions,
langsmithClient: lsClient,
logger,
});
const llmCallLimiter = pLimit(langsmithOptions.concurrency);
const artifactSaver = outputDir ? createArtifactSaver({ outputDir, logger }) : null;
const capturedResults: ExampleResult[] = [];
const writeBackEntries: LangSmithWriteBackEntry[] = [];
let targetCallCount = 0;
const stats = {
total: 0,
passed: 0,
failed: 0,
errors: 0,
scoreSum: 0,
durationSumMs: 0,
};
const traceableSubgraphRun = traceable(
async (args: {
state: PreComputedState;
runner: SubgraphRunFn;
genTimeoutMs?: number;
}): Promise<SubgraphResult> => {
return await runWithOptionalLimiter(async () => {
return await withTimeout({
promise: args.runner(args.state),
timeoutMs: args.genTimeoutMs,
label: `subgraph:${subgraph}`,
});
}, llmCallLimiter);
},
{
name: `subgraph_${subgraph}`,
run_type: 'chain',
client: lsClient,
},
);
// Pre-load example IDs if write-back is needed
const exampleIds = writeBack ? await preloadExampleIds(lsClient, datasetName, logger) : [];
const target = async (inputs: Record<string, unknown>): Promise<SubgraphTargetOutput> => {
targetCallCount++;
const index = targetCallCount;
const prompt = extractPromptFromInputs(inputs);
// Use index-1 since targetCallCount is 1-based
const exampleId = exampleIds[index - 1];
const startTime = Date.now();
try {
const { state, writeBackEntry } = await resolveState({
inputs,
regenerate,
llms,
parsedNodeTypes,
timeoutMs,
logger,
index,
prompt,
exampleId,
writeBack,
});
if (writeBackEntry) writeBackEntries.push(writeBackEntry);
const genStart = Date.now();
const subgraphResult = await traceableSubgraphRun({
state,
runner: subgraphRunner,
genTimeoutMs: timeoutMs,
});
const genDurationMs = Date.now() - genStart;
// Build evaluation context with subgraph-specific fields
const context: EvaluationContext & Record<string, unknown> = {
prompt,
llmCallLimiter,
timeoutMs,
};
if (subgraph === 'responder' && subgraphResult.response) {
context.responderOutput = subgraphResult.response;
context.workflowJSON = state.workflowJSON;
const evalCriteria = extractResponderEvals(inputs, logger, index);
if (evalCriteria) {
context.responderEvals = evalCriteria;
}
}
// Use empty workflow for evaluators that expect it
const emptyWorkflow: SimpleWorkflow = { name: '', nodes: [], connections: {} };
// Run evaluators
const evalStart = Date.now();
const feedback = await runEvaluatorsOnExample(evaluators, emptyWorkflow, context, timeoutMs);
const evalDurationMs = Date.now() - evalStart;
const totalDurationMs = Date.now() - startTime;
const score = calculateWeightedScore(feedback);
const hasError = feedback.some((f) => f.metric === 'error');
const status = hasError ? 'error' : score >= passThreshold ? 'pass' : 'fail';
stats.total++;
stats.scoreSum += score;
stats.durationSumMs += totalDurationMs;
if (status === 'pass') stats.passed++;
else if (status === 'fail') stats.failed++;
else stats.errors++;
const result: ExampleResult = {
index,
prompt,
status,
score,
feedback,
durationMs: totalDurationMs,
generationDurationMs: genDurationMs,
evaluationDurationMs: evalDurationMs,
subgraphOutput: {
response: subgraphResult.response,
workflow: subgraphResult.workflow,
},
};
artifactSaver?.saveExample(result);
capturedResults.push(result);
lifecycle?.onExampleComplete?.(index, result);
return {
response: subgraphResult.response,
workflow: subgraphResult.workflow,
prompt,
feedback,
exampleId,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const totalDurationMs = Date.now() - startTime;
const feedback: Feedback[] = [
{ evaluator: 'runner', metric: 'error', score: 0, kind: 'score', comment: errorMessage },
];
stats.total++;
stats.errors++;
stats.durationSumMs += totalDurationMs;
const result: ExampleResult = {
index,
prompt,
status: 'error',
score: 0,
feedback,
durationMs: totalDurationMs,
error: errorMessage,
};
artifactSaver?.saveExample(result);
capturedResults.push(result);
lifecycle?.onExampleComplete?.(index, result);
return { prompt, feedback, exampleId };
}
};
const feedbackExtractor = async (rootRun: Run, _example?: Example) => {
const outputs = rootRun.outputs;
const feedback =
isRecord(outputs) && isUnknownArray(outputs.feedback) && outputs.feedback.every(isFeedback)
? outputs.feedback
: undefined;
if (!feedback) {
return [{ key: 'evaluationError', score: 0, comment: 'No feedback found' }];
}
return feedback.map((fb) => toLangsmithEvaluationResult(fb));
};
logger.info(`Starting subgraph "${subgraph}" evaluation with dataset "${datasetName}"...`);
const evalStartTime = Date.now();
const experimentResults = await evaluate(target, {
data: datasetName,
evaluators: [feedbackExtractor],
experimentPrefix: langsmithOptions.experimentName,
maxConcurrency: langsmithOptions.concurrency,
client: lsClient,
...(langsmithOptions.repetitions > 1 && {
numRepetitions: langsmithOptions.repetitions,
}),
metadata: {
subgraph,
repetitions: langsmithOptions.repetitions,
concurrency: langsmithOptions.concurrency,
...langsmithOptions.experimentMetadata,
},
});
logger.info(
`Subgraph evaluation completed in ${((Date.now() - evalStartTime) / 1000).toFixed(1)}s (target called ${targetCallCount} times)`,
);
logger.verbose('Flushing pending trace batches...');
await lsClient.awaitPendingTraceBatches();
const experimentName = experimentResults.experimentName;
logger.info(`Experiment completed: ${experimentName}`);
const { experimentId, datasetId } = await extractExperimentIds(experimentResults, logger);
const evaluatorAverages = computeEvaluatorAverages(capturedResults);
const summary: RunSummary = {
totalExamples: stats.total,
passed: stats.passed,
failed: stats.failed,
errors: stats.errors,
averageScore: stats.total > 0 ? stats.scoreSum / stats.total : 0,
totalDurationMs: stats.durationSumMs,
evaluatorAverages,
...(experimentName &&
experimentId &&
datasetId && {
langsmith: { experimentName, experimentId, datasetId },
}),
};
if (artifactSaver) {
artifactSaver.saveSummary(summary, capturedResults);
}
// Write back regenerated state if requested
if (writeBack && writeBackEntries.length > 0) {
await writeBackToLangSmithDataset(lsClient, writeBackEntries, logger);
}
await lifecycle?.onEnd?.(summary);
return summary;
}
@@ -0,0 +1,131 @@
import type { BaseMessage } from '@langchain/core/messages';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
import { createResponderAgent, invokeResponderAgent } from '@/agents/responder.agent';
import type { ResponderContext } from '@/agents/responder.agent';
import type { CoordinationLogEntry } from '@/types/coordination';
import type { DiscoveryContext } from '@/types/discovery-types';
import type { SimpleWorkflow } from '@/types/workflow';
import type { ResolvedStageLLMs } from '../support/environment';
export type SubgraphName = 'responder' | 'discovery' | 'builder' | 'configurator';
/**
* Pre-computed state extracted from a dataset example.
* Contains all fields needed to run a subgraph without re-running upstream phases.
*/
export interface PreComputedState {
messages: BaseMessage[];
coordinationLog: CoordinationLogEntry[];
workflowJSON: SimpleWorkflow;
discoveryContext?: DiscoveryContext | null;
previousSummary?: string;
}
export interface SubgraphResult {
/** The text response (for responder subgraph) */
response?: string;
/** The workflow output (for builder/configurator subgraphs) */
workflow?: SimpleWorkflow;
}
export type SubgraphRunFn = (state: PreComputedState) => Promise<SubgraphResult>;
interface SubgraphRunnerConfig {
subgraph: SubgraphName;
llms: ResolvedStageLLMs;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Deserialize messages from dataset JSON into BaseMessage instances.
* Dataset messages are stored as plain objects with `type` and `content` fields.
*/
export function deserializeMessages(raw: unknown[]): BaseMessage[] {
return raw.map((m) => {
if (!isRecord(m)) throw new Error('Invalid message format: expected object');
const type = m.type as string;
const content = (m.content as string) ?? '';
switch (type) {
case 'human':
return new HumanMessage(content);
case 'ai':
return new AIMessage(content);
default:
return new HumanMessage(content);
}
});
}
/**
* Extract pre-computed state from a dataset example's inputs.
*/
export function extractPreComputedState(inputs: Record<string, unknown>): PreComputedState {
const rawMessages = inputs.messages;
if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
throw new Error('Dataset example missing required "messages" field');
}
const messages = deserializeMessages(rawMessages);
const rawCoordinationLog = inputs.coordinationLog;
if (!Array.isArray(rawCoordinationLog)) {
throw new Error('Dataset example missing required "coordinationLog" field');
}
const rawWorkflow = inputs.workflowJSON;
if (!isRecord(rawWorkflow)) {
throw new Error('Dataset example missing required "workflowJSON" field');
}
return {
messages,
coordinationLog: rawCoordinationLog as CoordinationLogEntry[],
workflowJSON: rawWorkflow as SimpleWorkflow,
discoveryContext: isRecord(inputs.discoveryContext)
? (inputs.discoveryContext as unknown as DiscoveryContext)
: null,
previousSummary:
typeof inputs.previousSummary === 'string' ? inputs.previousSummary : undefined,
};
}
/**
* Create a function that runs only the targeted subgraph with pre-computed state.
*
* For the responder subgraph: creates the agent via createResponderAgent, invokes it
* with the pre-computed state, and returns the response text.
*/
export function createSubgraphRunner(config: SubgraphRunnerConfig): SubgraphRunFn {
const { subgraph, llms } = config;
switch (subgraph) {
case 'responder':
return async (state: PreComputedState): Promise<SubgraphResult> => {
const agent = createResponderAgent({ llm: llms.responder });
const context: ResponderContext = {
messages: state.messages,
coordinationLog: state.coordinationLog,
workflowJSON: state.workflowJSON,
discoveryContext: state.discoveryContext,
previousSummary: state.previousSummary,
};
const result = await invokeResponderAgent(agent, context);
const { content } = result.response;
const response = typeof content === 'string' ? content : JSON.stringify(content);
return { response };
};
default:
throw new Error(
`Subgraph "${subgraph}" is not yet supported. Currently supported: responder`,
);
}
}
@@ -0,0 +1,4 @@
export {
TokenUsageTrackingHandler,
type AccumulatedTokenUsage,
} from '../../src/utils/token-usage-tracking-handler';
@@ -0,0 +1,110 @@
/**
* Workflow regeneration runner.
*
* Runs the full multi-agent workflow from a prompt and extracts final state
* for dataset regeneration purposes.
*/
import type { BaseMessage } from '@langchain/core/messages';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { CoordinationLogEntry } from '@/types/coordination';
import type { DiscoveryContext } from '@/types/discovery-types';
import type { SimpleWorkflow } from '@/types/workflow';
import { consumeGenerator, getChatPayload } from './evaluation-helpers';
import type { EvalLogger } from './logger';
import { generateRunId, isWorkflowStateValues } from '../langsmith/types';
import { EVAL_TYPES, EVAL_USERS } from '../support/constants';
import { createAgent, type ResolvedStageLLMs } from '../support/environment';
/** Serialized message format for JSON storage */
export interface SerializedMessage {
type: 'human' | 'ai';
content: string;
}
/** State extracted from a completed workflow generation */
export interface RegeneratedState {
messages: SerializedMessage[];
coordinationLog: CoordinationLogEntry[];
workflowJSON: SimpleWorkflow;
discoveryContext?: DiscoveryContext | null;
previousSummary?: string;
}
export interface RegenerateOptions {
prompt: string;
llms: ResolvedStageLLMs;
parsedNodeTypes: INodeTypeDescription[];
timeoutMs?: number;
abortSignal?: AbortSignal;
logger?: EvalLogger;
}
function serializeMessage(msg: BaseMessage): SerializedMessage {
const msgType = msg._getType();
return {
type: msgType === 'human' ? 'human' : 'ai',
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
};
}
/**
* Run the full multi-agent workflow to generate state from a prompt.
* Returns the final messages, coordinationLog, and workflowJSON.
*/
export async function regenerateWorkflowState(
options: RegenerateOptions,
): Promise<RegeneratedState> {
const { prompt, llms, parsedNodeTypes, abortSignal, logger } = options;
const runId = generateRunId();
logger?.verbose(`Regenerating workflow state for prompt: ${prompt.slice(0, 50)}...`);
const agent = createAgent({
parsedNodeTypes,
llms,
});
const payload = getChatPayload({
evalType: EVAL_TYPES.LANGSMITH,
message: prompt,
workflowId: runId,
});
await consumeGenerator(agent.chat(payload, EVAL_USERS.LANGSMITH, abortSignal));
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 values = state.values as {
messages: BaseMessage[];
workflowJSON: SimpleWorkflow;
coordinationLog?: CoordinationLogEntry[];
discoveryContext?: DiscoveryContext | null;
previousSummary?: string;
};
const messages = values.messages.map(serializeMessage);
const coordinationLog = values.coordinationLog ?? [];
const workflowJSON = values.workflowJSON;
const discoveryContext = values.discoveryContext;
const previousSummary = values.previousSummary;
logger?.verbose(
`Regeneration complete: ${messages.length} messages, ${coordinationLog.length} log entries`,
);
return {
messages,
coordinationLog,
workflowJSON,
discoveryContext,
previousSummary,
};
}