Skip to content

Commit 892f086

Browse files
authored
feat(core): Add introspection diagnostic tool for AI workflow builder (#25172)
1 parent cfca041 commit 892f086

31 files changed

Lines changed: 1230 additions & 161 deletions

packages/@n8n/ai-workflow-builder.ee/evaluations/__tests__/cli.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,31 @@ jest.mock('../cli/webhook', () => ({
6464
jest.mock('../harness/evaluation-helpers', () => ({
6565
consumeGenerator: (...args: unknown[]): unknown => mockConsumeGenerator(...args),
6666
getChatPayload: (...args: unknown[]): unknown => mockGetChatPayload(...args),
67+
createWorkflowGenerator: () =>
68+
jest.fn().mockResolvedValue({ name: 'Test', nodes: [], connections: {} }),
69+
}));
70+
71+
jest.mock('../lifecycles/introspection-analysis', () => ({
72+
createIntrospectionAnalysisLifecycle: () => ({}),
6773
}));
6874

6975
jest.mock('../index', () => ({
7076
runEvaluation: (...args: unknown[]): unknown => mockRunEvaluation(...args),
7177
createConsoleLifecycle: (...args: unknown[]): unknown => mockCreateConsoleLifecycle(...args),
78+
mergeLifecycles: (...lifecycles: unknown[]): unknown => {
79+
// Simple merge implementation for tests - just return the first non-empty lifecycle
80+
const valid = (lifecycles as Array<Record<string, unknown> | undefined>).filter(
81+
(lc) => lc !== undefined,
82+
);
83+
if (valid.length === 0) return {};
84+
// Return a merged object
85+
return Object.assign({}, ...valid);
86+
},
7287
createLLMJudgeEvaluator: (...args: unknown[]): unknown => mockCreateLLMJudgeEvaluator(...args),
7388
createProgrammaticEvaluator: (...args: unknown[]): unknown =>
7489
mockCreateProgrammaticEvaluator(...args),
7590
createPairwiseEvaluator: (...args: unknown[]): unknown => mockCreatePairwiseEvaluator(...args),
91+
createSimilarityEvaluator: () => ({ name: 'similarity', evaluate: jest.fn() }),
7692
}));
7793

7894
/** Helper to create a minimal valid workflow for tests */

packages/@n8n/ai-workflow-builder.ee/evaluations/__tests__/lifecycle.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe('Console Lifecycle', () => {
128128
lifecycle.onStart(config);
129129
mockConsole.log.mockClear();
130130

131-
lifecycle.onEnd({
131+
await lifecycle.onEnd({
132132
totalExamples: 0,
133133
passed: 0,
134134
failed: 0,
@@ -538,7 +538,7 @@ describe('Console Lifecycle', () => {
538538
totalDurationMs: 30000,
539539
};
540540

541-
lifecycle.onEnd(summary);
541+
await lifecycle.onEnd(summary);
542542

543543
expect(mockConsole.log).toHaveBeenCalled();
544544
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
@@ -601,7 +601,7 @@ describe('Console Lifecycle', () => {
601601

602602
lifecycle.onStart(config);
603603
lifecycle.onExampleStart(1, 1, 'Test');
604-
lifecycle.onEnd({
604+
await lifecycle.onEnd({
605605
totalExamples: 1,
606606
passed: 1,
607607
failed: 0,
@@ -809,10 +809,44 @@ describe('Console Lifecycle', () => {
809809
averageScore: 0.85,
810810
totalDurationMs: 5000,
811811
};
812-
merged.onEnd(summary);
812+
await merged.onEnd(summary);
813813

814814
expect(hook1).toHaveBeenCalledWith(summary);
815815
expect(hook2).toHaveBeenCalledWith(summary);
816816
});
817+
818+
it('should properly await async onEnd hooks in mergeLifecycles', async () => {
819+
const { mergeLifecycles } = await import('../harness/lifecycle');
820+
821+
const callOrder: string[] = [];
822+
823+
const asyncHook = jest.fn(async () => {
824+
await new Promise((resolve) => setTimeout(resolve, 50));
825+
callOrder.push('async');
826+
});
827+
const syncHook = jest.fn(() => {
828+
callOrder.push('sync');
829+
});
830+
831+
const lifecycle1: Partial<EvaluationLifecycle> = { onEnd: asyncHook };
832+
const lifecycle2: Partial<EvaluationLifecycle> = { onEnd: syncHook };
833+
834+
const merged = mergeLifecycles(lifecycle1, lifecycle2);
835+
const summary: RunSummary = {
836+
totalExamples: 1,
837+
passed: 1,
838+
failed: 0,
839+
errors: 0,
840+
averageScore: 1,
841+
totalDurationMs: 100,
842+
};
843+
844+
await merged.onEnd(summary);
845+
846+
expect(asyncHook).toHaveBeenCalledWith(summary);
847+
expect(syncHook).toHaveBeenCalledWith(summary);
848+
// Async hook should complete before sync hook starts (sequential await)
849+
expect(callOrder).toEqual(['async', 'sync']);
850+
});
817851
});
818852
});

packages/@n8n/ai-workflow-builder.ee/evaluations/__tests__/runner-langsmith.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ jest.mock('langsmith/traceable', () => ({
3333
),
3434
}));
3535

36-
// Mock core/environment module (dynamically imported in runner.ts)
3736
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
3837
return { name, nodes: [], connections: {} };
3938
}

packages/@n8n/ai-workflow-builder.ee/evaluations/cli/argument-parser.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import type { LangsmithExampleFilters } from '../harness/harness-types';
88
import { DEFAULTS } from '../support/constants';
99
import type { StageModels } from '../support/environment';
1010

11-
export type EvaluationSuite = 'llm-judge' | 'pairwise' | 'programmatic' | 'similarity';
11+
export type EvaluationSuite =
12+
| 'llm-judge'
13+
| 'pairwise'
14+
| 'programmatic'
15+
| 'similarity'
16+
| 'introspection';
1217
export type EvaluationBackend = 'local' | 'langsmith';
1318
export type AgentType = 'multi-agent' | 'code-builder';
1419

@@ -79,7 +84,9 @@ const modelIdSchema = z.enum(AVAILABLE_MODELS as [ModelId, ...ModelId[]]);
7984

8085
const cliSchema = z
8186
.object({
82-
suite: z.enum(['llm-judge', 'pairwise', 'programmatic', 'similarity']).default('llm-judge'),
87+
suite: z
88+
.enum(['llm-judge', 'pairwise', 'programmatic', 'similarity', 'introspection'])
89+
.default('llm-judge'),
8390
backend: z.enum(['local', 'langsmith']).default('local'),
8491
agent: z.enum(['code-builder', 'multi-agent']).default('code-builder'),
8592

@@ -152,7 +159,7 @@ const FLAG_DEFS: Record<string, FlagDef> = {
152159
key: 'suite',
153160
kind: 'string',
154161
group: 'eval',
155-
desc: 'Evaluation suite (llm-judge|pairwise|programmatic|similarity)',
162+
desc: 'Evaluation suite (llm-judge|pairwise|programmatic|similarity|introspection)',
156163
},
157164
'--backend': { key: 'backend', kind: 'string', group: 'eval', desc: 'Backend (local|langsmith)' },
158165
'--agent': {
@@ -446,14 +453,19 @@ function parseCli(argv: string[]): {
446453

447454
function parseFeatureFlags(args: {
448455
templateExamples: boolean;
456+
suite: EvaluationSuite;
449457
}): BuilderFeatureFlags | undefined {
450458
const templateExamplesFromEnv = process.env.EVAL_FEATURE_TEMPLATE_EXAMPLES === 'true';
451459
const templateExamples = templateExamplesFromEnv || args.templateExamples;
452460

453-
if (!templateExamples) return undefined;
461+
// Auto-enable introspection for introspection suite
462+
const enableIntrospection = args.suite === 'introspection';
463+
464+
if (!templateExamples && !enableIntrospection) return undefined;
454465

455466
return {
456467
templateExamples: templateExamples || undefined,
468+
enableIntrospection: enableIntrospection || undefined,
457469
};
458470
}
459471

@@ -521,6 +533,7 @@ export function parseEvaluationArgs(argv: string[] = process.argv.slice(2)): Eva
521533

522534
const featureFlags = parseFeatureFlags({
523535
templateExamples: parsed.templateExamples,
536+
suite: parsed.suite,
524537
});
525538

526539
const filters = parseFilters({

packages/@n8n/ai-workflow-builder.ee/evaluations/cli/index.ts

Lines changed: 84 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ import {
4545
getChatPayload,
4646
} from '../harness/evaluation-helpers';
4747
import { createLogger } from '../harness/logger';
48-
import type { GenerationCollectors } from '../harness/runner';
48+
import type { GenerationCollectors, SubgraphMetricsCollector } from '../harness/runner';
4949
import { TokenUsageTrackingHandler } from '../harness/token-tracking-handler';
5050
import {
5151
runEvaluation,
5252
createConsoleLifecycle,
53+
mergeLifecycles,
5354
createLLMJudgeEvaluator,
5455
createProgrammaticEvaluator,
5556
createPairwiseEvaluator,
@@ -61,6 +62,7 @@ import {
6162
type GenerationResult,
6263
} from '../index';
6364
import { generateRunId, isWorkflowStateValues } from '../langsmith/types';
65+
import { createIntrospectionAnalysisLifecycle } from '../lifecycles/introspection-analysis';
6466
import { AGENT_TYPES, EVAL_TYPES, EVAL_USERS } from '../support/constants';
6567
import { setupTestEnvironment, createAgent, type ResolvedStageLLMs } from '../support/environment';
6668

@@ -82,6 +84,28 @@ function hasCoordinationLog(
8284
return Array.isArray(obj.coordinationLog);
8385
}
8486

87+
/**
88+
* Report subgraph metrics from coordination log and workflow.
89+
*/
90+
function reportSubgraphMetrics(
91+
collector: SubgraphMetricsCollector,
92+
stateValues: unknown,
93+
workflow: SimpleWorkflow,
94+
): void {
95+
const coordinationLog = hasCoordinationLog(stateValues) ? stateValues.coordinationLog : undefined;
96+
const nodeCount = workflow.nodes?.length;
97+
const metrics = extractSubgraphMetrics(coordinationLog, nodeCount);
98+
99+
if (
100+
metrics.discoveryDurationMs !== undefined ||
101+
metrics.builderDurationMs !== undefined ||
102+
metrics.responderDurationMs !== undefined ||
103+
metrics.nodeCount !== undefined
104+
) {
105+
collector(metrics);
106+
}
107+
}
108+
85109
/**
86110
* Create a workflow generator function for the multi-agent system.
87111
* LangSmith tracing is handled via traceable() in the runner.
@@ -93,6 +117,7 @@ function createWorkflowGenerator(
93117
llms: ResolvedStageLLMs,
94118
featureFlags?: BuilderFeatureFlags,
95119
): (prompt: string, collectors?: GenerationCollectors) => Promise<SimpleWorkflow> {
120+
96121
return async (prompt: string, collectors?: GenerationCollectors): Promise<SimpleWorkflow> => {
97122
const runId = generateRunId();
98123

@@ -138,26 +163,48 @@ function createWorkflowGenerator(
138163

139164
// Extract and report subgraph metrics from coordination log
140165
if (collectors?.subgraphMetrics) {
141-
const coordinationLog = hasCoordinationLog(state.values)
142-
? state.values.coordinationLog
143-
: undefined;
144-
const nodeCount = workflow.nodes?.length;
145-
const metrics = extractSubgraphMetrics(coordinationLog, nodeCount);
146-
147-
if (
148-
metrics.discoveryDurationMs !== undefined ||
149-
metrics.builderDurationMs !== undefined ||
150-
metrics.responderDurationMs !== undefined ||
151-
metrics.nodeCount !== undefined
152-
) {
153-
collectors.subgraphMetrics(metrics);
154-
}
166+
reportSubgraphMetrics(collectors.subgraphMetrics, state.values, workflow);
155167
}
156168

169+
// Report introspection events
170+
collectors?.introspectionEvents?.(state.values.introspectionEvents ?? []);
171+
157172
return workflow;
158173
};
159174
}
160175

176+
/**
177+
* Create evaluators based on suite type.
178+
*/
179+
function createEvaluators(params: {
180+
suite: string;
181+
judgeLlm: ResolvedStageLLMs['judge'];
182+
parsedNodeTypes: Parameters<typeof createProgrammaticEvaluator>[0];
183+
numJudges: number;
184+
}): Array<Evaluator<EvaluationContext>> {
185+
const { suite, judgeLlm, parsedNodeTypes, numJudges } = params;
186+
const evaluators: Array<Evaluator<EvaluationContext>> = [];
187+
188+
switch (suite) {
189+
case 'llm-judge':
190+
evaluators.push(createLLMJudgeEvaluator(judgeLlm, parsedNodeTypes));
191+
evaluators.push(createProgrammaticEvaluator(parsedNodeTypes));
192+
break;
193+
case 'pairwise':
194+
evaluators.push(createPairwiseEvaluator(judgeLlm, { numJudges }));
195+
evaluators.push(createProgrammaticEvaluator(parsedNodeTypes));
196+
break;
197+
case 'programmatic':
198+
evaluators.push(createProgrammaticEvaluator(parsedNodeTypes));
199+
break;
200+
case 'similarity':
201+
evaluators.push(createSimilarityEvaluator());
202+
break;
203+
}
204+
205+
return evaluators;
206+
}
207+
161208
/**
162209
* Create a CodeWorkflowBuilder generator function.
163210
* Uses the CodeWorkflowBuilder which coordinates planning and coding agents to generate
@@ -314,7 +361,6 @@ export async function runV2Evaluation(): Promise<void> {
314361

315362
// Setup environment with per-stage model configuration
316363
const logger = createLogger(args.verbose);
317-
const lifecycle = createConsoleLifecycle({ verbose: args.verbose, logger });
318364
const stageModels = argsToStageModels(args);
319365
const env = await setupTestEnvironment(stageModels, logger);
320366

@@ -324,7 +370,6 @@ export async function runV2Evaluation(): Promise<void> {
324370
}
325371

326372
// Create workflow generator based on agent type
327-
// CODE_BUILDER uses CodeWorkflowBuilder
328373
const generateWorkflow =
329374
args.agent === AGENT_TYPES.CODE_BUILDER
330375
? createCodeWorkflowBuilderGenerator(
@@ -335,43 +380,39 @@ export async function runV2Evaluation(): Promise<void> {
335380
)
336381
: createWorkflowGenerator(env.parsedNodeTypes, env.llms, args.featureFlags);
337382

338-
// Create evaluators based on suite (using judge LLM for evaluation)
339-
// The --suite flag is always respected, regardless of agent type
340-
const evaluators: Array<Evaluator<EvaluationContext>> = [];
341-
342-
switch (args.suite) {
343-
case 'llm-judge':
344-
evaluators.push(createLLMJudgeEvaluator(env.llms.judge, env.parsedNodeTypes));
345-
evaluators.push(createProgrammaticEvaluator(env.parsedNodeTypes));
346-
break;
347-
case 'pairwise':
348-
evaluators.push(
349-
createPairwiseEvaluator(env.llms.judge, {
350-
numJudges: args.numJudges,
351-
}),
352-
);
353-
evaluators.push(createProgrammaticEvaluator(env.parsedNodeTypes));
354-
break;
355-
case 'programmatic':
356-
evaluators.push(createProgrammaticEvaluator(env.parsedNodeTypes));
357-
break;
358-
case 'similarity':
359-
evaluators.push(createSimilarityEvaluator());
360-
break;
361-
}
383+
// Create evaluators based on suite type
384+
const evaluators = createEvaluators({
385+
suite: args.suite,
386+
judgeLlm: env.llms.judge,
387+
parsedNodeTypes: env.parsedNodeTypes,
388+
numJudges: args.numJudges,
389+
});
362390

363391
const llmCallLimiter = pLimit(args.concurrency);
364392

393+
// Merge console lifecycle with optional introspection analysis lifecycle
394+
const mergedLifecycle = mergeLifecycles(
395+
createConsoleLifecycle({ verbose: args.verbose, logger }),
396+
args.suite === 'introspection'
397+
? createIntrospectionAnalysisLifecycle({
398+
judgeLlm: env.llms.judge,
399+
outputDir: args.outputDir,
400+
logger,
401+
})
402+
: undefined,
403+
);
404+
365405
const baseConfig = {
366406
generateWorkflow,
367407
evaluators,
368-
lifecycle,
408+
lifecycle: mergedLifecycle,
369409
logger,
370410
outputDir: args.outputDir,
371411
outputCsv: args.outputCsv,
372412
suite: args.suite,
373413
timeoutMs: args.timeoutMs,
374414
context: { llmCallLimiter },
415+
passThreshold: args.suite === 'introspection' ? 0 : undefined,
375416
};
376417

377418
const config: RunConfig =
@@ -400,6 +441,7 @@ export async function runV2Evaluation(): Promise<void> {
400441
...baseConfig,
401442
mode: 'local',
402443
dataset: loadTestCases(args),
444+
concurrency: args.concurrency,
403445
};
404446

405447
// Run evaluation

0 commit comments

Comments
 (0)