refactor(ai-builder): Split the eval harness runner into domain modules (no-changelog) (#34834)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
José Braulio González Valido
2026-07-27 10:01:09 +01:00
committed by GitHub
parent 1f4e65419a
commit b0a10229a0
35 changed files with 2078 additions and 1972 deletions
@@ -41,6 +41,24 @@ LangSmith driver wraps lane functions with `traceable()` (span names
`workflow_build` / `scenario_execution` / `agent_scenario_execution` — the
analytics pipeline reads them), the direct driver passes identity.
## Harness primitives
The `run/` phases sit on domain modules in `evaluations/harness/` (TRUST-342
split them out of the old `runner.ts` monolith):
- `harness/build-workflow.ts``buildWorkflow` end to end (thread +
credential-view setup, conversation seeding, chat loop, outcome discovery)
and its `BuildResult` / `BuildWorkflowConfig` types.
- `harness/scenario-execution.ts``executeScenario` against a built
workflow, multi-workflow entry-point routing, and verification-artifact
assembly (`VerificationArtifact`).
- `harness/agent-execution.ts``executeAgentScenario` and the agent
artifact helpers (the agent-anchored counterpart of scenario execution).
- `harness/seed-tables.ts` — the scenario seed-data-table family (TRUST-311):
dedupe, pre-seed note, per-scenario row reseeding.
- `harness/cleanup.ts``cleanupBuild`, per-case timeout policy, bounded
concurrency, binary workflow checks and shared failure summaries.
## Where to add things
| You want to… | Touch exactly |
@@ -1,7 +1,7 @@
import type { InstanceAiEvalAgentExecutionResult } from '@n8n/api-types';
import { describe, expect, it } from 'vitest';
import { buildAgentVerificationArtifact } from '../harness/runner';
import { buildAgentVerificationArtifact } from '../harness/agent-execution';
// Structurally an ExecutionScenario; the schema-inferred type is error-typed
// under type-aware lint (deep ZodEffects instantiation — see harness/schema.ts).
@@ -2,8 +2,9 @@ import { verifyBuildExpectations } from '../build-expectations/verifier';
import type { CliArgs } from '../cli/args';
import type { N8nClient } from '../clients/n8n-client';
import { resolveArtifactContext } from '../harness/artifacts/artifact-context';
import type { BuildResult } from '../harness/build-workflow';
import { runWorkflowChecks } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import { runWorkflowChecks, type BuildResult } from '../harness/runner';
import {
createBuildOrchestrator,
type BuildOrchestratorDeps,
@@ -18,12 +19,19 @@ import type { WorkflowTestCase } from '../types';
// against the behavior runWithLangSmith relied on while getOrBuild was a
// closure — keep them green through the decomposition.
vi.mock('../harness/runner', async (importOriginal) => {
const actual = await importOriginal<typeof import('../harness/runner')>();
vi.mock('../harness/agent-execution', async (importOriginal) => {
const actual = await importOriginal<typeof import('../harness/agent-execution')>();
return {
...actual,
fetchAgentScenarioContext: vi.fn().mockResolvedValue('AGENT CONTEXT'),
};
});
vi.mock('../harness/cleanup', async (importOriginal) => {
const actual = await importOriginal<typeof import('../harness/cleanup')>();
return {
...actual,
runWorkflowChecks: vi.fn().mockResolvedValue([]),
fetchAgentScenarioContext: vi.fn().mockResolvedValue('AGENT CONTEXT'),
};
});
@@ -1,8 +1,8 @@
import { vi } from 'vitest';
import type { N8nClient } from '../clients/n8n-client';
import { buildWorkflow } from '../harness/build-workflow';
import type { EvalLogger } from '../harness/logger';
import { buildWorkflow } from '../harness/runner';
import { buildAgentOutcome } from '../outcome/workflow-discovery';
import type { ExecutionScenario } from '../types';
@@ -1,6 +1,7 @@
import type { CliArgs } from '../cli/args';
import type { BuildResult } from '../harness/build-workflow';
import { cleanupBuild } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import { cleanupBuild, type BuildResult } from '../harness/runner';
import { BUILD_ONLY_SCENARIO_NAME } from '../langsmith/dataset-sync';
import type { BuildOrchestrator, CachedBuild, LaneState } from '../run/build-orchestrator';
import { createCasePipeline, type CasePipelineDeps } from '../run/case-pipeline';
@@ -12,11 +13,18 @@ import type { WorkflowTestCase } from '../types';
// per-build cleanup. Both drivers run rows through runRow — keep these green
// through the decomposition.
vi.mock('../harness/runner', async (importOriginal) => {
const actual = await importOriginal<typeof import('../harness/runner')>();
vi.mock('../harness/cleanup', async (importOriginal) => {
const actual = await importOriginal<typeof import('../harness/cleanup')>();
return {
...actual,
cleanupBuild: vi.fn().mockResolvedValue(true),
};
});
vi.mock('../harness/seed-tables', async (importOriginal) => {
const actual = await importOriginal<typeof import('../harness/seed-tables')>();
return {
...actual,
warnAgentSeedDataTablesIgnored: vi.fn(),
};
});
@@ -18,7 +18,7 @@ import {
VERIFY_INACTIVITY_TIMEOUT_MS,
verifyChecklist,
} from '../checklist/verifier';
import type { VerificationArtifact } from '../harness/runner';
import type { VerificationArtifact } from '../harness/scenario-execution';
import type { ChecklistItem } from '../types';
const mockCreateEvalAgent = vi.mocked(createEvalAgent);
@@ -2,9 +2,9 @@ import { vi } from 'vitest';
import type { Mock } from 'vitest';
import type { N8nClient } from '../clients/n8n-client';
import type { BuildResult } from '../harness/build-workflow';
import { cleanupBuild } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import { cleanupBuild } from '../harness/runner';
import type { BuildResult } from '../harness/runner';
/**
* Locks in the cleanupBuild contract the CLI's per-case cleanup relies on:
@@ -1,4 +1,4 @@
import { effectiveTimeoutMs } from '../harness/runner';
import { effectiveTimeoutMs } from '../harness/cleanup';
describe('effectiveTimeoutMs', () => {
it('gives complex cases 1.5x the base budget', () => {
@@ -1,4 +1,4 @@
import { summarizeMissingWorkflowError } from '../harness/runner';
import { summarizeMissingWorkflowError } from '../harness/cleanup';
import type { CapturedEvent } from '../types';
function event(type: string, data: Record<string, unknown>): CapturedEvent {
@@ -1,4 +1,4 @@
import { abortedWorkflowTestCaseResult } from '../harness/runner';
import { abortedWorkflowTestCaseResult } from '../harness/cleanup';
import { classifyScenarioExecutionError } from '../harness/transient-error';
import { aggregateResults } from '../run/aggregator';
import type { ExecutionScenario, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
@@ -1,8 +1,8 @@
import { vi } from 'vitest';
import type { N8nClient } from '../clients/n8n-client';
import { buildWorkflow } from '../harness/build-workflow';
import type { EvalLogger } from '../harness/logger';
import { buildWorkflow } from '../harness/runner';
const silentLogger: EvalLogger = {
info: () => {},
@@ -7,8 +7,8 @@ vi.mock('../binaryChecks/index', () => ({
import { runBinaryChecks } from '../binaryChecks/index';
import type { CheckOutcome } from '../binaryChecks/types';
import type { WorkflowResponse } from '../clients/n8n-client';
import { runWorkflowChecks } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import { runWorkflowChecks } from '../harness/runner';
const mockedRunBinaryChecks = vi.mocked(runBinaryChecks);
@@ -8,7 +8,7 @@ import {
dedupeScenarioSeedTables,
reseedScenarioTables,
scenariosRequireSerialSeeding,
} from '../harness/runner';
} from '../harness/seed-tables';
import type { ExecutionScenario } from '../types';
// TRUST-311 follow-up: scenario data tables are created EMPTY before the build
@@ -1,5 +1,8 @@
vi.mock('../harness/runner', () => ({
vi.mock('../harness/build-workflow', () => ({
buildWorkflow: vi.fn(),
}));
vi.mock('../harness/cleanup', () => ({
cleanupBuild: vi.fn(),
}));
@@ -9,7 +12,8 @@ vi.mock('../binaryChecks/index', () => ({
import { runBinaryChecks } from '../binaryChecks/index';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import { buildWorkflow, cleanupBuild, type BuildResult } from '../harness/runner';
import { buildWorkflow, type BuildResult } from '../harness/build-workflow';
import { cleanupBuild } from '../harness/cleanup';
import { runWorkflowBuildEval } from '../subagent/runner';
const mockedBuildWorkflow = vi.mocked(buildWorkflow);
@@ -1,7 +1,7 @@
import type { InstanceAiEvalExecutionResult, InstanceAiEvalNodeResult } from '@n8n/api-types';
import type { WorkflowResponse } from '../clients/n8n-client';
import { buildVerificationArtifact, selectScenarioWorkflowId } from '../harness/runner';
import { buildVerificationArtifact, selectScenarioWorkflowId } from '../harness/scenario-execution';
import type { ExecutionScenario } from '../types';
function makeNodeResult(
@@ -1,4 +1,4 @@
import { workflowExpectedForCase } from '../harness/runner';
import { workflowExpectedForCase } from '../harness/build-workflow';
describe('workflowExpectedForCase', () => {
const scenario = {
@@ -3,8 +3,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { abortedWorkflowTestCaseResult } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import { abortedWorkflowTestCaseResult } from '../harness/runner';
import { runEvalAndPersist } from '../run/persist';
import type { ExecutionScenario, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
@@ -6,7 +6,7 @@ import {
createEvalAgent,
resolveEvalModelConfig,
} from '../../src/utils/eval-agents';
import type { VerificationArtifact } from '../harness/runner';
import type { VerificationArtifact } from '../harness/scenario-execution';
import { MOCK_EXECUTION_VERIFY_PROMPT } from '../system-prompts/mock-execution-verify';
import type { ChecklistItem, ChecklistResult } from '../types';
@@ -127,7 +127,7 @@ export interface CliArgs {
// ---------------------------------------------------------------------------
const cliArgsSchema = z.object({
// Keep in sync with DEFAULT_TIMEOUT_MS in harness/runner.ts (and its
// Keep in sync with DEFAULT_TIMEOUT_MS in harness/build-workflow.ts (and its
// rationale for why this default must stay conservative).
timeoutMs: z.number().int().positive().default(900_000),
baseUrls: z.array(z.string().url()).min(1).default(['http://localhost:5678']),
@@ -14,9 +14,9 @@ import {
stageMcpConfigFromClaudeJson,
uniqueProjectScopes,
} from './mcp-builder';
import { runWithConcurrency } from '../harness/cleanup';
import { createLogger } from '../harness/logger';
import { prebuiltManifestSchema, type PrebuiltManifest } from '../harness/prebuilt-workflows';
import { runWithConcurrency } from '../harness/runner';
import { ConversationTurnSchema, DEFAULT_DATASETS } from '../harness/schema';
import { loadTestCasesFromLangTracer } from '../langtracer/provider';
@@ -33,8 +33,9 @@ import {
} from '../../../ai-workflow-builder.ee/evaluations/evaluators/pairwise';
import { DEFAULTS } from '../../../ai-workflow-builder.ee/evaluations/support/constants';
import { N8nClient, type WorkflowResponse } from '../clients/n8n-client';
import { buildWorkflow } from '../harness/build-workflow';
import { cleanupBuild } from '../harness/cleanup';
import { createLogger, type EvalLogger } from '../harness/logger';
import { buildWorkflow, cleanupBuild } from '../harness/runner';
import { extractOutcomeFromEvents } from '../outcome/event-parser';
import type { CapturedEvent, CapturedToolCall } from '../types';
@@ -0,0 +1,318 @@
// ---------------------------------------------------------------------------
// Agent scenario execution
//
// The agent-artifact counterpart of scenario-execution.ts: executes one
// scenario against a built first-class Agent (real model, mocked tool HTTP)
// and assembles the agent verification artifact.
// ---------------------------------------------------------------------------
import type { InstanceAiEvalAgentExecutionResult } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { setTimeout as delay } from 'node:timers/promises';
import { agentHandler } from './artifacts/agent-handler';
import type { EvalLogger } from './logger';
import { writeScenarioVerificationSnapshot, type VerificationArtifact } from './scenario-execution';
import { isTransientExecutionAbort, MAX_EXEC_ATTEMPTS } from './transient-error';
import { verifyChecklist } from '../checklist/verifier';
import type { N8nClient } from '../clients/n8n-client';
import type {
ArtifactRef,
BuildTrace,
ChecklistItem,
ExecutionScenarioResult,
ExecutionScenario,
} from '../types';
/** Shared routing rule for both eval paths: an agent ref marks the case
* agent-anchored — the agent, not any co-built helper workflow, is the target. */
export function findAgentArtifactRef(
artifactRefs: ArtifactRef[] | undefined,
): ArtifactRef | undefined {
return (artifactRefs ?? []).find((ref) => ref.type === 'agent');
}
/**
* Fetch + render the agent's config and skills — the stable verification
* context every scenario of the build shares (the agent-artifact analog of
* the workflow JSON block). Falls back to a marker string so a fetch failure
* degrades verification instead of failing the scenario.
*/
export async function fetchAgentScenarioContext(
client: N8nClient,
ref: ArtifactRef,
logger: EvalLogger,
): Promise<string> {
try {
const agentArtifact = await agentHandler.fetch(ref, client);
return agentHandler.renderArtifact(agentArtifact);
} catch (error: unknown) {
logger.warn(
` Agent config fetch failed — verifying scenarios without it: ${error instanceof Error ? error.message : String(error)}`,
);
return '(agent configuration could not be fetched)';
}
}
/**
* Execute one scenario against a built first-class Agent and verify the
* result — the agent-artifact counterpart of runScenario. The agent reasons
* with its real model; its tools' outbound HTTP is served by the mock layer.
*/
export async function executeAgentScenario(
client: N8nClient,
agentId: string,
scenario: ExecutionScenario,
agentContext: string,
logger: EvalLogger,
timeoutMs?: number,
testCaseName?: string,
buildTrace?: BuildTrace,
): Promise<ExecutionScenarioResult> {
const execStart = Date.now();
const projectId = await client.getPersonalProjectId();
let evalResult = await client.executeAgentWithLlmMock(
agentId,
projectId,
scenario.dataSetup,
timeoutMs,
);
// Same in-band transient-abort retry as the workflow path.
for (
let attempt = 1;
!evalResult.success &&
isTransientExecutionAbort(evalResult.errors) &&
attempt < MAX_EXEC_ATTEMPTS;
attempt++
) {
logger.warn(
` [${scenario.name}] agent execution aborted by transient DB error (attempt ${String(attempt)}/${String(MAX_EXEC_ATTEMPTS)}: ${evalResult.errors.join('; ')}); retrying`,
);
await delay(500 * attempt);
evalResult = await client.executeAgentWithLlmMock(
agentId,
projectId,
scenario.dataSetup,
timeoutMs,
);
}
const execMs = Date.now() - execStart;
logger.info(
` [${scenario.name}] agent exec=${String(Math.round(execMs / 1000))}s (${String(evalResult.toolCalls.length)} tool calls, ${String(evalResult.modelTurns.length)} model turns)`,
);
const verifyStart = Date.now();
const artifact = buildAgentVerificationArtifact(scenario, agentContext, evalResult);
const scenarioChecklist: ChecklistItem[] = [
{
id: 1,
description: scenario.successCriteria,
category: 'execution',
strategy: 'llm',
},
];
const verification = await verifyChecklist(scenarioChecklist, artifact);
const verificationResults = verification.results;
const verifyMs = Date.now() - verifyStart;
const passed = verificationResults.length > 0 && verificationResults[0].pass;
const result = verificationResults[0];
await writeScenarioVerificationSnapshot({
testCaseName: testCaseName ?? `agent-${agentId}`,
scenarioName: scenario.name,
workflowId: `agent:${agentId}`,
passed,
result,
verificationResults,
verifierAttempts: verification.attempts,
buildTrace,
logger,
});
const incomplete = verificationResults.length === 0;
const attemptErrors = verification.attempts
.map((a) => a.error)
.filter((e): e is string => e !== null);
const reasoning =
result?.reasoning ??
`No verification result — verifier exhausted all attempts${attemptErrors.length > 0 ? ` (${attemptErrors.join('; ')})` : ''}`;
const failureCategory = result?.failureCategory ?? (result ? undefined : 'verification_failure');
const rootCause = result?.rootCause;
const categoryLabel = failureCategory ? ` [${failureCategory}]` : '';
const statusLabel = incomplete ? 'INCOMPLETE (excluded from scoring)' : passed ? 'PASS' : 'FAIL';
logger.info(
` [${scenario.name}] ${statusLabel}${categoryLabel} verify=${String(Math.round(verifyMs / 1000))}s`,
);
if (!passed) {
logger.info(` [${scenario.name}] ${reasoning}`);
}
return {
scenario,
success: passed,
agentEvalResult: evalResult,
agentId,
score: passed ? 1 : 0,
reasoning,
failureCategory,
rootCause,
...(incomplete ? { incomplete: true } : {}),
};
}
/** Agent-artifact counterpart of buildVerificationArtifact: the agent's
* config + skills play the workflow-JSON role (stable across scenarios of
* the same build), the recorded agent run plays the execution trace. */
export function buildAgentVerificationArtifact(
scenario: ExecutionScenario,
agentContext: string,
evalResult: InstanceAiEvalAgentExecutionResult,
): VerificationArtifact {
return {
workflowContext: [
'## Agent under test',
'',
'This scenario ran against a first-class n8n Agent, not a workflow. The agent reasoned with its real configured model; every outbound HTTP request its tools made was intercepted and served by the eval mock layer.',
'',
agentContext,
].join('\n'),
scenarioContext: buildAgentScenarioContextBlock(scenario, evalResult),
};
}
function agentJsonBlock(value: unknown, cap = 2_000): string {
let serialized: string;
try {
serialized = JSON.stringify(value, null, 1) ?? 'null';
} catch {
return '[unserializable]';
}
if (serialized.length > cap) return `${serialized.slice(0, cap)}… [truncated]`;
return serialized;
}
function buildAgentScenarioContextBlock(
scenario: ExecutionScenario,
evalResult: InstanceAiEvalAgentExecutionResult,
): string {
const sections: string[] = [];
sections.push(
'## Scenario',
'',
`**Name:** ${scenario.name}${scenario.description}`,
`**Data setup:** ${scenario.dataSetup}`,
'',
);
// Pre-analysis: programmatic flags
const preAnalysis: string[] = [];
for (const warning of evalResult.seed.warnings) {
preAnalysis.push(`⚠ FRAMEWORK ISSUE: ${warning}`);
}
for (const skipped of evalResult.skippedFeatures) {
preAnalysis.push(
`⚠ HARNESS LIMITATION: agent feature "${skipped.feature}" was disabled for this run (${skipped.reason}) — do not fail the scenario for behaviour that would require it.`,
);
}
for (const call of evalResult.toolCalls) {
for (const req of call.interceptedRequests) {
if (isRecord(req.mockResponse) && '_evalMockError' in req.mockResponse) {
const msg = req.mockResponse.message;
preAnalysis.push(
`⚠ MOCK ISSUE: tool "${call.tool}" ${req.method} ${req.url} → mock generation failed: ${typeof msg === 'string' ? msg : 'unknown'}`,
);
}
}
}
if (preAnalysis.length > 0) {
sections.push('## Pre-analysis (automated flags)', '', ...preAnalysis, '');
}
sections.push(
'## Agent run',
'',
`**Opening user message (generated from the data setup):** ${evalResult.seed.openingMessage}`,
`**Run status:** ${evalResult.success ? 'completed' : 'FAILED'}${evalResult.finishReason ? ` (finishReason: ${evalResult.finishReason})` : ''}${evalResult.model ? ` — model: ${evalResult.model}` : ''}`,
'',
);
if (evalResult.errors.length > 0) {
sections.push('**Run errors:**', ...evalResult.errors.map((error) => `- ${error}`), '');
}
// Looping agents can rack up dozens of calls — elide the middle so the
// verifier prompt stays bounded (start + end carry the decisive activity).
const MAX_RENDERED_CALLS = 30;
const TAIL_CALLS = 8;
const MAX_RENDERED_REQUESTS_PER_CALL = 5;
const allCalls = evalResult.toolCalls.map((call, index) => ({ call, ordinal: index + 1 }));
const renderedCalls =
allCalls.length <= MAX_RENDERED_CALLS
? allCalls
: [...allCalls.slice(0, MAX_RENDERED_CALLS - TAIL_CALLS), ...allCalls.slice(-TAIL_CALLS)];
const elidedCallCount = allCalls.length - renderedCalls.length;
if (evalResult.toolCalls.length === 0) {
sections.push('**Tool calls:** none — the agent made no tool calls in this run.', '');
} else {
sections.push(`## Tool calls (${String(evalResult.toolCalls.length)})`, '');
if (elidedCallCount > 0) {
sections.push(
`_Showing the first ${String(MAX_RENDERED_CALLS - TAIL_CALLS)} and last ${String(TAIL_CALLS)} calls; ${String(elidedCallCount)} middle calls elided._`,
'',
);
}
renderedCalls.forEach(({ call, ordinal }) => {
sections.push(
`### ${String(ordinal)}. ${call.tool} (${call.kind})${call.error ? ' — ERRORED' : ''}${call.autoApproved ? ' [approval auto-granted by the harness]' : ''}`,
'',
);
if (call.input !== undefined) {
sections.push('**Input:**', '```json', agentJsonBlock(call.input), '```', '');
}
if (call.error) {
sections.push(`**Error:** ${call.error}`, '');
}
if (call.output !== undefined) {
sections.push('**Output:**', '```json', agentJsonBlock(call.output), '```', '');
}
const requests = call.interceptedRequests ?? [];
for (const req of requests.slice(0, MAX_RENDERED_REQUESTS_PER_CALL)) {
sections.push(`**Intercepted request:** ${req.method} ${req.url} (${req.nodeType})`);
if (req.requestBody !== undefined) {
sections.push('Request body:', '```json', agentJsonBlock(req.requestBody, 1_000), '```');
}
sections.push(
'Mock response:',
'```json',
agentJsonBlock(req.mockResponse, 1_500),
'```',
'',
);
}
if (requests.length > MAX_RENDERED_REQUESTS_PER_CALL) {
sections.push(
`_${String(requests.length - MAX_RENDERED_REQUESTS_PER_CALL)} further intercepted request(s) for this call elided._`,
'',
);
}
});
}
sections.push(
'## Agent final reply',
'',
evalResult.finalText.length > 0 ? evalResult.finalText : '(no final text)',
'',
);
sections.push(
`**Model turns:** ${String(evalResult.modelTurns.length)} real call(s) to ${evalResult.model ?? 'the configured model'}${evalResult.usage ? ` — ~${String(evalResult.usage.inputTokens ?? 0)} input / ${String(evalResult.usage.outputTokens ?? 0)} output tokens` : ''}.`,
'',
'Verify the checklist against how the agent actually behaved in this run — its tool calls, the intercepted requests and mock responses, and its final reply.',
);
return sections.join('\n');
}
@@ -0,0 +1,612 @@
// ---------------------------------------------------------------------------
// Workflow build via Instance AI
//
// Drives one build conversation end to end: thread + credential-view setup,
// conversation seeding, the (multi-turn) chat loop, outcome discovery from
// the SSE stream, and assembly of the BuildResult consumed by scenario
// execution and cleanup.
// ---------------------------------------------------------------------------
import type { InstanceAiConfirmRequest } from '@n8n/api-types';
import crypto from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import {
SSE_SETTLE_DELAY_MS,
startSseConnection,
waitForAllActivity,
runMultiTurnConversation,
recordUserTurn,
type ConfirmationStrategy,
} from './chat-loop';
import { runWorkflowChecks, summarizeMissingWorkflowError } from './cleanup';
import {
loadConversationSeed,
remapSeedWorkflowIds,
seedFromProse,
transcriptPrefixFromSeed,
type ConversationSeed,
} from './conversation-seed';
import { reconstructSeedFromThread, type SeedThreadRef } from './langsmith-seed';
import type { EvalLogger } from './logger';
import { buildSeededTablesNote, dedupeScenarioSeedTables } from './seed-tables';
import type { CheckOutcome } from '../binaryChecks/types';
import { N8nApiError, type N8nClient, type WorkflowResponse } from '../clients/n8n-client';
import { createDeclaredCredentials } from '../credentials/seeder';
import {
buildConversationMetrics,
extractOutcomeFromEvents,
mergeSeededConversationMetrics,
} from '../outcome/event-parser';
import { buildTranscriptFromEvents } from '../outcome/transcript-from-events';
import { buildAgentOutcome, extractWorkflowIdsFromMessages } from '../outcome/workflow-discovery';
import type {
ArtifactRef,
BuildTrace,
CapturedEvent,
ConversationMetrics,
ConversationTurn,
ExecutionScenario,
TestCaseCredential,
TranscriptTurn,
WorkflowTestCase,
} from '../types';
import {
agentTurnsAsText,
failedBuildsPerTurn,
lastAgentText,
userTurnsAsText,
} from '../utils/conversation-text';
import { UserProxyLlm, type ProxyDecisionStats } from '../utils/user-proxy';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// 15 min. Lanes with heavy multi-agent scenarios (large mocked payloads)
// legitimately need more — the MCP CI workflow passes --timeout-ms 1500000
// explicitly (observed: trading-bot at 863s with a 15-row dataset, hard
// timeouts at 32 rows). Do NOT raise this default: a timed-out attempt is
// retried once, so under high-concurrency contention (the Instance AI
// experiments suite runs ~4x the MCP lane's concurrency) a generous default
// lets starved scenarios hold lane slots for 2x the budget and amplify the
// very contention that starved them (observed: run 28779266673).
const DEFAULT_TIMEOUT_MS = 900_000;
// ---------------------------------------------------------------------------
// Multi-turn driver — wires UserProxyLlm into runMultiTurnConversation
// ---------------------------------------------------------------------------
interface MultiTurnDriverConfig {
client: N8nClient;
threadId: string;
conversation: ConversationTurn[];
messageBudget?: number;
events: CapturedEvent[];
approvedRequests: Set<string>;
startTime: number;
timeoutMs: number;
logger: EvalLogger;
proxyResponses?: Map<string, InstanceAiConfirmRequest>;
followUpMessagesOut?: string[];
/** Appended to the FIRST sent message only (pre-seeded-table hint); the
* recorded turn and the proxy's conversation keep the clean prompt. */
openingMessageSuffix?: string;
}
async function driveMultiTurnConversation(
config: MultiTurnDriverConfig,
): Promise<ProxyDecisionStats> {
const openingMessage = config.conversation[0]?.text ?? '';
const proxy = new UserProxyLlm({
conversation: config.conversation,
messageBudget: config.messageBudget,
logger: config.logger,
});
const confirmationStrategy: ConfirmationStrategy = proxy.respondToConfirmation.bind(proxy);
const nextMessageDecider = async () => {
proxy.ingestEvents(config.events);
const decision = await proxy.decideFollowUp();
if (decision.kind === 'followUp') {
config.followUpMessagesOut?.push(decision.message);
}
return decision;
};
recordUserTurn(config.events, openingMessage);
await config.client.sendMessage(
config.threadId,
openingMessage + (config.openingMessageSuffix ?? ''),
);
await runMultiTurnConversation({
client: config.client,
threadId: config.threadId,
events: config.events,
approvedRequests: config.approvedRequests,
startTime: config.startTime,
timeoutMs: config.timeoutMs,
logger: config.logger,
confirmationStrategy,
nextMessageDecider,
proxyResponses: config.proxyResponses,
});
return { ...proxy.getDecisionStats() };
}
// ---------------------------------------------------------------------------
// Split API: build once, run scenarios independently
// ---------------------------------------------------------------------------
export interface BuildResult {
success: boolean;
workflowId?: string;
workflowJsons: WorkflowResponse[];
error?: string;
buildTrace?: BuildTrace;
/** IDs to pass to cleanupBuild() */
createdWorkflowIds: string[];
createdDataTableIds: string[];
/** Maps each scenario seed table's declared NAME to the real id it was created
* under (empty) before the build turn, so each scenario can reset+seed its
* rows into the table the built workflow actually bound (TRUST-311 follow-up).
* Absent when the case declares no scenario seed tables. */
seededScenarioTableIdsByName?: Record<string, string>;
/** Non-workflow artifact refs (agent, config-eval) captured from the SSE stream,
* fed to the build-expectations judge context. Empty/undefined for prebuilt runs. */
artifactRefs?: ArtifactRef[];
/** Per-turn deterministic counters extracted from the captured event stream. */
conversationMetrics?: ConversationMetrics;
/** Captured SSE events from the build run. */
events?: CapturedEvent[];
/** The thread id used during the build — keys the LangSmith trace lookup. */
threadId?: string;
/** Counts of UserProxyLlm decisions by category (multi-turn builds only). */
proxyDecisionStats?: ProxyDecisionStats;
/** Chat-style transcript built from the SSE event stream + proxy responses. */
transcript?: TranscriptTurn[];
workflowChecks?: CheckOutcome[];
/** False when the backend lacks the credential-pin endpoint and the build ran unpinned. */
credentialViewPinned?: boolean;
/** True when the build failed while setting up the conversation seed (trace
* gone, reconstruction drift, restore failed) — a harness/framework problem,
* not an agent build failure. Routed to `framework_issue`. */
seedingFailed?: boolean;
/** Transport-level failure (network error, or the lane unreachable right
* after failing — e.g. timed out against a dead lane). Routed to `framework_issue`. */
transportFailure?: boolean;
}
export interface BuildWorkflowConfig {
client: N8nClient;
/** Hand-authored conversation (≥1 turn, first `user`; one user turn →
* auto-approve, more → proxy). Optional when `seedThread` derives the live turn. */
conversation?: ConversationTurn[];
/** Max follow-up messages the proxy will send. Ignored in auto-approve mode. */
messageBudget?: number;
/** Credentials this build should see (created for real, view pinned to them). */
credentials?: TestCaseCredential[];
/** Run-level registry the created credential IDs are added to for cleanup. */
createdCredentialIds?: Set<string>;
/** Synthetic seed file (path) restored before the live message. */
seedFile?: string;
/** Prose turns seeded as plain-text history. */
priorConversation?: ConversationTurn[];
/** Reproduce a real conversation from its LangSmith trace (seed = before the
* last user message, live = that message). */
seedThread?: SeedThreadRef;
/** Execution scenarios whose declared `seedDataTables` are created + row-seeded
* after a successful build, before any scenario runs (TRUST-311). */
executionScenarios?: ExecutionScenario[];
timeoutMs?: number;
preRunWorkflowIds: Set<string>;
claimedWorkflowIds: Set<string>;
logger: EvalLogger;
/** Optional " [lane N/M]" suffix appended to the scenario log line. */
laneTag?: string;
/**
* Last-resort workflow discovery by list-diffing visible workflows. Keep this
* disabled for normal eval runs because concurrent builds make the diff
* non-attributable.
*/
allowWorkflowListDiffFallback?: boolean;
/** Let callers that own their own scoring avoid duplicate binary checks. */
skipWorkflowChecks?: boolean;
/** False for answer-only cases: ending the conversation without a saved
* workflow is then a valid outcome, not a failed build. Defaults to true. */
workflowExpected?: boolean;
}
/** A case needs a workflow iff something judges one: execution scenarios or
* outcome expectations. Cases with neither are graded on the conversation. */
export function workflowExpectedForCase(
testCase: Pick<WorkflowTestCase, 'executionScenarios' | 'outcomeExpectations'>,
): boolean {
return (
(testCase.executionScenarios?.length ?? 0) > 0 ||
(testCase.outcomeExpectations?.length ?? 0) > 0
);
}
/** A conversation is multi-turn if it has more than one turn, or if the only
* turn is from the assistant. Empty conversations are treated as single-turn. */
function isMultiTurnConversation(conversation: ConversationTurn[]): boolean {
if (conversation.length === 0) return false;
if (conversation.length > 1) return true;
return conversation[0].role !== 'user';
}
/**
* Build a workflow via Instance AI. Returns the workflow ID for use with
* executeScenario(). Call cleanupBuild() when done.
*/
export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildResult> {
const { client, logger } = config;
const threadId = crypto.randomUUID();
const startTime = Date.now();
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const abortController = new AbortController();
const events: CapturedEvent[] = [];
const approvedRequests = new Set<string>();
const proxyResponses = new Map<string, InstanceAiConfirmRequest>();
const followUpMessages: string[] = [];
let credentialViewPinned = true;
let restoredWorkflowIds: string[] = [];
let restoredDataTableIds: string[] = [];
// TRUST-311 follow-up: scenario seed tables are created empty before the build
// turn (so the agent binds their real id); this maps declared name → real id
// for the per-scenario row seeding, and the note tells the agent they exist.
const scenarioTableIdsByName: Record<string, string> = {};
let scenarioSeedTablesNote = '';
// Ids the build itself produced (the agent's workflow + any data tables it
// made). Tracked here so a throw AFTER the build lands — scenario-table
// seeding, workflow checks — still hands them to the caller's cleanup rather
// than leaking them into the shared eval project.
let builtWorkflowIds: string[] = [];
let builtDataTableIds: string[] = [];
let seededTranscript: TranscriptTurn[] = [];
let seedingFailed = false;
try {
const buildStart = Date.now();
// `seedThread` derives both seed and live turn from a trace; otherwise the
// seed (if any) is a file/prose prelude and the conversation is authored.
let seed: ConversationSeed | undefined;
let conversation = config.conversation ?? [];
try {
if (config.seedThread) {
const reconstructed = await reconstructSeedFromThread(config.seedThread);
seed = reconstructed.seed;
// The trace's last user message is the live opening; any authored
// `conversation` continues from there (proxy-driven follow-ups).
conversation = [
{ role: 'user', text: reconstructed.liveTurn },
...(config.conversation ?? []),
];
const contSuffix =
(config.conversation?.length ?? 0) > 0
? ` + ${String(config.conversation!.length)} continuation turn(s)`
: '';
const wsLabel = reconstructed.sourceWorkspace
? `${reconstructed.sourceWorkspace}/${reconstructed.sourceProject}`
: reconstructed.sourceProject;
logger.info(
` Reconstructed seed from thread ${config.seedThread.threadId}: ${String(reconstructed.runCount)} runs → ${String(seed.messages.length)} message(s), ${String(seed.workflows.length)} workflow(s)${contSuffix} [${wsLabel}]${config.laneTag ?? ''}`,
);
} else if (config.seedFile) {
seed = loadConversationSeed(config.seedFile);
} else if (config.priorConversation && config.priorConversation.length > 0) {
seed = seedFromProse(config.priorConversation);
}
} catch (error: unknown) {
// A seed that can't be resolved is a harness/framework problem, not an
// agent build failure — tag it and fail before spending a live turn.
seedingFailed = true;
throw new Error(`Seeding failed: ${error instanceof Error ? error.message : String(error)}`);
}
const openingMessage = conversation[0]?.text ?? '';
const isMultiTurn = isMultiTurnConversation(conversation);
logger.info(
` Running case${isMultiTurn ? ' [multi-turn]' : ''}: "${truncate(openingMessage, 60)}"${config.laneTag ?? ''}`,
);
const projectId = await client.getPersonalProjectId();
await client.ensureThread(threadId, projectId);
// Pin the thread's credential view to the case's declared set (empty by
// default) before the first message, so every build-workflow call inside
// the build sees the same deterministic environment.
const declaredCredentials = config.credentials ?? [];
const createdCredentials = await createDeclaredCredentials(client, declaredCredentials, {
onCreated: (id) => config.createdCredentialIds?.add(id),
logger,
});
try {
await client.setThreadCredentialAllowlist(
threadId,
createdCredentials.map((c) => c.id),
);
} catch (error: unknown) {
// Only a missing endpoint (older backend) may degrade to the legacy
// unpinned view, and only for cases that declared nothing — any other
// failure must fail the build rather than silently change which
// credentials it sees.
const endpointMissing = error instanceof N8nApiError && error.status === 404;
if (!endpointMissing || declaredCredentials.length > 0) throw error;
credentialViewPinned = false;
logger.info(
` Credential-pin endpoint unavailable, building unpinned${config.laneTag ?? ''}`,
);
}
// Restore the seed before the first live message. No degraded mode: a
// seeded case can't run unseeded, so any restore failure fails the build.
if (seed) {
try {
const remapped = remapSeedWorkflowIds(seed);
const restoreResult = await client.restoreThread(
threadId,
remapped.messages,
remapped.workflows,
remapped.dataTables,
);
restoredWorkflowIds = restoreResult.workflowIds;
restoredDataTableIds = restoreResult.dataTableIds;
seededTranscript = transcriptPrefixFromSeed(remapped.messages);
const dtSuffix =
restoredDataTableIds.length > 0
? `, ${String(restoredDataTableIds.length)} data table(s)`
: '';
logger.info(
` Seeded ${String(restoreResult.restored)} prior message(s), ${String(restoredWorkflowIds.length)} workflow(s)${dtSuffix}${config.laneTag ?? ''}`,
);
} catch (error: unknown) {
seedingFailed = true;
throw new Error(
`Seeding failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// TRUST-311 follow-up: create the case's execution-scenario data tables EMPTY
// under their EXACT declared names BEFORE the build turn, so the agent
// discovers the real table (Data Table list/schema) and binds its real id —
// the production-faithful flow where the user's table pre-exists. Rows are
// reset+seeded per scenario (reseedScenarioTables) because a build-time
// self-verification execution can mutate them. The created ids fold into
// restoredDataTableIds so the outer catch and cleanupBuild already cover them
// (a build failure still cleans them up); a create failure is a harness
// problem, so flag seedingFailed → the CLI attributes framework_issue.
try {
const scenarioSeedTables = dedupeScenarioSeedTables(config.executionScenarios ?? [], logger);
if (scenarioSeedTables.length > 0) {
const schemasOnly = scenarioSeedTables.map((table) => ({ ...table, rows: undefined }));
const { dataTableIds } = await client.restoreThread(threadId, [], [], schemasOnly, {
uniquifyNames: false,
});
// restoreThread returns ids in input order; a length mismatch means we
// can't safely map names to ids, so fail rather than mis-seed.
if (dataTableIds.length !== scenarioSeedTables.length) {
throw new Error(
`Pre-seeding created ${String(dataTableIds.length)} data table(s) but the case declares ${String(scenarioSeedTables.length)}; cannot map names to ids.`,
);
}
scenarioSeedTables.forEach((table, index) => {
scenarioTableIdsByName[table.name] = dataTableIds[index];
});
restoredDataTableIds = [...restoredDataTableIds, ...dataTableIds];
scenarioSeedTablesNote = buildSeededTablesNote(scenarioSeedTables);
logger.info(
` Pre-seeded ${String(dataTableIds.length)} scenario data table schema(s)${config.laneTag ?? ''}`,
);
}
} catch (error: unknown) {
seedingFailed = true;
throw error;
}
const ssePromise = startSseConnection(client, threadId, events, abortController.signal).catch(
() => {},
);
await delay(SSE_SETTLE_DELAY_MS);
let proxyDecisionStats: ProxyDecisionStats | undefined;
if (isMultiTurn) {
proxyDecisionStats = await driveMultiTurnConversation({
client,
threadId,
conversation,
messageBudget: config.messageBudget,
events,
approvedRequests,
startTime,
timeoutMs,
logger,
proxyResponses,
followUpMessagesOut: followUpMessages,
// The pre-seeded-table note goes to the agent, but the recorded turn
// (and the graded transcript) keeps the clean user prompt.
openingMessageSuffix: scenarioSeedTablesNote,
});
} else {
recordUserTurn(events, openingMessage);
await client.sendMessage(threadId, openingMessage + scenarioSeedTablesNote);
await waitForAllActivity({
client,
threadId,
events,
approvedRequests,
startTime,
timeoutMs,
logger,
proxyResponses,
});
}
abortController.abort();
await ssePromise.catch(() => {});
const conversationMetrics = mergeSeededConversationMetrics(
seededTranscript,
buildConversationMetrics(events),
);
const transcript = [
...seededTranscript,
...buildTranscriptFromEvents({
events,
openingMessage,
followUpMessages,
proxyResponses,
}),
];
let threadMessages;
try {
threadMessages = await client.getThreadMessages(threadId);
} catch {
threadMessages = { messages: [] };
}
const messageWorkflowIds = extractWorkflowIdsFromMessages(threadMessages.messages);
const eventOutcome = extractOutcomeFromEvents(events);
// Restored workflows keep a seeded build scoreable/cleanable even if the
// live turn touches no workflow tool; live ids stay first (primary artifact).
const threadWorkflowIds = [
...new Set([...eventOutcome.workflowIds, ...messageWorkflowIds, ...restoredWorkflowIds]),
];
const buildTrace: BuildTrace = {
finalText:
eventOutcome.finalText.length > 0 ? eventOutcome.finalText : lastAgentText(transcript),
toolCalls: eventOutcome.toolCalls,
agentActivities: eventOutcome.agentActivities,
};
const outcome = await buildAgentOutcome(
client,
{ ...eventOutcome, workflowIds: threadWorkflowIds },
config.preRunWorkflowIds,
config.claimedWorkflowIds,
{ allowListDiffFallback: config.allowWorkflowListDiffFallback === true, logger },
);
builtWorkflowIds = outcome.workflowsCreated.map((wf) => wf.id);
builtDataTableIds = outcome.dataTablesCreated;
if (outcome.workflowsCreated.length === 0) {
// Answer-only cases (no execution scenarios, no outcome expectations)
// are graded on the conversation — ending without a workflow is a
// valid outcome for them, not a failed build.
if (config.workflowExpected === false) {
logger.info(
` Conversation completed without a workflow (none expected) [${String(Math.round((Date.now() - buildStart) / 1000))}s] [thread ${threadId}]`,
);
return {
success: true,
workflowJsons: [],
buildTrace,
artifactRefs: eventOutcome.artifactRefs,
createdWorkflowIds: restoredWorkflowIds,
createdDataTableIds: [...outcome.dataTablesCreated, ...restoredDataTableIds],
conversationMetrics,
events,
threadId,
proxyDecisionStats,
transcript,
credentialViewPinned,
seedingFailed,
};
}
return {
success: false,
error: summarizeMissingWorkflowError(events),
workflowJsons: [],
buildTrace,
createdWorkflowIds: restoredWorkflowIds,
createdDataTableIds: [...outcome.dataTablesCreated, ...restoredDataTableIds],
artifactRefs: eventOutcome.artifactRefs,
conversationMetrics,
events,
threadId,
proxyDecisionStats,
transcript,
credentialViewPinned,
seedingFailed,
};
}
const buildMs = Date.now() - buildStart;
const proxySuffix = formatProxyStatsSuffix(proxyDecisionStats);
logger.info(
` Workflow built: ${outcome.workflowsCreated[0].name} (${String(outcome.workflowsCreated[0].nodeCount)} nodes) [${String(Math.round(buildMs / 1000))}s]${isMultiTurn ? ` (${String(conversationMetrics.turnCount)} turn${conversationMetrics.turnCount === 1 ? '' : 's'})` : ''}${proxySuffix} [thread ${threadId}]`,
);
const workflowChecks = config.skipWorkflowChecks
? undefined
: await runWorkflowChecks({
workflow: outcome.workflowJsons[0],
prompt: userTurnsAsText(transcript),
agentText: agentTurnsAsText(transcript),
failedBuildsPerTurn: failedBuildsPerTurn(transcript),
logger,
});
// The case's scenario data tables were created empty before the build turn
// (see the pre-build block above), so the agent bound their real ids; their
// per-scenario rows are seeded in runScenario via seededScenarioTableIdsByName.
return {
success: true,
workflowId: outcome.workflowsCreated[0].id,
workflowJsons: outcome.workflowJsons,
buildTrace,
createdWorkflowIds: outcome.workflowsCreated.map((wf) => wf.id),
createdDataTableIds: [...outcome.dataTablesCreated, ...restoredDataTableIds],
seededScenarioTableIdsByName: scenarioTableIdsByName,
artifactRefs: eventOutcome.artifactRefs,
conversationMetrics,
events,
threadId,
proxyDecisionStats,
transcript,
workflowChecks,
credentialViewPinned,
};
} catch (error: unknown) {
abortController.abort();
// Try to surface partial metrics so timeouts still produce a per-turn report.
const conversationMetrics = events.length > 0 ? buildConversationMetrics(events) : undefined;
return {
success: false,
error: error instanceof Error ? error.message : String(error),
workflowJsons: [],
createdWorkflowIds: [...restoredWorkflowIds, ...builtWorkflowIds],
createdDataTableIds: [...restoredDataTableIds, ...builtDataTableIds],
conversationMetrics,
events,
threadId,
credentialViewPinned,
seedingFailed,
};
}
}
function formatProxyStatsSuffix(stats: ProxyDecisionStats | undefined): string {
if (!stats) return '';
const entries = Object.entries(stats).sort(([, a], [, b]) => b - a);
if (entries.length === 0) return '';
return ` [proxy: ${entries.map(([k, v]) => `${k}=${String(v)}`).join(', ')}]`;
}
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
}
@@ -6,7 +6,7 @@
// memory jobs (via thread status polling), auto-approves any confirmation
// confirmation requests, and surfaces the captured events.
//
// Used by `harness/runner.ts` (workflow eval) and the computer-use eval
// Used by `harness/build-workflow.ts` (workflow eval) and the computer-use eval
// harness. Both consume the same primitives so any fix here lands in both
// flows automatically.
// ---------------------------------------------------------------------------
@@ -0,0 +1,245 @@
// ---------------------------------------------------------------------------
// Build cleanup + shared runner utilities
//
// Post-build cleanup of created artifacts, per-case timeout policy, bounded
// concurrency, binary workflow checks, and the shared failure summaries /
// synthetic results both drivers rely on.
// ---------------------------------------------------------------------------
import { findAgentArtifactRef } from './agent-execution';
import type { BuildResult } from './build-workflow';
import type { EvalLogger } from './logger';
import { classifyScenarioExecutionError } from './transient-error';
import { SONNET_MODEL } from '../../src/utils/eval-agents';
import { runBinaryChecks } from '../binaryChecks/index';
import type { BinaryCheckContext, CheckOutcome } from '../binaryChecks/types';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import type { CapturedEvent, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
/**
* Per-case budget: `complex` cases get 1.5× the base timeout. The heaviest
* builds (multi-agent fan-outs, 5-integration pipelines) legitimately run at
* the shared default's cap — observed 777900s with 4/10 builds timing out on
* weekly-social-content-scheduler in run 29012884140 — while the default must
* NOT rise globally (see the DEFAULT_TIMEOUT_MS comment in build-workflow.ts:
* a generous default lets starved scenarios amplify the contention that
* starved them). Keyed off the authored `complexity` field so the budget
* travels with the case (incl. through the lang-tracer mirror) instead of a
* bespoke per-case knob.
*/
export function effectiveTimeoutMs(
complexity: WorkflowTestCase['complexity'] | undefined,
baseMs: number,
): number {
return complexity === 'complex' ? Math.round(baseMs * 1.5) : baseMs;
}
function eventPayload(event: CapturedEvent): Record<string, unknown> {
return typeof event.data.payload === 'object' && event.data.payload !== null
? (event.data.payload as Record<string, unknown>)
: event.data;
}
/**
* Best-effort explanation for a run that produced no workflow, drawn from the
* captured event stream. Tool errors are most specific; run-level `error`
* events (e.g. the terminal-fallback emitted when the run throws before doing
* any work — a crashed sandbox, a failed model call) carry the actual failure
* reason and must be surfaced, otherwise a crashed run reports nothing at all.
*/
export function summarizeMissingWorkflowError(events: CapturedEvent[]): string {
const toolErrors = events
.filter((e) => e.type === 'tool-error')
.map((e) => {
const payload = eventPayload(e);
const toolError = payload.error ?? payload.message;
return typeof toolError === 'string' ? toolError : 'unknown tool error';
});
if (toolErrors.length > 0) return `Tool errors: ${toolErrors.join('; ')}`;
const runErrors = events
.filter((e) => e.type === 'error')
.map((e) => {
const payload = eventPayload(e);
const runError = payload.content ?? payload.error ?? payload.message;
return typeof runError === 'string' ? runError : 'unknown agent error';
});
if (runErrors.length > 0) return `Agent error: ${runErrors.join('; ')}`;
const agentText = events
.filter((e) => e.type === 'text-delta')
.map((e) => {
const payload = eventPayload(e);
if (typeof e.data.text === 'string') return e.data.text;
return typeof payload.text === 'string' ? payload.text : '';
})
.join('');
if (agentText.length > 0) return `Agent response: ${agentText.slice(0, 500)}`;
return 'No workflow produced — no error details captured';
}
/**
* Synthetic result for a test case whose run threw before it could produce one
* (a budget/timeout abort, a lane meltdown, an OOM). Recording it — instead of
* letting the throw reject the batch — keeps every OTHER case's already-completed
* results, and keeps this case index-aligned so the aggregator counts it rather
* than losing the whole run. One `framework_issue` row per declared scenario
* carries the pinned cross-repo contract (timeout-flavoured rootCause for budget
* aborts) so the lang-tracer side buckets it as infra, not product quality.
*/
export function abortedWorkflowTestCaseResult(
testCase: WorkflowTestCase,
baseUrl: string,
errorMessage: string,
): WorkflowTestCaseResult {
const classified = classifyScenarioExecutionError(errorMessage);
return {
testCase,
workflowBuildSuccess: false,
buildError: errorMessage,
n8nBaseUrl: baseUrl,
executionScenarioResults: (testCase.executionScenarios ?? []).map((scenario) => ({
scenario,
success: false,
score: 0,
...classified,
})),
};
}
/**
* Clean up workflows, data tables and any built agent created during a build.
*
* Returns false when any deletion failed so callers can retry later.
*/
export async function cleanupBuild(
client: N8nClient,
build: BuildResult,
logger: EvalLogger,
): Promise<boolean> {
let clean = true;
for (const id of build.createdWorkflowIds) {
try {
await client.deleteWorkflow(id);
} catch {
clean = false; // Best-effort cleanup
}
}
// Agent-anchored builds create a first-class Agent — delete it with the
// rest of the build's artifacts so no caller has to remember to.
const agentRef = findAgentArtifactRef(build.artifactRefs);
if (agentRef) {
try {
await client.deleteAgent(await client.getPersonalProjectId(), agentRef.id);
} catch {
clean = false; // Best-effort cleanup
}
}
if (build.createdDataTableIds.length > 0) {
try {
const projectId = await client.getPersonalProjectId();
for (const dtId of build.createdDataTableIds) {
try {
await client.deleteDataTable(projectId, dtId);
} catch {
clean = false; // Best-effort cleanup
}
}
logger.verbose(` Cleaned up ${String(build.createdDataTableIds.length)} data table(s)`);
} catch {
clean = false; // Non-fatal — project ID lookup may fail
}
}
// Clears backend thread state (run-state registries, memory) that otherwise
// grows one entry per build for the container's lifetime.
if (build.threadId) {
try {
await client.deleteThread(build.threadId);
} catch {
clean = false; // Best-effort cleanup
}
}
return clean;
}
// ---------------------------------------------------------------------------
// Concurrency control
// ---------------------------------------------------------------------------
/**
* Run tasks with bounded concurrency. Like Promise.all but limits how many
* tasks execute simultaneously to avoid API rate limits.
*/
export async function runWithConcurrency<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
limit: number,
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await fn(items[index]);
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => await worker());
await Promise.all(workers);
return results;
}
export async function runWorkflowChecks(args: {
workflow: WorkflowResponse | undefined;
prompt: string;
agentText: string | undefined;
/** Per-live-turn failed build-workflow attempt counts; feeds the efficiency check. */
failedBuildsPerTurn?: number[];
logger: EvalLogger;
}): Promise<CheckOutcome[] | undefined> {
if (!args.workflow) return undefined;
const modelId = hasAnthropicKey() ? SONNET_MODEL : undefined;
const ctx: BinaryCheckContext = {
prompt: args.prompt,
...(modelId ? { modelId } : {}),
...(args.agentText ? { agentTextResponse: args.agentText } : {}),
...(args.failedBuildsPerTurn ? { failedBuildsPerTurn: args.failedBuildsPerTurn } : {}),
};
try {
const { outcomes } = await runBinaryChecks(args.workflow, ctx);
const failed = outcomes.filter((o) => o.status === 'fail');
if (failed.length > 0) {
args.logger.info(
` Workflow checks: ${String(failed.length)} failing (${failed.map((o) => o.name).join(', ')})`,
);
}
const errored = outcomes.filter((o) => o.status === 'error');
if (errored.length > 0) {
args.logger.warn(
` Workflow checks: ${String(errored.length)} errored, excluded from scoring (${errored.map((o) => o.name).join(', ')})`,
);
}
return outcomes;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
args.logger.warn(` Workflow checks errored: ${message}`);
return undefined;
}
}
function hasAnthropicKey(): boolean {
return Boolean(
process.env.N8N_INSTANCE_AI_MODEL_API_KEY ??
process.env.N8N_AI_ANTHROPIC_KEY ??
process.env.ANTHROPIC_API_KEY,
);
}
@@ -20,8 +20,8 @@
import { readFileSync } from 'fs';
import { z } from 'zod';
import type { BuildResult } from './build-workflow';
import type { EvalLogger } from './logger';
import type { BuildResult } from './runner';
import type { N8nClient } from '../clients/n8n-client';
export const prebuiltManifestSchema = z
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,660 @@
// ---------------------------------------------------------------------------
// Workflow scenario execution
//
// Runs one execution scenario against a built workflow with LLM-mocked HTTP,
// routes multi-workflow builds to the right entry point, and assembles the
// verification artifact + checklist verification that scores the run.
// ---------------------------------------------------------------------------
import type { InstanceAiEvalExecutionResult } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import type { EvalLogger } from './logger';
import { reseedScenarioTables, type ScenarioSeedContext } from './seed-tables';
import { isTransientExecutionAbort, MAX_EXEC_ATTEMPTS } from './transient-error';
import { buildWorkflowContextBlock } from './workflow-context';
import { isMockableTriggerNodeType } from '../../src/tools/workflows/workflow-json-utils';
import { type VerifierAttemptDebug, verifyChecklist } from '../checklist/verifier';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import type {
BuildTrace,
ChecklistItem,
ChecklistResult,
ExecutionScenarioResult,
ExecutionScenario,
} from '../types';
const EVAL_DATA_DIR = path.join(__dirname, '..', '..', '.data');
function makeArtifactTimestamp(): string {
return new Date().toISOString().replace(/[:.]/g, '-');
}
function slugifyArtifactSegment(value: string, fallback: string): string {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
return slug.length > 0 ? slug : fallback;
}
/** Exported for the agent scenario path — agent-execution.ts writes the same snapshots. */
export async function writeScenarioVerificationSnapshot(input: {
testCaseName: string;
scenarioName: string;
workflowId: string;
passed: boolean;
result: ChecklistResult | undefined;
verificationResults: ChecklistResult[];
verifierAttempts: VerifierAttemptDebug[];
buildTrace?: BuildTrace;
logger: EvalLogger;
}): Promise<void> {
const timestamp = makeArtifactTimestamp();
const fileName = `${slugifyArtifactSegment(input.testCaseName, 'workflow')}_${slugifyArtifactSegment(input.scenarioName, 'scenario')}_${timestamp}.json`;
const filePath = path.join(EVAL_DATA_DIR, fileName);
try {
await mkdir(EVAL_DATA_DIR, { recursive: true });
await writeFile(
filePath,
JSON.stringify(
{
timestamp,
workflowId: input.workflowId,
testCaseName: input.testCaseName,
scenarioName: input.scenarioName,
passed: input.passed,
result: input.result ?? null,
verificationResults: input.verificationResults,
verifierAttempts: input.verifierAttempts,
buildTrace: input.buildTrace ?? null,
},
null,
2,
),
'utf8',
);
input.logger.verbose(` [${input.scenarioName}] wrote verifier snapshot: ${filePath}`);
} catch (error) {
input.logger.warn(
` [${input.scenarioName}] failed to write verifier snapshot: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Execute a single scenario against a pre-built workflow and verify the result.
*/
export async function executeScenario(
client: N8nClient,
workflowId: string,
scenario: ExecutionScenario,
workflowJsons: WorkflowResponse[],
logger: EvalLogger,
timeoutMs?: number,
testCaseName?: string,
buildTrace?: BuildTrace,
pinAiRoots?: string[],
seedContext?: ScenarioSeedContext,
): Promise<ExecutionScenarioResult> {
return await runScenario(
client,
scenario,
workflowId,
workflowJsons,
logger,
timeoutMs,
testCaseName,
buildTrace,
pinAiRoots,
seedContext,
);
}
// ---------------------------------------------------------------------------
// Scenario execution (internal)
// ---------------------------------------------------------------------------
const SCENARIO_MATCH_STOPWORDS = new Set([
'the',
'and',
'for',
'with',
'that',
'this',
'from',
'tool',
'test',
'node',
'workflow',
'when',
'then',
'should',
'returns',
'return',
]);
function scenarioMatchTokens(text: string): Set<string> {
return new Set(
text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((t) => t.length >= 3 && !SCENARIO_MATCH_STOPWORDS.has(t)),
);
}
/** Workflow ids referenced by enabled Execute Workflow nodes (database source,
* plain string or resource-locator value) — those targets are dependencies of
* this workflow, not entry points. */
function executeWorkflowReferences(wf: WorkflowResponse): string[] {
const ids: string[] = [];
for (const node of wf.nodes) {
if (node.disabled || node.type !== 'n8n-nodes-base.executeWorkflow') continue;
const params = node.parameters ?? {};
if (typeof params.source === 'string' && params.source !== 'database') continue;
const raw = isRecord(params.workflowId) ? params.workflowId.value : params.workflowId;
if (typeof raw === 'string' && raw.trim() !== '' && !raw.startsWith('=')) ids.push(raw.trim());
}
return ids;
}
/**
* Compositional builds split the system across multiple workflows (SKILL.md
* endorses this), but execution historically always ran `build.workflowId` —
* scenarios targeting a sibling workflow failed as phantom builder issues
* while the expectations judge (which sees every workflowJson) passed the
* same build. Mirror reality instead: a caller hits the specific endpoint, so
* route the scenario to the workflow whose trigger-bearing content best
* matches it. Sub-workflows referenced by another candidate's Execute Workflow
* node are dependencies, not entry points — executing one directly starts it
* once with an empty payload, so they're demoted whenever an entry point
* remains. A single-candidate pool routes to that candidate: `workflowId`
* itself may be a sub-workflow (whichever the agent happened to save first).
*/
export function selectScenarioWorkflowId(
scenario: ExecutionScenario,
workflowId: string,
workflowJsons: WorkflowResponse[],
logger: EvalLogger,
): string {
const candidates = workflowJsons.filter(
(wf) =>
wf?.id && Array.isArray(wf.nodes) && wf.nodes.some((n) => isMockableTriggerNodeType(n.type)),
);
if (candidates.length === 0) return workflowId;
const referencedIds = new Set(candidates.flatMap(executeWorkflowReferences));
const entryPoints = candidates.filter((wf) => !referencedIds.has(wf.id));
const pool = entryPoints.length > 0 ? entryPoints : candidates;
const fallbackId = pool.some((wf) => wf.id === workflowId) ? workflowId : pool[0].id;
const scenarioTokens = scenarioMatchTokens(`${scenario.name} ${scenario.dataSetup}`);
if (pool.length === 1 || scenarioTokens.size === 0) {
if (fallbackId !== workflowId) {
logger.info(
` [${scenario.name}] multi-workflow build: routing to entry point ${fallbackId}`,
);
}
return fallbackId;
}
let bestId = fallbackId;
let bestScore = -1;
let tied = false;
for (const wf of pool) {
const haystackParts: string[] = [wf.name ?? ''];
for (const node of wf.nodes) {
haystackParts.push(String(node.name ?? ''));
try {
haystackParts.push(JSON.stringify(node.parameters ?? {}).slice(0, 500));
} catch {
// skip unserializable parameters
}
}
const haystackTokens = scenarioMatchTokens(haystackParts.join(' '));
let score = 0;
for (const token of scenarioTokens) if (haystackTokens.has(token)) score++;
if (score > bestScore) {
bestScore = score;
bestId = wf.id;
tied = false;
} else if (score === bestScore) {
tied = true;
}
}
if (tied || bestScore <= 0) bestId = fallbackId;
if (bestId !== workflowId) {
logger.info(
` [${scenario.name}] multi-workflow build: routing to workflow ${bestId} (score ${String(bestScore)})`,
);
}
return bestId;
}
async function runScenario(
client: N8nClient,
scenario: ExecutionScenario,
workflowId: string,
workflowJsons: WorkflowResponse[],
logger: EvalLogger,
timeoutMs?: number,
testCaseName?: string,
buildTrace?: BuildTrace,
pinAiRoots?: string[],
seedContext?: ScenarioSeedContext,
): Promise<ExecutionScenarioResult> {
const pinNodes = pinAiRoots && pinAiRoots.length > 0 ? pinAiRoots : undefined;
const targetWorkflowId = selectScenarioWorkflowId(scenario, workflowId, workflowJsons, logger);
// Reset + seed this scenario's declared rows into the tables the build bound,
// just before it runs — clears any prior scenario's (or build-time) rows so
// each scenario runs against exactly the state it declared (TRUST-311). Runs
// serially per case (see scenariosRequireSerialSeeding) to avoid racing on the
// shared table.
if (seedContext) {
await reseedScenarioTables(
client,
scenario,
seedContext.threadId,
seedContext.tableIdsByName,
logger,
);
}
const execStart = Date.now();
let evalResult = await client.executeWithLlmMock(
targetWorkflowId,
scenario.dataSetup,
timeoutMs,
pinNodes,
);
// DB write races abort the execution before any node runs and are reported
// in-band (success:false), bypassing the throw-based transient retry —
// retry them here so they don't pollute builder reliability stats.
for (
let attempt = 1;
!evalResult.success &&
isTransientExecutionAbort(evalResult.errors) &&
attempt < MAX_EXEC_ATTEMPTS;
attempt++
) {
logger.warn(
` [${scenario.name}] execution aborted by transient DB error (attempt ${String(attempt)}/${String(MAX_EXEC_ATTEMPTS)}: ${evalResult.errors.join('; ')}); retrying`,
);
await delay(500 * attempt);
evalResult = await client.executeWithLlmMock(
targetWorkflowId,
scenario.dataSetup,
timeoutMs,
pinNodes,
);
}
const execMs = Date.now() - execStart;
const pinTag = pinNodes ? ` pinned=${pinNodes.join(',')}` : '';
logger.info(
` [${scenario.name}] exec=${String(Math.round(execMs / 1000))}s (${Object.keys(evalResult.nodeResults).length} nodes)${pinTag}`,
);
const verifyStart = Date.now();
const artifact = buildVerificationArtifact(scenario, evalResult, workflowJsons, targetWorkflowId);
const savedChars = artifact.truncationSavedChars ?? 0;
if (savedChars > 0) {
logger.info(
` [${scenario.name}] scenario context capped: saved ${String(savedChars)} chars (~${String(Math.round(savedChars / 4))} tokens)`,
);
}
const scenarioChecklist: ChecklistItem[] = [
{
id: 1,
description: scenario.successCriteria,
category: 'execution',
strategy: 'llm',
},
];
const verification = await verifyChecklist(scenarioChecklist, artifact);
const verificationResults = verification.results;
const verifyMs = Date.now() - verifyStart;
const passed = verificationResults.length > 0 && verificationResults[0].pass;
const result = verificationResults[0];
await writeScenarioVerificationSnapshot({
testCaseName: testCaseName ?? `workflow-${workflowId}`,
scenarioName: scenario.name,
workflowId: targetWorkflowId,
passed,
result,
verificationResults,
verifierAttempts: verification.attempts,
buildTrace,
logger,
});
// Empty verification = the verifier itself failed after all attempts. The run
// is excluded from scoring (mirrors incomplete build expectations) but stays
// visible in console/report/artifact under `verification_failure`.
const incomplete = verificationResults.length === 0;
const attemptErrors = verification.attempts
.map((a) => a.error)
.filter((e): e is string => e !== null);
const reasoning =
result?.reasoning ??
`No verification result — verifier exhausted all attempts${attemptErrors.length > 0 ? ` (${attemptErrors.join('; ')})` : ''}`;
const failureCategory = result?.failureCategory ?? (result ? undefined : 'verification_failure');
const rootCause = result?.rootCause;
const categoryLabel = failureCategory ? ` [${failureCategory}]` : '';
const statusLabel = incomplete ? 'INCOMPLETE (excluded from scoring)' : passed ? 'PASS' : 'FAIL';
logger.info(
` [${scenario.name}] ${statusLabel}${categoryLabel} verify=${String(Math.round(verifyMs / 1000))}s`,
);
if (!passed) {
logger.info(` [${scenario.name}] ${reasoning}`);
}
return {
scenario,
success: passed,
evalResult,
workflowId: targetWorkflowId,
score: passed ? 1 : 0,
reasoning,
failureCategory,
rootCause,
...(incomplete ? { incomplete: true } : {}),
};
}
// ---------------------------------------------------------------------------
// Verification artifact builder
// ---------------------------------------------------------------------------
export interface VerificationArtifact {
/** Workflow structure + connections + node configs. Stable across scenarios of the same build (cacheable). */
workflowContext: string;
/** Scenario + execution trace + errors. Fresh per scenario. */
scenarioContext: string;
/** Chars dropped from oversized JSON blocks / request lists (head/tail truncation). */
truncationSavedChars?: number;
}
/** Per-JSON-block char cap in the scenario context (~1.5k tokens). */
const SCENARIO_JSON_BLOCK_CAP = 6_000;
/** Max intercepted requests rendered per node — first/last half beyond this. */
const MAX_RENDERED_REQUESTS_PER_NODE = 12;
/** Head/tail-truncate an oversized JSON block, keeping shape + boundaries. */
function capJsonBlock(json: string, saved: { chars: number }): string {
if (json.length <= SCENARIO_JSON_BLOCK_CAP) return json;
const half = Math.floor(SCENARIO_JSON_BLOCK_CAP / 2);
const omitted = json.length - 2 * half;
saved.chars += omitted;
return `${json.slice(0, half)}\n… [${String(omitted)} chars truncated] …\n${json.slice(json.length - half)}`;
}
/** Keep the first/last half of an oversized list, dropping the middle. */
function elideMiddle<T>(items: T[], max: number): { head: T[]; tail: T[]; omitted: T[] } {
if (items.length <= max) return { head: items, tail: [], omitted: [] };
const half = Math.floor(max / 2);
return {
head: items.slice(0, half),
tail: items.slice(items.length - half),
omitted: items.slice(half, items.length - half),
};
}
function isObjectRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function isNodeOutputs(value: unknown): value is Record<string, unknown[][]> {
if (!isObjectRecord(value)) return false;
return Object.values(value).every(
(branches) => Array.isArray(branches) && branches.every((branch) => Array.isArray(branch)),
);
}
function getNodeOutputs(value: unknown): Record<string, unknown[][]> {
return isNodeOutputs(value) ? value : {};
}
function getNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' ? value : fallback;
}
function getOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}
/** For a given node + connection type, return downstream node names per output port. */
function getDownstreamsByBranch(
nodeName: string,
connectionType: string,
connections: Record<string, unknown> | undefined,
): string[][] {
if (!connections) return [];
const nodeConns = connections[nodeName];
if (!isObjectRecord(nodeConns)) return [];
const typeConns = nodeConns[connectionType];
if (!Array.isArray(typeConns)) return [];
return typeConns.map((branch) => {
if (!Array.isArray(branch)) return [];
const targets: string[] = [];
for (const c of branch) {
if (isObjectRecord(c) && typeof c.node === 'string') targets.push(c.node);
}
return targets;
});
}
/** Render per-node outputs grouped by connection type + branch, with downstream labels. */
function renderNodeOutputs(
nodeName: string,
outputs: Record<string, unknown[][]>,
outputCount: number,
truncated: boolean | undefined,
connections: Record<string, unknown> | undefined,
saved: { chars: number },
): string[] {
const lines: string[] = [];
const connTypes = Object.keys(outputs);
// "Output: none" only when no branches exist on any port — distinct from "branches exist but all empty".
// An `outputs.main = [[]]` (one connected branch, zero items) falls through and renders as `Output [main]: 0 items`.
if (connTypes.length === 0 || connTypes.every((k) => outputs[k].length === 0)) {
lines.push('**Output:** none');
return lines;
}
for (const connType of connTypes) {
const branches = outputs[connType];
if (branches.length === 0) continue;
const downstreams = getDownstreamsByBranch(nodeName, connType, connections);
const isMultiBranch = branches.length > 1 || connType !== 'main';
if (!isMultiBranch) {
lines.push(`**Output [${connType}]:** ${String(branches[0].length)} items`);
lines.push('```json', capJsonBlock(JSON.stringify(branches[0], null, 2), saved), '```');
continue;
}
for (let i = 0; i < branches.length; i++) {
const branch = branches[i];
const targets = downstreams[i] ?? [];
const targetLabel =
targets.length > 0 ? `${targets.join(', ')}` : '→ (no downstream connection)';
lines.push(
`**Output [${connType} branch ${String(i)}] ${targetLabel}:** ${String(branch.length)} items`,
);
if (branch.length > 0) {
lines.push('```json', capJsonBlock(JSON.stringify(branch, null, 2), saved), '```');
}
}
}
if (truncated) {
lines.push(
`_(items truncated for size; full count across all branches: ${String(outputCount)})_`,
);
}
return lines;
}
/** Render the per-scenario context: scenario, pre-analysis, execution summary, errors, per-node trace. */
function buildScenarioContextBlock(
scenario: ExecutionScenario,
evalResult: InstanceAiEvalExecutionResult,
wf: WorkflowResponse | undefined,
saved: { chars: number },
): string {
const sections: string[] = [];
sections.push(
'## Scenario',
'',
`**Name:** ${scenario.name}${scenario.description}`,
`**Data setup:** ${scenario.dataSetup}`,
'',
);
// Pre-analysis: programmatic flags
const preAnalysis: string[] = [];
if (evalResult.hints.warnings.length > 0) {
for (const warning of evalResult.hints.warnings) {
preAnalysis.push(`⚠ FRAMEWORK ISSUE: ${warning}`);
}
}
if (Object.keys(evalResult.hints.triggerContent).length === 0) {
preAnalysis.push(
'⚠ FRAMEWORK ISSUE: Trigger content is empty — the start node received no input data. All downstream failures are likely caused by this, not by the workflow builder.',
);
}
for (const [nodeName, nr] of Object.entries(evalResult.nodeResults)) {
if (nr.configIssues && Object.keys(nr.configIssues).length > 0) {
preAnalysis.push(
`⚠ BUILDER ISSUE: "${nodeName}" has missing config: ${Object.values(nr.configIssues).flat().join('; ')}`,
);
}
for (const req of nr.interceptedRequests) {
if (
typeof req.mockResponse === 'object' &&
req.mockResponse !== null &&
'_evalMockError' in (req.mockResponse as Record<string, unknown>)
) {
const msg = (req.mockResponse as Record<string, unknown>).message;
const msgStr = typeof msg === 'string' ? msg : 'unknown';
preAnalysis.push(
`⚠ MOCK ISSUE: "${nodeName}" ${req.method} ${req.url} → mock generation failed: ${msgStr}`,
);
}
}
}
if (preAnalysis.length > 0) {
sections.push('## Pre-analysis (automated flags)', '', ...preAnalysis, '');
}
// Execution summary
const mockedNodes: string[] = [];
const pinnedNodes: string[] = [];
const realNodes: string[] = [];
const ranNodes = new Set<string>();
for (const [nodeName, nr] of Object.entries(evalResult.nodeResults)) {
if (nr.executionMode === 'mocked') mockedNodes.push(nodeName);
else if (nr.executionMode === 'pinned') pinnedNodes.push(nodeName);
else realNodes.push(nodeName);
// Pinned nodes (trigger / bypass) get their data from pin data and never appear in runData,
// so `iterationCount` stays 0 — count them as "ran" anyway to keep them out of `didNotRun`.
if (nr.iterationCount > 0 || nr.executionMode !== 'real') ranNodes.add(nodeName);
}
const didNotRun: string[] =
wf?.nodes
.map((n) => n.name)
.filter((name): name is string => typeof name === 'string' && !ranNodes.has(name)) ?? [];
sections.push(
'## Execution summary',
'',
`**Status:** ${evalResult.success ? 'success' : 'failed'}`,
`**Mocked nodes** (HTTP intercepted): ${mockedNodes.join(', ') || 'none'}`,
`**Pinned nodes** (synthetic input): ${pinnedNodes.join(', ') || 'none'}`,
`**Real nodes** (executed with actual logic): ${realNodes.join(', ') || 'none'}`,
`**Did not run** (no execution data): ${didNotRun.join(', ') || 'none'}`,
'',
);
if (evalResult.errors.length > 0) {
sections.push('## Errors', '', ...evalResult.errors.map((e) => `- ${e}`), '');
}
// Per-node execution trace, sorted by start time
sections.push('## Execution trace', '');
const sortedNodeResults = Object.entries(evalResult.nodeResults).sort(
([, a], [, b]) => (a.startTime ?? 0) - (b.startTime ?? 0),
);
for (const [nodeName, nr] of sortedNodeResults) {
const iterTag = nr.iterationCount > 1 ? ` · ran ${String(nr.iterationCount)}×` : '';
const errTag =
nr.firstErrorIteration !== undefined
? ` · first error at iter ${String(nr.firstErrorIteration)}`
: '';
sections.push(`### ${nodeName} [${nr.executionMode}${iterTag}${errTag}]`);
if (nr.configIssues && Object.keys(nr.configIssues).length > 0) {
sections.push(`**Config issues:** ${Object.values(nr.configIssues).flat().join('; ')}`);
}
const renderRequest = (req: (typeof nr.interceptedRequests)[number]): void => {
sections.push(`**Request:** ${req.method} ${req.url}`);
if (req.requestBody) {
sections.push(
'```json',
capJsonBlock(JSON.stringify(req.requestBody, null, 2), saved),
'```',
);
}
if (req.mockResponse !== undefined) {
sections.push('**Mock response:**');
sections.push(
'```json',
capJsonBlock(JSON.stringify(req.mockResponse, null, 2), saved),
'```',
);
}
};
const reqs = elideMiddle(nr.interceptedRequests, MAX_RENDERED_REQUESTS_PER_NODE);
for (const req of reqs.head) renderRequest(req);
if (reqs.omitted.length > 0) {
saved.chars += reqs.omitted.reduce((n, r) => n + JSON.stringify(r).length, 0);
sections.push(`_… ${String(reqs.omitted.length)} further requests omitted for size …_`);
}
for (const req of reqs.tail) renderRequest(req);
const nodeOutputs = getNodeOutputs(nr.outputs);
const outputCount = getNumber(nr.outputCount);
const truncated = getOptionalBoolean(nr.truncated);
sections.push(
...renderNodeOutputs(nodeName, nodeOutputs, outputCount, truncated, wf?.connections, saved),
);
sections.push('');
}
return sections.join('\n');
}
/** Build a verification artifact split into a cacheable workflow block + a fresh scenario block. */
export function buildVerificationArtifact(
scenario: ExecutionScenario,
evalResult: InstanceAiEvalExecutionResult,
workflowJsons: WorkflowResponse[],
workflowId?: string,
): VerificationArtifact {
const wf = workflowJsons.find((w) => w.id === workflowId) ?? workflowJsons[0];
const saved = { chars: 0 };
return {
workflowContext: buildWorkflowContextBlock(wf),
scenarioContext: buildScenarioContextBlock(scenario, evalResult, wf, saved),
truncationSavedChars: saved.chars,
};
}
@@ -0,0 +1,148 @@
// ---------------------------------------------------------------------------
// Scenario seed data tables (TRUST-311)
//
// A case's execution scenarios share one pre-created data table per declared
// name; rows are reset + seeded per scenario. These helpers dedupe the
// declared union, describe it to the agent, and reseed rows before each run.
// ---------------------------------------------------------------------------
import type { InstanceAiEvalSeedDataTable } from '@n8n/api-types';
import type { EvalLogger } from './logger';
import type { N8nClient } from '../clients/n8n-client';
import type { ExecutionScenario } from '../types';
/** Per-scenario row-seeding context: the run's thread and the name→real-id map
* of the tables created empty before the build turn (TRUST-311 follow-up). */
export interface ScenarioSeedContext {
threadId: string;
tableIdsByName: Record<string, string>;
}
/** Max distinct scenario seed tables per case — mirrors the restore-thread
* DTO's `dataTables` cap, since the whole union is sent in one call. */
const MAX_SEED_DATA_TABLES = 20;
/**
* Deduplicate the data tables an execution-scenario case declares
* (`seedDataTables`) into the union a case shares across its scenarios
* (TRUST-311). A table name is unique per project and the built workflow binds
* it by name, so a case shares ONE table per name across its scenarios; the
* first declaration wins. A later same-name declaration with a different shape
* (columns/rows) is dropped with a warning — the by-name binding can only
* resolve to one table, so keeping the first silently would be data loss for the
* author. Throws if the distinct-name union exceeds the restore-thread DTO's cap
* (the whole union is created in one call). The returned tables carry their
* declared `rows`, but the pre-build creation seeds only the schema — rows are
* reset+seeded per scenario (`reseedScenarioTables`).
*/
export function dedupeScenarioSeedTables(
scenarios: ExecutionScenario[],
logger: EvalLogger,
): InstanceAiEvalSeedDataTable[] {
const byName = new Map<string, InstanceAiEvalSeedDataTable>();
for (const scenario of scenarios) {
for (const table of scenario.seedDataTables ?? []) {
const existing = byName.get(table.name);
if (existing) {
if (!sameSeedTableShape(existing, table)) {
logger.warn(
` Scenario seed table "${table.name}" is declared more than once with different columns/rows; keeping the first declaration and ignoring the rest.`,
);
}
continue;
}
byName.set(table.name, table);
}
}
if (byName.size > MAX_SEED_DATA_TABLES) {
throw new Error(
`A case declares ${String(byName.size)} distinct scenario seed data tables, exceeding the ${String(MAX_SEED_DATA_TABLES)}-table restore limit; reduce the number of distinct table names.`,
);
}
return [...byName.values()];
}
/**
* A note appended to the build's opening message naming the data tables that
* already exist in the workspace (created empty before the build turn) so the
* agent discovers and binds the REAL table (via the Data Table node's
* list/schema) instead of creating a duplicate — the production-faithful flow
* where the user's table pre-exists (TRUST-311 follow-up). Empty when the case
* declares no scenario seed tables.
*/
export function buildSeededTablesNote(tables: InstanceAiEvalSeedDataTable[]): string {
if (tables.length === 0) return '';
const lines = tables.map((table) => {
const columns = table.columns.map((column) => `${column.name}: ${column.type}`).join(', ');
return `- "${table.name}" (columns: ${columns})`;
});
return `\n\nThe following data table(s) already exist in this workspace — reuse them (look them up with the Data Table node's list/schema) instead of creating new ones:\n${lines.join('\n')}`;
}
/**
* True when any scenario declares seed tables. All of a case's scenarios share
* one table per name, so their per-scenario row reset+seed
* (`reseedScenarioTables`) must run serially — concurrent scenarios would race
* on the shared table's rows. Callers gate scenario concurrency to 1 for such
* cases.
*/
export function scenariosRequireSerialSeeding(scenarios: ExecutionScenario[]): boolean {
return scenarios.some((scenario) => (scenario.seedDataTables?.length ?? 0) > 0);
}
/**
* Reset + row-seed a scenario's declared data tables into their pre-seeded real
* ids, just before that scenario executes (TRUST-311). Clears whatever rows a
* prior scenario — or a build-time self-verification execution — left, then
* inserts this scenario's declared rows, so each scenario runs against exactly
* the state it declared (and scenarios may carry different rows for the same
* table). `tableIdsByName` maps the declared table name to the real id created
* before the build turn; a name missing from it means the table was never
* pre-seeded, which is a harness bug, so throw rather than silently skip.
*/
export async function reseedScenarioTables(
client: N8nClient,
scenario: ExecutionScenario,
threadId: string,
tableIdsByName: Record<string, string>,
logger: EvalLogger,
): Promise<void> {
for (const table of scenario.seedDataTables ?? []) {
const tableId = tableIdsByName[table.name];
if (!tableId) {
throw new Error(
`Scenario "${scenario.name}" declares seed table "${table.name}" that was not pre-seeded before the build; cannot bind its rows.`,
);
}
await client.seedDataTableRows(threadId, tableId, table.rows ?? []);
logger.verbose(
` [${scenario.name}] reseeded data table "${table.name}" (${String((table.rows ?? []).length)} row(s))`,
);
}
}
/** Two seed tables bind the same way iff their columns + rows match (the id
* differs per declaration and is cosmetic under by-name seeding). */
function sameSeedTableShape(
a: InstanceAiEvalSeedDataTable,
b: InstanceAiEvalSeedDataTable,
): boolean {
return (
JSON.stringify({ columns: a.columns, rows: a.rows }) ===
JSON.stringify({ columns: b.columns, rows: b.rows })
);
}
/** Agent scenarios don't seed data-table rows (tables exist but stay empty) — shared warning for both orchestration paths. */
export function warnAgentSeedDataTablesIgnored(
logger: EvalLogger,
scenarioName: string,
seedDataTables: unknown[] | undefined,
): void {
if ((seedDataTables?.length ?? 0) > 0) {
logger.warn(
` [${scenarioName}] seedDataTables are not seeded on the agent execution path — tables exist but stay empty`,
);
}
}
@@ -23,11 +23,13 @@ export { seedMcpRegistry } from './mcp-registry/seeder';
export type { McpRegistrySeedResult } from './mcp-registry/seeder';
// -- Concurrency helper --
export { runWithConcurrency } from './harness/runner';
export { runWithConcurrency } from './harness/cleanup';
// -- Runner (split API: build once, run scenarios independently) --
export { buildWorkflow, executeScenario, cleanupBuild } from './harness/runner';
export type { BuildResult, BuildWorkflowConfig } from './harness/runner';
export { buildWorkflow } from './harness/build-workflow';
export type { BuildResult, BuildWorkflowConfig } from './harness/build-workflow';
export { executeScenario } from './harness/scenario-execution';
export { cleanupBuild } from './harness/cleanup';
// -- Workflow discovery --
export { snapshotWorkflowIds } from './outcome/workflow-discovery';
@@ -16,24 +16,23 @@ import { allFailVerdicts, verifyBuildExpectations } from '../build-expectations/
import type { CliArgs } from '../cli/args';
import { buildWorkflowViaMcp, type McpBuildSettings } from '../cli/mcp-builder';
import type { N8nClient } from '../clients/n8n-client';
import {
fetchAgentScenarioContext,
findAgentArtifactRef,
type executeAgentScenario,
} from '../harness/agent-execution';
import { resolveArtifactContext } from '../harness/artifacts/artifact-context';
import type { BuildResult } from '../harness/build-workflow';
import { captureThreadRunDebug } from '../harness/capture-run-debug';
import { effectiveTimeoutMs, runWorkflowChecks } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import {
fetchPrebuiltBuild,
pickPrebuiltWorkflowId,
type PrebuiltManifest,
} from '../harness/prebuilt-workflows';
import {
effectiveTimeoutMs,
fetchAgentScenarioContext,
findAgentArtifactRef,
runWorkflowChecks,
type BuildResult,
type executeAgentScenario,
type executeScenario,
type ScenarioSeedContext,
} from '../harness/runner';
import type { executeScenario } from '../harness/scenario-execution';
import type { ScenarioSeedContext } from '../harness/seed-tables';
import { isTransientNetworkError } from '../harness/transient-error';
import type {
BuildExpectationResult,
@@ -15,15 +15,14 @@ import type { InstanceAiRunDebugResponse } from '@n8n/api-types';
import type { BuildOrchestrator } from './build-orchestrator';
import { sentinelOutcomeFromVerdicts, type TargetOutput } from './reshape';
import type { CliArgs } from '../cli/args';
import { findAgentArtifactRef } from '../harness/agent-execution';
import { cleanupBuild, effectiveTimeoutMs } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import {
cleanupBuild,
effectiveTimeoutMs,
findAgentArtifactRef,
scenariosRequireSerialSeeding,
warnAgentSeedDataTablesIgnored,
type ScenarioSeedContext,
} from '../harness/runner';
} from '../harness/seed-tables';
import {
classifyScenarioExecutionError,
extractErrorMessage,
@@ -16,7 +16,7 @@ import { type RowSink } from './persist';
import { reshapeLangSmithRuns, type ReshapeRunRow } from './reshape';
import { BUILD_ONLY_SCENARIO_NAME, roundRobinCaseRows } from './rows';
import type { WorkflowTestCaseWithFile } from '../data/workflows';
import { runWithConcurrency } from '../harness/runner';
import { runWithConcurrency } from '../harness/cleanup';
import type { MultiRunEvaluation, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
export interface DirectRunConfig extends Omit<EvalSessionConfig, 'wrap'> {
@@ -23,17 +23,17 @@ import { createCasePipeline, type CasePipeline } from './case-pipeline';
import { LaneAllocator } from './lane-allocator';
import type { CliArgs } from '../cli/args';
import type { WorkflowTestCaseWithFile } from '../data/workflows';
import type { EvalLogger } from '../harness/logger';
import type { PrebuiltManifest } from '../harness/prebuilt-workflows';
import { executeAgentScenario } from '../harness/agent-execution';
import {
buildWorkflow,
cleanupBuild,
executeAgentScenario,
executeScenario,
workflowExpectedForCase,
type BuildResult,
type ScenarioSeedContext,
} from '../harness/runner';
} from '../harness/build-workflow';
import { cleanupBuild } from '../harness/cleanup';
import type { EvalLogger } from '../harness/logger';
import type { PrebuiltManifest } from '../harness/prebuilt-workflows';
import { executeScenario } from '../harness/scenario-execution';
import type { ScenarioSeedContext } from '../harness/seed-tables';
import type {
BuildExpectationResult,
ExecutionScenario,
@@ -15,8 +15,9 @@ import type {
import { runBinaryChecks } from '../binaryChecks/index';
import type { BinaryCheckContext } from '../binaryChecks/types';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import { buildWorkflow, type BuildResult } from '../harness/build-workflow';
import { cleanupBuild } from '../harness/cleanup';
import { createLogger, type EvalLogger } from '../harness/logger';
import { buildWorkflow, cleanupBuild, type BuildResult } from '../harness/runner';
import { agentTextOf } from '../utils/conversation-text';
/**