feat(core): Make instanceAI evals more mcp-friendly (no-changelog) (#32916)

This commit is contained in:
Milorad FIlipović
2026-06-25 08:33:50 +02:00
committed by GitHub
parent 1632ad1d8f
commit d3dd105aae
56 changed files with 702 additions and 172 deletions
+14 -10
View File
@@ -199,16 +199,20 @@ Operational details:
### Build expectations (per test case)
A test case can declare optional natural-language assertions about *how the build went* — `buildExpectations: string[]` in its JSON. Each is graded by a separate Sonnet judge (`build-expectations/verifier.ts`) against the **conversation transcript + final workflow + conversation metrics**, and **counts as a unit in the pass rate**: evaluated expectations fold into the per-case and headline pass@k/pass^k alongside execution scenarios. It doesn't flip an individual scenario's pass/fail (it's its own unit), and a judge `incomplete` verdict is excluded from the count.
A test case can declare optional natural-language assertions, split by what they judge:
Use it for things the binary checks and `successCriteria` don't cover:
- **`processExpectations: string[]`** — about *how the build went* (clarifications asked, push-back, ordering). Judged from the **conversation transcript** (plus the workflow and conversation metrics). They require a transcript, so they are **skipped in prebuilt/MCP runs**. e.g. `"Before building, the agent asked which Slack channel to use."`
- **`outcomeExpectations: string[]`** — about the **resulting workflow**. Judged from the **workflow JSON**, so they **also run in prebuilt/MCP runs** (which have no transcript). e.g. `"The final workflow splits the records envelope before posting."`
- **Process / conversational** — `"Before building, the agent asked which Slack channel to use."` (judged from the transcript)
- **Outcome tied to the conversation** — `"The final workflow reflects the user's follow-up to split the records envelope before posting."` (judged from the workflow)
Both are graded by the same Sonnet judge (`build-expectations/verifier.ts`) and **count as units in the pass rate**: evaluated expectations fold into the per-case and headline pass@k/pass^k alongside execution scenarios. They don't flip an individual scenario's pass/fail (each is its own unit), and a judge `incomplete` verdict is excluded from the count. A full build judges the union of both fields against the transcript; a prebuilt build judges only `outcomeExpectations` against the workflow.
Use them for things the binary checks and `successCriteria` don't cover:
```json
"buildExpectations": [
"Before building, the agent asked which Airtable table and which Slack channel to use.",
"processExpectations": [
"Before building, the agent asked which Airtable table and which Slack channel to use."
],
"outcomeExpectations": [
"The agent honored the user's instruction to fetch via an HTTP Request node, not the Airtable node."
]
```
@@ -216,14 +220,14 @@ Use it for things the binary checks and `successCriteria` don't cover:
The signal surfaces in:
- **HTML report** — a "Build expectations" disclosure on the test case: per-expectation ✓/✗ with a one-line judge reason.
- **`eval-results.json`** — `buildExpectations` (aggregated per-expectation pass rate) plus `buildExpectationResultsPerRun` (per-iteration verdicts).
- **`eval-results.json`** — `buildExpectations` (aggregated per-expectation pass rate across both fields) plus `buildExpectationResultsPerRun` (per-iteration verdicts).
Operational details:
- Judged **once per build** (not per scenario), fired concurrently with the scenario batch — ~0 added wall-clock in the common case.
- Runs on both eval paths (direct loop + LangSmith). Requires a build transcript, so it's judged even when the build fails, and skipped only when no transcript was captured.
- Runs on both eval paths (direct loop + LangSmith). `processExpectations` need a transcript (judged even when the build fails, skipped only when no transcript was captured); `outcomeExpectations` are judged from the workflow, including in prebuilt/MCP runs.
- The judge retries on failure, has a per-attempt timeout, and falls back to an all-fail verdict — a judge failure can't break a run.
- Absent the field, it's a complete no-op.
- Absent both fields, it's a complete no-op.
## Environment variables
@@ -539,7 +543,7 @@ Test cases live in `evaluations/data/workflows/*.json`. Drop a file in — the C
}
```
`conversation` (≥1 turn, first must be `user`) and `executionScenarios` (≥1), plus `complexity` and `tags`, are required. `description`, `triggerType`, `messageBudget`, `buildExpectations`, `credentials`, and `datasets` (default `["full"]`) are optional. A turns `text` may be a string or an array of strings joined with newlines — handy for long stage directions.
`conversation` (≥1 turn, first must be `user`) and `executionScenarios` (≥1), plus `complexity` and `tags`, are required. `description`, `triggerType`, `messageBudget`, `processExpectations`, `outcomeExpectations`, `credentials`, and `datasets` (default `["full"]`) are optional. A turns `text` may be a string or an array of strings joined with newlines — handy for long stage directions.
**One JSON file = one LangSmith split**, named from the filename slug. Pick a slug you're happy to also use as a `--filter` target.
@@ -0,0 +1,28 @@
import { collectExpectations } from '../build-expectations/collect';
describe('collectExpectations', () => {
it('returns an empty array when neither field is set', () => {
expect(collectExpectations({})).toEqual([]);
});
it('returns only process expectations when outcome is absent', () => {
expect(collectExpectations({ processExpectations: ['p1', 'p2'] })).toEqual(['p1', 'p2']);
});
it('returns only outcome expectations when process is absent', () => {
expect(collectExpectations({ outcomeExpectations: ['o1'] })).toEqual(['o1']);
});
it('concatenates process expectations before outcome expectations', () => {
expect(
collectExpectations({
processExpectations: ['p1', 'p2'],
outcomeExpectations: ['o1', 'o2'],
}),
).toEqual(['p1', 'p2', 'o1', 'o2']);
});
it('treats empty arrays as empty', () => {
expect(collectExpectations({ processExpectations: [], outcomeExpectations: [] })).toEqual([]);
});
});
@@ -1,5 +1,9 @@
import type { TranscriptTurn } from '../types';
import { transcriptAsText, userTurnsAsText } from '../utils/conversation-text';
import type { ConversationTurn, TranscriptTurn } from '../types';
import {
conversationUserTurnsAsText,
transcriptAsText,
userTurnsAsText,
} from '../utils/conversation-text';
describe('userTurnsAsText', () => {
it('returns empty string on empty transcript', () => {
@@ -24,6 +28,36 @@ describe('userTurnsAsText', () => {
});
});
describe('conversationUserTurnsAsText', () => {
it('returns empty string on empty conversation', () => {
expect(conversationUserTurnsAsText([])).toBe('');
});
it('returns empty string when conversation is undefined (seedThread-only case)', () => {
expect(conversationUserTurnsAsText(undefined)).toBe('');
});
it('returns the lone user message as plain text on a single user turn', () => {
const conversation: ConversationTurn[] = [{ role: 'user', text: 'build a webhook' }];
expect(conversationUserTurnsAsText(conversation)).toBe('build a webhook');
});
it('numbers user turns on multi-turn and drops assistant/empty turns', () => {
const conversation: ConversationTurn[] = [
{ role: 'user', text: 'build it' },
{ role: 'assistant', text: 'what kind?' },
{ role: 'user', text: '' },
{ role: 'user', text: 'a webhook' },
];
expect(conversationUserTurnsAsText(conversation)).toBe('Turn 1: build it\n\nTurn 2: a webhook');
});
it('returns empty string when there are no non-empty user turns', () => {
const conversation: ConversationTurn[] = [{ role: 'assistant', text: 'hello' }];
expect(conversationUserTurnsAsText(conversation)).toBe('');
});
});
describe('transcriptAsText', () => {
it('surfaces tool-call args and result so the judge sees what each call did', () => {
const transcript: TranscriptTurn[] = [
@@ -117,31 +117,49 @@ describe('WorkflowTestCaseSchema', () => {
expect(parsed.triggerType).toBe('webhook');
});
it('accepts the optional buildExpectations array', () => {
it('accepts the optional process/outcome expectation arrays', () => {
const parsed = WorkflowTestCaseSchema.parse({
...validFixture(),
buildExpectations: ['the agent asked which channel before building'],
processExpectations: ['the agent asked which channel before building'],
outcomeExpectations: ['the final workflow posts to Slack'],
});
expect(parsed.buildExpectations).toEqual(['the agent asked which channel before building']);
expect(parsed.processExpectations).toEqual(['the agent asked which channel before building']);
expect(parsed.outcomeExpectations).toEqual(['the final workflow posts to Slack']);
});
it('leaves buildExpectations undefined when omitted', () => {
it('leaves expectation arrays undefined when omitted', () => {
const parsed = WorkflowTestCaseSchema.parse(validFixture());
expect(parsed.buildExpectations).toBeUndefined();
expect(parsed.processExpectations).toBeUndefined();
expect(parsed.outcomeExpectations).toBeUndefined();
});
it('rejects a non-array buildExpectations', () => {
it('rejects a non-array expectation field', () => {
expect(() =>
WorkflowTestCaseSchema.parse({ ...validFixture(), buildExpectations: 'nope' }),
WorkflowTestCaseSchema.parse({ ...validFixture(), outcomeExpectations: 'nope' }),
).toThrow();
});
it('rejects an empty-string expectation', () => {
expect(() =>
WorkflowTestCaseSchema.parse({ ...validFixture(), buildExpectations: [''] }),
WorkflowTestCaseSchema.parse({ ...validFixture(), processExpectations: [''] }),
).toThrow();
});
it('rejects a legacy buildExpectations key with a migration hint', () => {
expect(() =>
WorkflowTestCaseSchema.parse({
...validFixture(),
buildExpectations: ['legacy assertion that would otherwise be silently dropped'],
}),
).toThrow(/no longer supported/);
});
it('rejects an unknown top-level key instead of silently stripping it', () => {
expect(() =>
WorkflowTestCaseSchema.parse({ ...validFixture(), outcomeExpectaiton: ['typo'] }),
).toThrow(/[Uu]nrecognized key/);
});
it('accepts a credentials entry with a supported type', () => {
const parsed = WorkflowTestCaseSchema.parse({
...validFixture(),
@@ -40,7 +40,7 @@ const turn: TranscriptTurn = { steps: [{ kind: 'agent-text', text: 'building...'
const verdict: BuildExpectationResult = { expectation: 'asked first', pass: true, reason: 'did' };
describe('reshapeLangSmithRuns', () => {
it('reattaches transcript + build-expectation verdicts to the test case by threadId', () => {
it('reattaches transcript by threadId and build-expectation verdicts by iteration:fileSlug', () => {
const cases = [withFile('airtable', [scenario('s1'), scenario('s2')])];
const rows = [
row(
@@ -58,7 +58,7 @@ describe('reshapeLangSmithRuns', () => {
cases,
1,
new Map([['tid-1', [turn]]]),
new Map([['tid-1', [verdict]]]),
new Map([['0:airtable', [verdict]]]),
'http://localhost:5678',
);
@@ -72,24 +72,24 @@ describe('reshapeLangSmithRuns', () => {
expect(tc.executionScenarioResults.map((r) => r.success)).toEqual([true, true]);
});
it('leaves transcript + verdicts undefined when the run output carries no threadId (regression: dropped threadId)', () => {
// Models the exec-error return that omitted threadId: build succeeded, but
// with no threadId on the output the join can't find the maps' entries.
it('attaches build-expectation verdicts by iteration:fileSlug even with no threadId (prebuilt/MCP path)', () => {
// Prebuilt/MCP builds have no threadId. Transcript stays threadId-gated (so it
// remains undefined here), but outcome-expectation verdicts must still attach via
// the build-cache key, so LangSmith prebuilt runs match the direct-loop path.
const cases = [withFile('airtable', [scenario('s1')])];
const rows = [
row(
{ testCaseFile: 'airtable', scenarioName: 's1', _iteration: 0 },
{ buildSuccess: true, passed: false, score: 0, reasoning: 'exec error' },
{ buildSuccess: true, passed: true, score: 1, reasoning: 'ok' },
),
];
// Maps DO hold data under a real threadId — proving we don't misattach it.
const result = reshapeLangSmithRuns(
rows,
cases,
1,
new Map([['tid-real', [turn]]]),
new Map([['tid-real', [verdict]]]),
new Map([['0:airtable', [verdict]]]),
undefined,
);
@@ -97,7 +97,7 @@ describe('reshapeLangSmithRuns', () => {
expect(tc.workflowBuildSuccess).toBe(true);
expect(tc.threadId).toBeUndefined();
expect(tc.transcript).toBeUndefined();
expect(tc.buildExpectationResults).toBeUndefined();
expect(tc.buildExpectationResults).toEqual([verdict]);
});
it('stubs a build_failure for a scenario with no matching run', () => {
@@ -0,0 +1,111 @@
import { selectAuthorExpectations } from '../build-expectations/select';
import type { EvalLogger } from '../harness/logger';
import type { ConversationTurn, TranscriptTurn, WorkflowTestCase } from '../types';
function makeLogger(): { logger: EvalLogger; warnings: string[] } {
const warnings: string[] = [];
const logger: EvalLogger = {
info: () => {},
verbose: () => {},
success: () => {},
warn: (msg: string) => warnings.push(msg),
error: () => {},
isVerbose: false,
};
return { logger, warnings };
}
const conversation: ConversationTurn[] = [{ role: 'user', text: 'build it' }];
const realTranscript: TranscriptTurn[] = [{ userMessage: 'build it', steps: [] }];
function testCase(
over: Partial<Pick<WorkflowTestCase, 'processExpectations' | 'outcomeExpectations'>> = {},
): Pick<WorkflowTestCase, 'processExpectations' | 'outcomeExpectations' | 'conversation'> {
return { conversation, ...over };
}
describe('selectAuthorExpectations', () => {
it('judges the process+outcome union against the real transcript for a full build', () => {
const { logger, warnings } = makeLogger();
const { expectations, transcript } = selectAuthorExpectations({
testCase: testCase({ processExpectations: ['p1'], outcomeExpectations: ['o1'] }),
transcript: realTranscript,
buildSucceeded: true,
isPrebuilt: false,
logger,
});
expect(expectations).toEqual(['p1', 'o1']);
expect(transcript).toBe(realTranscript);
expect(warnings).toEqual([]);
});
it('judges only outcome expectations against a synthesized transcript for a prebuilt build', () => {
const { logger, warnings } = makeLogger();
const { expectations, transcript } = selectAuthorExpectations({
testCase: testCase({ processExpectations: ['p1'], outcomeExpectations: ['o1'] }),
transcript: undefined,
buildSucceeded: true,
isPrebuilt: true,
logger,
});
expect(expectations).toEqual(['o1']);
expect(transcript).toEqual([{ userMessage: 'build it', steps: [] }]);
expect(warnings).toEqual([]);
});
it('synthesizes an empty-prompt transcript for a seedThread-style case with no authored conversation', () => {
const { logger, warnings } = makeLogger();
const { expectations, transcript } = selectAuthorExpectations({
// seedThread cases carry no authored `conversation`; on the prebuilt/no-transcript
// path this must not crash (regression: conversationUserTurnsAsText(undefined)).
testCase: { outcomeExpectations: ['o1'] },
transcript: undefined,
buildSucceeded: true,
isPrebuilt: true,
logger,
});
expect(expectations).toEqual(['o1']);
expect(transcript).toEqual([{ userMessage: '', steps: [] }]);
expect(warnings).toEqual([]);
});
it('warns when a full (non-prebuilt) build has no transcript but declares process expectations', () => {
const { logger, warnings } = makeLogger();
const { expectations } = selectAuthorExpectations({
testCase: testCase({ processExpectations: ['p1', 'p2'], outcomeExpectations: ['o1'] }),
transcript: undefined,
buildSucceeded: true,
isPrebuilt: false,
logger,
});
expect(expectations).toEqual(['o1']);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('no transcript');
expect(warnings[0]).toContain('2 process expectation');
});
it('does not warn for a full no-transcript build that declares no process expectations', () => {
const { logger, warnings } = makeLogger();
selectAuthorExpectations({
testCase: testCase({ outcomeExpectations: ['o1'] }),
transcript: undefined,
buildSucceeded: true,
isPrebuilt: false,
logger,
});
expect(warnings).toEqual([]);
});
it('judges nothing and does not warn when a build fails with no transcript', () => {
const { logger, warnings } = makeLogger();
const { expectations } = selectAuthorExpectations({
testCase: testCase({ processExpectations: ['p1'], outcomeExpectations: ['o1'] }),
transcript: undefined,
buildSucceeded: false,
isPrebuilt: false,
logger,
});
expect(expectations).toEqual([]);
expect(warnings).toEqual([]);
});
});
@@ -0,0 +1,14 @@
import type { WorkflowTestCase } from '../types';
/**
* Author expectations to judge for a full build: process turns first, then outcome.
*
* The original single `buildExpectations` order isn't preserved across the split,
* but verdicts are matched back by expectation string (not index), so ordering
* only affects the judge's numbered list — within its inherent run-to-run noise.
*/
export function collectExpectations(
testCase: Pick<WorkflowTestCase, 'processExpectations' | 'outcomeExpectations'>,
): string[] {
return [...(testCase.processExpectations ?? []), ...(testCase.outcomeExpectations ?? [])];
}
@@ -0,0 +1,59 @@
import { collectExpectations } from './collect';
import type { EvalLogger } from '../harness/logger';
import type { TranscriptTurn, WorkflowTestCase } from '../types';
import { conversationUserTurnsAsText } from '../utils/conversation-text';
export interface SelectAuthorExpectationsArgs {
testCase: Pick<WorkflowTestCase, 'processExpectations' | 'outcomeExpectations' | 'conversation'>;
/** Captured build transcript, if any. Empty/absent for prebuilt/MCP builds. */
transcript: TranscriptTurn[] | undefined;
buildSucceeded: boolean;
/** True only on the `--prebuilt-workflows` path. Lets us distinguish an
* expected no-transcript (prebuilt) build from an unexpected one (a full
* build whose event capture failed). */
isPrebuilt: boolean;
logger: EvalLogger;
}
/**
* Decide which author expectations to judge for a build, and the transcript to
* feed the judge.
*
* - Full build with a transcript → judge the process + outcome union against the
* real transcript.
* - No transcript but the build succeeded → judge only `outcomeExpectations`
* against the workflow, with the authored conversation as request context.
* This is the prebuilt/MCP path.
* - Build failed with no transcript → judge nothing.
*
* A successful full (non-prebuilt) build should always carry a transcript; if it
* doesn't, `processExpectations` can't be judged. We still skip them (judging
* them against no transcript would only produce false failures), but warn so the
* lost signal — likely an event-capture bug — isn't silently swallowed.
*/
export function selectAuthorExpectations(args: SelectAuthorExpectationsArgs): {
expectations: string[];
transcript: TranscriptTurn[];
} {
const { testCase, buildSucceeded, isPrebuilt, logger } = args;
const hasTranscript = (args.transcript?.length ?? 0) > 0;
const processCount = testCase.processExpectations?.length ?? 0;
if (!isPrebuilt && !hasTranscript && buildSucceeded && processCount > 0) {
logger.warn(
` Full build produced no transcript — skipping ${String(processCount)} process expectation(s); only outcome expectations will be judged (possible event-capture issue)`,
);
}
const expectations = hasTranscript
? collectExpectations(testCase)
: buildSucceeded
? (testCase.outcomeExpectations ?? [])
: [];
const transcript: TranscriptTurn[] = hasTranscript
? args.transcript!
: [{ userMessage: conversationUserTurnsAsText(testCase.conversation), steps: [] }];
return { expectations, transcript };
}
@@ -1,3 +1,4 @@
import { collectExpectations } from '../build-expectations/collect';
import type {
WorkflowTestCaseResult,
MultiRunEvaluation,
@@ -96,7 +97,7 @@ export function aggregateResults(
// Aggregate each build expectation as a measured unit alongside scenarios.
// `incomplete` verdicts are excluded from the count (denominator = evaluated runs).
const buildExpectations: BuildExpectationAggregation[] = (testCase.buildExpectations ?? []).map(
const buildExpectations: BuildExpectationAggregation[] = collectExpectations(testCase).map(
(expectation) => {
const expRuns = runs
.map((r) => (r.buildExpectationResults ?? []).find((e) => e.expectation === expectation))
@@ -10,6 +10,7 @@ import { homedir, tmpdir } from 'os';
import { basename, join, resolve } from 'path';
import { z } from 'zod';
import { DEFAULT_DATASETS } from '../data/workflows/schema';
import { prebuiltManifestSchema, type PrebuiltManifest } from '../harness/prebuilt-workflows';
import { runWithConcurrency } from '../harness/runner';
@@ -31,6 +32,8 @@ interface CliArgs {
slugs: string[];
maxAttempts: number;
mcpTimeoutMs: number;
/** When set, only build slugs whose `datasets` array includes this tier (mirrors eval --tier). */
tier?: string;
/** When set, instructs the model to pass `projectId` to
* `create_workflow_from_code` so workflows land in a specific n8n project.
* When unset, workflows go to the user's personal project (MCP default). */
@@ -77,6 +80,9 @@ Flags:
--mcp-timeout-ms N MCP_TIMEOUT env passed to claude -p (default: 120000).
--project-id ID n8n project to create the workflows in. Defaults
to the user's personal project.
--tier TIER Only build test cases whose datasets array includes
TIER (e.g. "mcp"). Mirrors eval:instance-ai --tier.
Applies to discovered and positional slugs alike.
--workflow-dir DIR Test-case JSON directory. Defaults to
evaluations/data/workflows/ derived from the n8n
repo (via git). Set this to run from outside the
@@ -170,6 +176,10 @@ function parseArgs(argv: string[]): ParseResult {
result.projectId = nextArg(argv, i, arg);
i += 2;
break;
case '--tier':
result.tier = nextArg(argv, i, arg);
i += 2;
break;
case '--workflow-dir':
result.workflowDir = nextArg(argv, i, arg);
i += 2;
@@ -541,6 +551,26 @@ function discoverSlugs(workflowDir: string): string[] {
.sort();
}
const tierDatasetsSchema = z.object({ datasets: z.array(z.string()).optional() }).passthrough();
/** A test case's `datasets`, defaulting to the shared eval default when absent — mirrors the loader schema. */
function readDatasets(workflowDir: string, slug: string): string[] {
const file = join(workflowDir, `${slug}.json`);
if (!existsSync(file)) return [];
try {
return (
tierDatasetsSchema.parse(readJson(file, `test case ${slug}`)).datasets ?? DEFAULT_DATASETS
);
} catch {
return DEFAULT_DATASETS;
}
}
/** Keep only slugs whose `datasets` includes `tier`, mirroring eval:instance-ai --tier semantics. */
function filterSlugsByTier(workflowDir: string, slugs: string[], tier: string): string[] {
return slugs.filter((slug) => readDatasets(workflowDir, slug).includes(tier));
}
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2));
if (parsed.helpRequested) {
@@ -587,8 +617,13 @@ async function main(): Promise<void> {
if (args.slugs.length === 0) {
args.slugs = discoverSlugs(workflowDir);
}
if (args.tier) {
args.slugs = filterSlugsByTier(workflowDir, args.slugs, args.tier);
}
if (args.slugs.length === 0) {
throw new Error('No scenarios to build');
throw new Error(
args.tier ? `No scenarios match --tier "${args.tier}"` : 'No scenarios to build',
);
}
const projectScopes = uniqueProjectScopes([
@@ -28,6 +28,7 @@ import {
type TargetOutput,
} from './reshape';
import { aggregateWorkflowChecks, statusMap } from '../binaryChecks/aggregate';
import { selectAuthorExpectations } from '../build-expectations/select';
import { allFailVerdicts, verifyBuildExpectations } from '../build-expectations/verifier';
import { N8nClient } from '../clients/n8n-client';
import {
@@ -74,7 +75,7 @@ import type {
WorkflowTestCase,
WorkflowTestCaseResult,
} from '../types';
import { caseDisplayPrompt } from '../utils/conversation-text';
import { caseDisplayPrompt, conversationUserTurnsAsText } from '../utils/conversation-text';
// n8n degrades above ~4 concurrent builds.
const MAX_CONCURRENT_BUILDS = 4;
@@ -263,9 +264,11 @@ async function runWithLangSmith(config: RunConfig): Promise<{
// Stash transcripts by threadId so reshapeLangSmithRuns can merge them in —
// the LangSmith target() output schema doesn't carry the full transcript.
const transcriptByThreadId = new Map<string, TranscriptTurn[]>();
// Build-expectation verdicts, judged once per build and merged the same way —
// fired during getOrBuild, awaited before reshapeLangSmithRuns.
const buildExpectationsByThreadId = new Map<string, Promise<BuildExpectationResult[]>>();
// Build-expectation verdicts, judged once per build and merged by the build-cache
// key (`iteration:fileSlug`) rather than threadId — so prebuilt/MCP builds, which
// have no threadId, still get their outcome expectations judged and counted.
// Fired during getOrBuild, awaited before reshapeLangSmithRuns.
const buildExpectationsByKey = new Map<string, Promise<BuildExpectationResult[]>>();
const runDebugByThreadId = new Map<string, Promise<InstanceAiRunDebugResponse[]>>();
// LangSmith dataset rows carry only per-scenario fields. The build-side
@@ -395,13 +398,16 @@ async function runWithLangSmith(config: RunConfig): Promise<{
const buildDurationMs = Date.now() - start;
buildDurations.set(key, buildDurationMs);
stashTranscript(build);
stashBuildExpectations(fileSlug, build);
stashBuildExpectations(key, fileSlug, build, true);
stashRunDebug(lane.runner.client, build);
if (build.success && !build.workflowChecks) {
// No transcript in prebuilt mode — checks run with empty prompt context.
// No transcript in prebuilt mode, but the authored conversation still
// carries the user's request — feed it so prompt-aware checks (e.g.
// fulfills_user_request) grade against real intent instead of "".
const conversation = testCaseByFileSlug.get(fileSlug)?.conversation ?? [];
build.workflowChecks = await runWorkflowChecks({
workflow: build.workflowJsons[0],
prompt: '',
prompt: conversationUserTurnsAsText(conversation),
agentText: undefined,
logger,
});
@@ -426,7 +432,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
const buildDurationMs = Date.now() - start;
buildDurations.set(key, buildDurationMs);
stashTranscript(build);
stashBuildExpectations(fileSlug, build);
stashBuildExpectations(key, fileSlug, build, false);
stashRunDebug(lane.runner.client, build);
return { build, lane, buildDurationMs };
} finally {
@@ -448,19 +454,31 @@ async function runWithLangSmith(config: RunConfig): Promise<{
runDebugByThreadId.set(build.threadId, captureThreadRunDebug(client, build.threadId, logger));
}
// Judge build expectations once per build (off the scenario critical path);
// reshapeLangSmithRuns awaits and merges the verdicts by threadId.
function stashBuildExpectations(fileSlug: string, build: BuildResult): void {
const expectations = testCaseByFileSlug.get(fileSlug)?.buildExpectations;
// Judge whenever there's a transcript — even on build failure, matching the
// direct-loop runner; the judge prompt handles the "no workflow produced" case.
if (!build.threadId || !expectations?.length || !build.transcript?.length) {
return;
}
buildExpectationsByThreadId.set(
build.threadId,
// Judge author expectations once per build (off the scenario critical path);
// reshapeLangSmithRuns awaits and merges the verdicts by the build-cache key.
// Full builds judge process + outcome against the real transcript; prebuilt/MCP
// builds (no transcript) judge only outcome expectations against the workflow,
// with the authored conversation as request context — mirroring the direct loop.
function stashBuildExpectations(
key: string,
fileSlug: string,
build: BuildResult,
isPrebuilt: boolean,
): void {
const testCase = testCaseByFileSlug.get(fileSlug);
if (!testCase) return;
const { expectations, transcript } = selectAuthorExpectations({
testCase,
transcript: build.transcript,
buildSucceeded: build.success,
isPrebuilt,
logger,
});
if (expectations.length === 0) return;
buildExpectationsByKey.set(
key,
verifyBuildExpectations(expectations, {
transcript: build.transcript,
transcript,
workflowJson: build.workflowJsons[0],
metrics: build.conversationMetrics,
}).catch((error: unknown) =>
@@ -679,8 +697,8 @@ async function runWithLangSmith(config: RunConfig): Promise<{
await lsClient.awaitPendingTraceBatches();
const buildExpectationsResolved = new Map<string, BuildExpectationResult[]>();
for (const [threadId, verdictsPromise] of buildExpectationsByThreadId) {
buildExpectationsResolved.set(threadId, await verdictsPromise);
for (const [key, verdictsPromise] of buildExpectationsByKey) {
buildExpectationsResolved.set(key, await verdictsPromise);
}
const runDebugResolved = new Map<string, InstanceAiRunDebugResponse[]>();
for (const [threadId, runDebugPromise] of runDebugByThreadId) {
@@ -137,7 +137,9 @@ export function reshapeLangSmithRuns(
testCasesWithFiles: WorkflowTestCaseWithFile[],
numIterations: number,
transcriptByThreadId: Map<string, TranscriptTurn[]>,
buildExpectationsByThreadId: Map<string, BuildExpectationResult[]>,
/** Keyed by the build-cache key (`iteration:fileSlug`), not threadId, so prebuilt
* builds (no threadId) still attach their outcome-expectation verdicts. */
buildExpectationsByKey: Map<string, BuildExpectationResult[]>,
n8nBaseUrl: string | undefined,
runDebugByThreadId: Map<string, InstanceAiRunDebugResponse[]> = new Map(),
): WorkflowTestCaseResult[][] {
@@ -195,9 +197,7 @@ export function reshapeLangSmithRuns(
}
const transcript = threadId ? transcriptByThreadId.get(threadId) : undefined;
const buildExpectationResults = threadId
? buildExpectationsByThreadId.get(threadId)
: undefined;
const buildExpectationResults = buildExpectationsByKey.get(`${String(iter)}:${fileSlug}`);
runResults.push({
testCase,
fileSlug,
@@ -7,6 +7,7 @@
],
"complexity": "medium",
"tags": ["build", "agent", "ai-tool", "mcp-registry", "notion"],
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -22,7 +22,7 @@
}
],
"messageBudget": 6,
"buildExpectations": [
"outcomeExpectations": [
"The agent honored the user's explicit instruction to fetch the tasks via an HTTP Request node with Bearer auth, not the dedicated Airtable node.",
"The final workflow reflects the user's follow-up that Airtable returns a `{records: [...]}` envelope, splitting it into individual items (e.g. a Split Out node) before posting to Slack."
],
@@ -30,11 +30,13 @@
"tags": ["behaviour", "changes-applied", "long-conversation", "batching", "schedule", "slack"],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"By the end, the workflow reflects every change the user asked for: it is named 'Daily Standup Bot'; it runs at 8:45am, only on Monday/Wednesday/Friday; the first message's text is assembled in a preceding Set/Edit Fields step that the post references, starts with a 📢 emoji, reads 'Standup in 5 minutes', mentions @here, and includes today's date; there is a second message 30 minutes later that says 'Standup now', mentions @here, and replies in the first message's Slack thread; and if a Slack post fails the workflow continues and posts to a #alerts channel. Nothing the user asked for is missing.",
"processExpectations": [
"After every change the user asked for, there is a before and after diff in the workflow that shows the change was applied. User request was accurately applied in the workflow.",
"The agent did not repeat itself to build: in each turn it applied that message's change(s) in a single build of the workflow, rather than building once per change. A message bundling several changes should still be one build, not one per change."
],
"outcomeExpectations": [
"By the end, the workflow reflects every change the user asked for: it is named 'Daily Standup Bot'; it runs at 8:45am, only on Monday/Wednesday/Friday; the first message's text is assembled in a preceding Set/Edit Fields step that the post references, starts with a 📢 emoji, reads 'Standup in 5 minutes', mentions @here, and includes today's date; there is a second message 30 minutes later that says 'Standup now', mentions @here, and replies in the first message's Slack thread; and if a Slack post fails the workflow continues and posts to a #alerts channel. Nothing the user asked for is missing."
],
"executionScenarios": [
{
"name": "standup-reminder",
@@ -19,7 +19,7 @@
"tags": ["behaviour", "clarification", "question-tool", "schedule", "slack"],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"Before building, the agent asked which destination to send the summary to, since the request did not specify one.",
"The agent asked using the structured question tool, not by posing the question in plain assistant text.",
"The agent waited for the user's answer before building."
@@ -8,6 +8,7 @@
"complexity": "medium",
"tags": ["webhook", "binary", "multipart", "slack", "upload"],
"triggerType": "webhook",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -8,6 +8,7 @@
"complexity": "simple",
"tags": ["http-request", "binary", "pdf", "manual"],
"triggerType": "manual",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -8,6 +8,7 @@
"complexity": "complex",
"tags": ["telegram", "binary", "audio", "openai", "whisper", "webhook"],
"triggerType": "webhook",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -41,10 +41,12 @@
}
],
"description": "Across an iterative multi-turn build, the agent must keep carrying the workflow and the facts established earlier in the thread. When the user sends a terse final follow-up that only resolves against earlier context (the onboarding validation project and its agreed counts, established a few turns earlier and deliberately not restated), the agent must recall those facts and keep working the SAME workflow — not cold-start ('this appears to be the start of our conversation'), ask an unrelated or already-answered clarifying question, re-ask details already given, or build a fresh workflow from scratch.",
"buildExpectations": [
"processExpectations": [
"On the final turn the user referred back to the onboarding project's agreed numbers without restating them; the agent treated that target (established earlier in the thread as 45 active / 41 completed / 86 total) as already-known and did not ask the user to repeat it or to re-identify the onboarding project.",
"The agent never cold-started or claimed it lacked prior context — it did not respond as if the latest message were the beginning of the conversation, and did not ask an unrelated or already-answered clarifying question in place of engaging with the terse, context-dependent follow-up.",
"Across the refinement turns the agent kept working the single workflow it had been building throughout the thread; it never created a new workflow or restarted the build from scratch.",
"Across the refinement turns the agent kept working the single workflow it had been building throughout the thread; it never created a new workflow or restarted the build from scratch."
],
"outcomeExpectations": [
"Every refinement added earlier in the thread is still present in the final workflow — fail if ANY is missing: the active/completed split derived from task.status, the fields[tasks] parameter requesting at least projectId, and page[size]=500. The agent did not silently drop an earlier-agreed setting while handling the final follow-up."
],
"triggerType": "schedule",
@@ -25,6 +25,7 @@
"complexity": "medium",
"tags": ["build", "webhook", "gmail", "telegram", "google-sheets", "multi-action"],
"triggerType": "webhook",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -25,6 +25,7 @@
"complexity": "complex",
"tags": ["build", "linear", "slack", "schedule", "data-processing"],
"triggerType": "schedule",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -9,6 +9,7 @@
"complexity": "medium",
"tags": ["build", "gmail", "ai", "schedule", "conditional", "digest"],
"triggerType": "schedule",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "emails-with-action-items",
@@ -25,6 +25,7 @@
"complexity": "medium",
"tags": ["build", "slack", "ai", "schedule"],
"triggerType": "schedule",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -30,7 +30,8 @@
"complexity": "complex",
"tags": ["data-table", "schema", "build", "behaviour", "no-simplify"],
"triggerType": "schedule",
"buildExpectations": [
"datasets": ["mcp", "full"],
"outcomeExpectations": [
"The agent created the pd_tq_clusters data table with all ten specified columns: theme_key, cluster_id, period_id, title, thread_count, rank, consecutive_weeks, first_seen_period, last_seen_period, and seen_at.",
"The agent created the pd_periods data table with all six specified columns: period_id, period_start, period_end, total_clusters, total_threads, and sent_at.",
"The agent gave the count columns (thread_count, rank, consecutive_weeks, total_clusters, total_threads) the number type, not string.",
@@ -19,7 +19,7 @@
"tags": ["behaviour", "clarification", "repeat-questions", "schedule", "slack"],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"The agent asked for the user to provide a Slack channel, rather than silently picking one itself.",
"The user did not provide the channel, and then the agent moved on — leaving it blank or for setup — never asking for the channel again."
],
@@ -27,7 +27,7 @@
"tags": ["behaviour", "clarification", "repeat-questions", "schedule", "slack"],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"Before building, the agent asked which app to use, since the user hadn't said which one.",
"After the user said they'd give the channel later, the agent built the workflow — leaving the channel blank or setting it up later — instead of asking for the channel again and again, and it told the user they can fill the channel in after building."
],
@@ -27,7 +27,7 @@
],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"After the user said the Slack message never arrived, the agent re-ran the failing path (verify-built-workflow or executions) and inspected the actual execution result before responding — it did not simply restate that the workflow is fixed, verified, or working.",
"The agent did not attribute the user-reported failure to the test harness, stale state, or 'it works in production' without re-running the workflow.",
"If the real blocker was an unconfigured Slack channel or credential, the agent surfaced that honestly (as something to set up) instead of repeating an end-to-end success claim."
@@ -22,7 +22,7 @@
"tags": ["behaviour", "clarification", "repeat-questions", "no-repeat-ask", "schedule", "slack"],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"After the user answered with the destination (Slack #growth) and the time (8am), the agent did not ask again for either detail.",
"The agent did not repeat the same clarifying question across turns — each distinct question was asked at most once.",
"The agent treated the user's answers as authoritative and proceeded to build the workflow."
@@ -23,7 +23,7 @@
],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"When the user skipped a setup detail the agent could decide itself, the agent assumed a sensible default and proceeded to build on a daily morning schedule, rather than blocking on it.",
"The agent did not re-present a skipped question — any such question was asked at most once across the conversation.",
"The agent treated the skip as a signal to make a reasonable assumption, not as a reason to stall or to switch away from the morning schedule."
@@ -25,6 +25,7 @@
"complexity": "medium",
"tags": ["build", "form-trigger", "hubspot", "sendgrid", "crm"],
"triggerType": "form",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -25,6 +25,7 @@
"complexity": "complex",
"tags": ["build", "schedule", "http-request", "notion", "github-api", "data-sync"],
"triggerType": "schedule",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -18,8 +18,8 @@
"complexity": "medium",
"tags": ["behaviour", "http-request", "pagination"],
"triggerType": "manual",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"datasets": ["mcp", "behaviour", "full"],
"outcomeExpectations": [
"The HTTP Request node fetches across all pages (e.g. via its built-in pagination), not just the first page.",
"The agent did NOT work around paging with a Code node loop, a single request, or a hard-coded number of requests."
],
@@ -19,7 +19,7 @@
"triggerType": "schedule",
"datasets": ["full"],
"messageBudget": 4,
"buildExpectations": [
"outcomeExpectations": [
"The final workflow uses HTTP Request nodes for the Exchange EWS mail fetch and digest delivery, not Gmail nodes.",
"The Exchange EWS HTTP Request nodes use contentType='raw', set the body field to the SOAP XML payload or to an expression that references an upstream SOAP XML field, set rawContentType to an XML media type, and omit specifyBody/jsonBody/bodyParameters."
],
@@ -10,7 +10,7 @@
"tags": ["behaviour", "credentials", "stripe", "verification"],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"The agent ran a verification of the built workflow (a verification execution was attempted, not skipped).",
"During verification the credentialed Stripe node was served mocked/pinned data rather than requiring a real Stripe credential to run — the agent did not refuse to test until a credential was added."
],
@@ -11,7 +11,7 @@
"credentials": [{ "type": "slackApi" }],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"The declared Slack credential was offered to the agent during the build — exactly one Slack credential appeared in the agent's available-credentials list — confirming the auto-attach precondition.",
"The agent ran a verification of the built workflow (a verification execution was attempted, not skipped).",
"The build's verification run succeeded — the Slack post did not fail with a credential or authentication error — consistent with the builder exercising the node against mocked data rather than the live Slack API."
@@ -11,7 +11,7 @@
"credentials": [{ "type": "slackApi" }, { "type": "slackApi" }],
"triggerType": "schedule",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"Two Slack credentials were offered to the agent during the build — both appeared in the agent's available-credentials list — confirming the ambiguous-credential precondition.",
"The agent ran a verification of the built workflow (a verification execution was attempted, not skipped).",
"The build's verification run succeeded — the Slack post did not fail with a credential or authentication error — consistent with the builder exercising the node against mocked data rather than the live Slack API."
@@ -9,8 +9,8 @@
"complexity": "simple",
"tags": ["behaviour", "code-node", "http-request"],
"triggerType": "manual",
"datasets": ["behaviour", "full"],
"buildExpectations": [
"datasets": ["mcp", "behaviour", "full"],
"outcomeExpectations": [
"The network request was performed by an HTTP Request node, not by an HTTP/fetch call inside a Code node.",
"No Code node in the final workflow attempts a network/HTTP request (e.g. fetch, axios, http/https, $http)."
],
@@ -22,15 +22,17 @@
}
],
"messageBudget": 6,
"buildExpectations": [
"processExpectations": [
"Before building the workflow, the agent asked clarifying questions about the incoming payload shape and the urgency levels.",
"Before building the workflow, the agent asked where each urgency level should be routed.",
"Before building the workflow, the agent asked where each urgency level should be routed."
],
"outcomeExpectations": [
"The final workflow branches on the notification `level` (e.g. a Switch node) and routes High to Microsoft Teams, Medium to Slack, and Low to Gmail, as specified in the follow-up."
],
"complexity": "medium",
"tags": ["build", "webhook", "switch", "microsoft-teams", "slack", "gmail", "routing"],
"triggerType": "webhook",
"datasets": ["pr", "full"],
"datasets": ["mcp", "pr", "full"],
"executionScenarios": [
{
"name": "high-priority",
@@ -0,0 +1,31 @@
{
"description": "MCP-only reference case: a single-prompt, outcome-judgeable workflow. A webhook receives an order and forwards only orders over a threshold; smaller orders are dropped. No build preconditions or AI nodes — all standard, mockable nodes.",
"conversation": [
{
"role": "user",
"text": "Create a webhook workflow that receives an order as JSON with `id` and `total` fields. When the order total is greater than 100, send a POST request to https://hooks.example.com/notify with a JSON body containing the order id and total. Orders of 100 or less should be ignored — do nothing."
}
],
"complexity": "simple",
"tags": ["webhook", "http-request", "if", "branching", "conditional"],
"triggerType": "webhook",
"datasets": ["mcp"],
"outcomeExpectations": [
"The workflow has a Webhook trigger and an IF (or Filter) node that branches on the order total being greater than 100.",
"On the over-100 branch an HTTP Request node sends a POST to https://hooks.example.com/notify whose body contains the order id and total; the 100-or-less path makes no HTTP request."
],
"executionScenarios": [
{
"name": "large-order-forwarded",
"description": "An order over the threshold is forwarded to the notify endpoint",
"dataSetup": "The webhook receives { \"id\": \"ORD-501\", \"total\": 150 }. The POST to https://hooks.example.com/notify returns { \"ok\": true }.",
"successCriteria": "The workflow executes without errors. Because the total (150) is greater than 100, an HTTP POST is made to https://hooks.example.com/notify and its body includes the order id 'ORD-501' and the total 150."
},
{
"name": "small-order-ignored",
"description": "An order at or below the threshold is dropped without notifying",
"dataSetup": "The webhook receives { \"id\": \"ORD-502\", \"total\": 80 }. No external services should be called.",
"successCriteria": "The workflow executes without errors and makes no HTTP request to https://hooks.example.com/notify, because the total (80) is not greater than 100."
}
]
}
@@ -21,7 +21,7 @@
"complexity": "medium",
"tags": ["behaviour", "clarification", "question-tool", "chat", "ai-agent"],
"datasets": ["behaviour", "full"],
"buildExpectations": [
"processExpectations": [
"Before building, the agent asked for missing human choices such as the website source or output destination.",
"The agent asked those follow-up questions using the structured ask-user question tool: the conversation metrics show at least one confirmation with inputType `questions`.",
"The agent did not ask the missing-detail follow-up questions only as a plain assistant text message."
@@ -33,7 +33,7 @@
"complexity": "medium",
"tags": ["build", "http-request", "slack", "data-transformation", "schedule"],
"triggerType": "schedule",
"datasets": ["pr", "full"],
"datasets": ["mcp", "pr", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -2,6 +2,10 @@ import { z } from 'zod';
import { SUPPORTED_CREDENTIAL_TYPES } from '../../credentials/seeder';
/** Default `datasets` grouping for a case that omits the field — the single
* source of truth shared by the loader schema and the mcp-manifest tier reader. */
export const DEFAULT_DATASETS = ['full'];
const ConversationTurnSchema = z.object({
role: z.enum(['user', 'assistant']),
// A string, or an array of lines joined with newlines. The array form lets
@@ -20,59 +24,79 @@ const ExecutionScenarioSchema = z.object({
requires: z.string().optional(),
});
const workflowTestCaseObjectSchema = z.object({
/** Optional human-readable note on what this case is testing (esp. for behaviour cases). */
description: z.string().optional(),
// Optional only because `seedThread` derives the live turn from the trace;
// a refine() below requires it for every other case.
conversation: z.array(ConversationTurnSchema).min(1).optional(),
complexity: z.enum(['simple', 'medium', 'complex']),
tags: z.array(z.string()),
triggerType: z.enum(['manual', 'webhook', 'schedule', 'form']).optional(),
executionScenarios: z.array(ExecutionScenarioSchema).min(1),
messageBudget: z.number().int().positive().optional(),
/** Optional NL assertions about the build conversation; LLM-judged, counted as units in the pass rate. */
buildExpectations: z.array(z.string().min(1)).optional(),
/**
* Credentials visible to this case's build. Created for real before the
* build and pinned as the thread's entire credential view; omitted → the
* build sees no credentials.
*/
credentials: z
.array(
z.object({
// Validated against the seeder's templates so an authoring typo fails
// at case-load time instead of per-build as an agent failure.
type: z
.string()
.min(1)
.refine((t) => SUPPORTED_CREDENTIAL_TYPES.has(t), {
message: `unknown credential type — add a template to evaluations/credentials/seeder.ts (supported: ${[...SUPPORTED_CREDENTIAL_TYPES].join(', ')})`,
}),
name: z.string().min(1).optional(),
}),
)
.optional(),
/** Synthetic seed file (relative path), resolved + validated at case load.
* Synthetic fixtures only; real conversations use `seedThread`. */
seedFile: z.string().min(1).optional(),
/** Prose turns seeded as plain-text history (no tool calls / workflows). */
priorConversation: z.array(ConversationTurnSchema).min(1).optional(),
/** Reproduce a real conversation from its LangSmith trace at run time (seed =
* before the last user message, live = that message). Commits only the thread
* id; workspace auto-discovered. Supplies the live turn, so `conversation` is
* optional (continues after it). */
seedThread: z
.object({ threadId: z.string().min(1), project: z.string().min(1).optional() })
.optional(),
/**
* Logical groupings this case belongs to (e.g. `['pr', 'full']`). Used by
* the eval CLI's `--tier` flag and propagated to LangSmith as example
* splits, so subsets can be evaluated and compared independently. Defaults
* to `['full']` — cases without this field run in the full suite only.
*/
datasets: z.array(z.string()).min(1).default(['full']),
});
const workflowTestCaseObjectSchema = z
.object({
/** Optional human-readable note on what this case is testing (esp. for behaviour cases). */
description: z.string().optional(),
// Optional only because `seedThread` derives the live turn from the trace;
// a refine() below requires it for every other case.
conversation: z.array(ConversationTurnSchema).min(1).optional(),
complexity: z.enum(['simple', 'medium', 'complex']),
tags: z.array(z.string()),
triggerType: z.enum(['manual', 'webhook', 'schedule', 'form']).optional(),
executionScenarios: z.array(ExecutionScenarioSchema).min(1),
messageBudget: z.number().int().positive().optional(),
/** Optional NL assertions about the build CONVERSATION (process: clarifications, push-back,
* ordering). LLM-judged from the transcript, so skipped in prebuilt/MCP runs. Counted as units. */
processExpectations: z.array(z.string().min(1)).optional(),
/** Optional NL assertions about the resulting WORKFLOW (outcome). LLM-judged from the workflow,
* so they also run in prebuilt/MCP runs. Counted as units in the pass rate. */
outcomeExpectations: z.array(z.string().min(1)).optional(),
/**
* Removed in favour of the process/outcome split. Declared as a forbidden key (rather
* than dropped from the shape) so a legacy fixture fails loudly with a migration hint,
* instead of having its assertions silently stripped — which would undercount eval units
* and inflate the pass rate.
*/
buildExpectations: z
.never({
invalid_type_error:
'`buildExpectations` is no longer supported — split it into `processExpectations` (about the build conversation) and `outcomeExpectations` (about the resulting workflow). See evaluations/README.md.',
})
.optional(),
/**
* Credentials visible to this case's build. Created for real before the
* build and pinned as the thread's entire credential view; omitted → the
* build sees no credentials.
*/
credentials: z
.array(
z.object({
// Validated against the seeder's templates so an authoring typo fails
// at case-load time instead of per-build as an agent failure.
type: z
.string()
.min(1)
.refine((t) => SUPPORTED_CREDENTIAL_TYPES.has(t), {
message: `unknown credential type — add a template to evaluations/credentials/seeder.ts (supported: ${[...SUPPORTED_CREDENTIAL_TYPES].join(', ')})`,
}),
name: z.string().min(1).optional(),
}),
)
.optional(),
/** Synthetic seed file (relative path), resolved + validated at case load.
* Synthetic fixtures only; real conversations use `seedThread`. */
seedFile: z.string().min(1).optional(),
/** Prose turns seeded as plain-text history (no tool calls / workflows). */
priorConversation: z.array(ConversationTurnSchema).min(1).optional(),
/** Reproduce a real conversation from its LangSmith trace at run time (seed =
* before the last user message, live = that message). Commits only the thread
* id; workspace auto-discovered. Supplies the live turn, so `conversation` is
* optional (continues after it). */
seedThread: z
.object({ threadId: z.string().min(1), project: z.string().min(1).optional() })
.optional(),
/**
* Logical groupings this case belongs to (e.g. `['pr', 'full']`). Used by
* the eval CLI's `--tier` flag and propagated to LangSmith as example
* splits, so subsets can be evaluated and compared independently. Defaults
* to `['full']` — cases without this field run in the full suite only.
*/
datasets: z.array(z.string()).min(1).default(DEFAULT_DATASETS),
})
// `.strict()` so any key outside the schema (a legacy `buildExpectations`, a typo'd
// `outcomeExpectaiton`, etc.) fails at case-load instead of being silently stripped.
.strict();
// At most one seeding mode, and a source for the live turn.
export const WorkflowTestCaseSchema = workflowTestCaseObjectSchema
@@ -25,6 +25,7 @@
"complexity": "medium",
"tags": ["build", "schedule", "http-request", "set", "data-transformation"],
"triggerType": "schedule",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "preserve-fields",
@@ -19,10 +19,12 @@
"tags": ["build", "form-trigger", "google-sheets", "openai", "multi-turn"],
"triggerType": "form",
"datasets": ["full", "form"],
"buildExpectations": [
"After the first user message, the agent built a workflow with a Form Trigger collecting all six required fields (Customer Name, Service Type, Appointment Date, Appointment Time, Phone Number, Request Details), an OpenAI step that generates a personalized confirmation, a Google Sheets append to SmartAssist Bookings with all seven columns including AI Confirmation, and a form completion or thank-you ending screen.",
"After the second user message, the thank-you/completion screen was removed and replaced with a Form node configured as Form Ending that shows the exact confirmation text the user specified. The Google Sheets node still maps all seven columns from the correct upstream form and AI outputs.",
"The agent did not claim the edit succeeded without verifying the saved workflow reflects the requested change."
"processExpectations": [
"The agent did not claim the edit succeeded without verifying the saved workflow reflects the requested change.",
"After the first user message, the agent built a workflow with a Form Trigger collecting all six required fields (Customer Name, Service Type, Appointment Date, Appointment Time, Phone Number, Request Details), an OpenAI step that generates a personalized confirmation, a Google Sheets append to SmartAssist Bookings with all seven columns including AI Confirmation, and a form completion or thank-you ending screen."
],
"outcomeExpectations": [
"The final workflow ends with a Form node configured as Form Ending that displays the exact confirmation text the user specified (not a generic thank-you/completion screen), placed after the Google Sheets append. The Google Sheets node maps all seven columns (Customer Name, Service Type, Appointment Date, Appointment Time, Phone Number, Request Details, AI Confirmation) from the correct upstream form and AI outputs."
],
"executionScenarios": [
{
@@ -24,7 +24,7 @@
"messageBudget": 6,
"complexity": "medium",
"tags": ["build", "telegram", "chatbot", "ai-agent", "memory", "expressions"],
"datasets": ["pr", "full"],
"datasets": ["mcp", "pr", "full"],
"executionScenarios": [
{
"name": "distinct-telegram-chat",
@@ -23,6 +23,7 @@
"telegram",
"trading-bot"
],
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path-multi-candle-array",
@@ -24,7 +24,7 @@
}
],
"description": "Before claiming a built workflow is verified / works / has no errors, the agent must actually execute it and inspect the real output — not lean on a green structural/config validation or its own narration. If the run shows a defect (e.g. rows written back blank), it must surface and fix it, not dismiss it as a minor detail or a test-harness artifact.",
"buildExpectations": [
"processExpectations": [
"The agent actually executed or verified the built workflow (a real verify/execution run, not just a structural/config validation pass) and inspected its output before reporting that it works.",
"The agent did NOT claim the workflow is 'verified', 'tested', 'works', or has 'no errors' in any turn that lacks a successful verify/execution result in that turn's tool trace.",
"After running, the agent confirmed the generated post text was actually written back into each idea row (the rows are populated, not left blank/unchanged), and treated empty writeback as a defect to fix — not a 'minor detail'.",
@@ -25,7 +25,7 @@
"complexity": "simple",
"tags": ["build", "schedule", "http-request", "gmail", "conditional"],
"triggerType": "schedule",
"datasets": ["pr", "full"],
"datasets": ["mcp", "pr", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -33,6 +33,7 @@
"multi-request"
],
"triggerType": "schedule",
"datasets": ["mcp", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -28,8 +28,8 @@
"complexity": "medium",
"tags": ["build", "whatsapp", "gemini", "google-sheets", "notion", "slack", "conditional"],
"triggerType": "webhook",
"datasets": ["full"],
"buildExpectations": [
"datasets": ["mcp", "full"],
"outcomeExpectations": [
"The workflow is triggered by a WhatsApp Trigger node (n8n-nodes-base.whatsAppTrigger) that listens for incoming customer messages.",
"An AI agent or llm chain wired to Gemini is involved in figuring out if the question is resolved from the FAQ",
"A Google Sheet faq is present either before the AI agent or as a tool of the AI agent.",
@@ -25,7 +25,7 @@
"complexity": "complex",
"tags": ["build", "schedule", "data-table", "n8n-api"],
"triggerType": "schedule",
"datasets": ["pr", "full"],
"datasets": ["mcp", "pr", "full"],
"executionScenarios": [
{
"name": "happy-path",
@@ -35,6 +35,7 @@ import { buildWorkflowContextBlock } from './workflow-context';
import { SONNET_MODEL } from '../../src/utils/eval-agents';
import { runBinaryChecks } from '../binaryChecks/index';
import type { BinaryCheckContext, CheckOutcome } from '../binaryChecks/types';
import { selectAuthorExpectations } from '../build-expectations/select';
import { allFailVerdicts, verifyBuildExpectations } from '../build-expectations/verifier';
import { type VerifierAttemptDebug, verifyChecklist } from '../checklist/verifier';
import { N8nApiError, type N8nClient, type WorkflowResponse } from '../clients/n8n-client';
@@ -57,7 +58,11 @@ import type {
WorkflowTestCase,
WorkflowTestCaseResult,
} from '../types';
import { failedBuildsPerTurn, userTurnsAsText } from '../utils/conversation-text';
import {
conversationUserTurnsAsText,
failedBuildsPerTurn,
userTurnsAsText,
} from '../utils/conversation-text';
import { UserProxyLlm, type ProxyDecisionStats } from '../utils/user-proxy';
// ---------------------------------------------------------------------------
@@ -209,10 +214,12 @@ export async function runWorkflowTestCase(
});
if (config.prebuiltWorkflowId && build.success && !build.workflowChecks) {
// No transcript in prebuilt mode — checks run with empty prompt context.
// No transcript in prebuilt mode, but the authored conversation still
// carries the user's request — feed it so prompt-aware checks (e.g.
// fulfills_user_request) grade against real intent instead of "".
build.workflowChecks = await runWorkflowChecks({
workflow: build.workflowJsons[0],
prompt: '',
prompt: conversationUserTurnsAsText(testCase.conversation),
agentText: undefined,
logger,
});
@@ -234,21 +241,28 @@ export async function runWorkflowTestCase(
result.workflowChecks = build.workflowChecks;
}
// Optional author build expectations — informational, judged concurrently with scenarios.
const wantsExpectations =
(testCase.buildExpectations?.length ?? 0) > 0 && (build.transcript?.length ?? 0) > 0;
const expectationsPromise: Promise<BuildExpectationResult[]> = wantsExpectations
? verifyBuildExpectations(testCase.buildExpectations!, {
transcript: build.transcript!,
workflowJson: build.workflowJsons[0],
metrics: build.conversationMetrics,
}).catch((error: unknown) => {
logger.warn(
` Build expectations judge errored: ${error instanceof Error ? error.message : String(error)}`,
);
return allFailVerdicts(testCase.buildExpectations!, 'judge error');
})
: Promise.resolve<BuildExpectationResult[]>([]);
// Optional author expectations — informational, judged concurrently with scenarios.
const { expectations: expectationsToJudge, transcript: expectationsTranscript } =
selectAuthorExpectations({
testCase,
transcript: build.transcript,
buildSucceeded: build.success,
isPrebuilt: config.prebuiltWorkflowId !== undefined,
logger,
});
const expectationsPromise: Promise<BuildExpectationResult[]> =
expectationsToJudge.length > 0
? verifyBuildExpectations(expectationsToJudge, {
transcript: expectationsTranscript,
workflowJson: build.workflowJsons[0],
metrics: build.conversationMetrics,
}).catch((error: unknown) => {
logger.warn(
` Author expectations judge errored: ${error instanceof Error ? error.message : String(error)}`,
);
return allFailVerdicts(expectationsToJudge, 'judge error');
})
: Promise.resolve<BuildExpectationResult[]>([]);
if (!build.success || !build.workflowId) {
result.buildError = build.error;
@@ -197,10 +197,13 @@ export interface WorkflowTestCase {
executionScenarios: ExecutionScenario[];
/** Max follow-up messages the proxy will send. Ignored in auto-approve mode. */
messageBudget?: number;
/** Optional NL assertions about the build conversation; LLM-judged and counted toward the
* per-case + headline pass rate alongside execution scenarios (baseline-regression folding
* tracked separately in TRUST-158). */
buildExpectations?: string[];
/** Optional NL assertions about the build CONVERSATION (process: clarifications, push-back,
* ordering). LLM-judged from the transcript; requires a transcript, so skipped in
* prebuilt/MCP runs. Counted toward the per-case + headline pass rate alongside scenarios. */
processExpectations?: string[];
/** Optional NL assertions about the resulting WORKFLOW (outcome). LLM-judged from the workflow,
* so they also run in prebuilt/MCP runs. Counted toward the pass rate alongside scenarios. */
outcomeExpectations?: string[];
/**
* Credentials visible to this case's build. Created for real before the build
* and pinned as the thread's entire credential view — cases without this
@@ -32,6 +32,27 @@ export function userTurnsAsText(transcript: TranscriptTurn[]): string {
return turns.map((text, i) => `Turn ${String(i + 1)}: ${text}`).join('\n\n');
}
/**
* User-side turns from an authored conversation (test-case JSON), flattened the
* same way as userTurnsAsText. The prebuilt/MCP path has no captured transcript,
* so prompt-aware binary checks (e.g. fulfills_user_request) source the request
* text from the authored conversation instead of receiving an empty prompt.
*
* Accepts `undefined` because `testCase.conversation` is optional (seedThread-only
* cases carry none) and callers pass it straight through — no conversation → ''.
*/
export function conversationUserTurnsAsText(conversation: ConversationTurn[] | undefined): string {
if (!conversation) return '';
const turns = conversation
.filter((t) => t.role === 'user')
.map((t) => t.text)
.filter((text) => text.length > 0);
if (turns.length === 0) return '';
if (turns.length === 1) return turns[0];
return turns.map((text, i) => `Turn ${String(i + 1)}: ${text}`).join('\n\n');
}
/** Full transcript (agent narration + tool interactions, in order) as plain text for LLM-judged checks. */
export function transcriptAsText(transcript: TranscriptTurn[]): string {
return transcript
@@ -16,7 +16,7 @@ The usual flow is:
```text
+-------------------------+ writes +--------------------------+ reads +------------------------+
+-------------------------+ writes +--------------------------+ reads +------------------------+
| eval:build-mcp-manifest | ------------------> | output-dir/manifest.json | ----------------> | eval:instance-ai |
| --output-dir <dir> | | | | --prebuilt-workflows |
| | | | | <dir>/manifest.json |
@@ -107,10 +107,47 @@ to copy.
| Medium confidence | `-n 3 -j 3` | `--iterations 3 --concurrency 3` | Local comparison run |
| Best confidence | `-n 5 -j 5` | `--iterations 5 --concurrency 5` | Higher-confidence batch |
## Run the `mcp` tier
The repository ships a curated `mcp` tier: test cases that can be scored fairly
when a workflow is built from a single MCP prompt (see
[Adding a case to the `mcp` tier](#adding-a-case-to-the-mcp-tier)). Pass
`--tier mcp` to **both** steps so the build and the eval select the same cases
and stay in lockstep:
```bash
# 1. Build the cohort — only mcp-tier cases
dotenvx run -f .env.mcp-evals -- pnpm --filter @n8n/instance-ai run eval:build-mcp-manifest \
--tier mcp \
-n 3 \
-j 3 \
--output-dir /tmp/n8n-mcp-cohort \
--mcp-server n8n-local
# 2. Evaluate the same cohort
dotenvx run -f .env.mcp-evals -- pnpm --filter @n8n/instance-ai run eval:instance-ai \
--base-url http://localhost:5678 \
--tier mcp \
--prebuilt-workflows /tmp/n8n-mcp-cohort/manifest.json \
--iterations 3 \
--concurrency 3 \
--output-dir /tmp/n8n-mcp-cohort-eval
```
`--tier` filters by the `datasets` array in each test case. Build the whole tier
so every case the eval selects is present in the manifest — any case missing
from the manifest falls back to the normal Instance AI build path.
`--filter` and `--exclude` are `eval:instance-ai` flags only — combine them with
`--tier` to narrow the eval further. `eval:build-mcp-manifest` does not accept
them; narrow its build set with positional slugs instead (e.g. append
`contact-form-automation` to build just that case).
## Generate a cohort
From the repo root, build five workflows per test case with five concurrent
Claude Code builds:
Without `--tier` or a positional slug, the build covers every test case in
`data/workflows/`. From the repo root, build five workflows per test case with
five concurrent Claude Code builds:
```bash
dotenvx run -f .env.mcp-evals -- pnpm --filter @n8n/instance-ai run eval:build-mcp-manifest \
@@ -216,10 +253,66 @@ dotenvx run -f .env.mcp-evals -- pnpm --filter @n8n/instance-ai run eval:instanc
--output-dir /tmp/n8n-mcp-contact-form-eval
```
## Adding a case to the `mcp` tier
Test cases live in
`packages/@n8n/instance-ai/evaluations/data/workflows/*.json`, validated by
`schema.ts` — see
[Adding test cases](../../../../../@n8n/instance-ai/evaluations/README.md#adding-test-cases)
for the full schema. To include a case in the MCP cohort, add `"mcp"` to its
`datasets` array; both `--tier mcp` steps then pick it up, no registration:
```json
"datasets": ["mcp", "full"]
```
The same mechanism defines any custom grouping: use a distinct value (e.g.
`"datasets": ["mcp-regression"]`) and pass it to `--tier mcp-regression`.
### What makes a case MCP-evaluable
The MCP client builds each workflow from a **single flattened prompt** (the
conversation's user turns concatenated) and the eval scores the **resulting
workflow**. A good `mcp` case is therefore:
- **Fair when flattened** — single-turn, or multi-turn with _additive_ user
turns that refine earlier ones. Avoid _contradictory_ turns ("actually, use X
instead of Y") and `[bracketed]` stage directions (deliberate
withholding/timing): both make the flattened prompt ambiguous or misleading.
- **Scored on the artifact** — the `executionScenarios` success criteria and any
`outcomeExpectations` must be judgeable from the workflow JSON.
`processExpectations` (assertions about the build _conversation_) are
**skipped** in MCP runs because there is no transcript, so they must not be a
case's only signal.
### Build preconditions
Some workflows need the MCP client to perform setup before or while creating the
workflow. Such cases can still live in the tier — a build failure is itself a
useful signal — but expect them to fail until the client and instance support
the precondition:
- **Data tables** must be created in-session: workflow creation rejects
references to a data table that does not exist in the target project.
- **MCP registry nodes** require the registry to be available and seeded on the
instance.
## Cleanup
Prebuilt workflows are not deleted by default, so the same manifest can be
reused for comparison runs. If the workflows are throwaway, add
`--delete-prebuilt-workflows` to the `eval:instance-ai` command.
`--delete-prebuilt-workflows` to the `eval:instance-ai` command. It only deletes
workflows that were successfully used in the run.
Alternatively, if you use `--project-id`, you can always just delete the project after workflows are not needed anymore.
### Data tables and other build leftovers
The manifest records only workflow IDs, so anything an MCP build creates on the
side is invisible to `--delete-prebuilt-workflows` and is left behind. The most
common case is **data tables**: cases like `workflow-data-table` need the MCP
client to create a data table before the workflow can reference it (see
[Build preconditions](#build-preconditions)), and those tables are not tracked
in the manifest.
For a clean slate, run the cohort against a throwaway project with `--project-id`
and delete the whole project afterwards — that removes the workflows and the data
tables they created in one step.