feat(ai-builder): Support dataset context and conversation history in evaluations (no-changelog) (#27618)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Eugene
2026-03-30 10:14:01 +02:00
committed by GitHub
parent bd0bc0cbd6
commit 6314cd4842
7 changed files with 314 additions and 32 deletions
@@ -176,6 +176,7 @@ describe('Runner - LangSmith Mode', () => {
// Collectors are passed explicitly from the traceable wrapper to capture token usage and subgraph metrics
expect(generateWorkflow).toHaveBeenCalledWith(
'Create a workflow',
undefined,
expect.objectContaining({
tokenUsage: expect.any(Function),
subgraphMetrics: expect.any(Function),
@@ -9,6 +9,8 @@ import type { INodeTypeDescription } from 'n8n-workflow';
import pLimit from 'p-limit';
import { CodeWorkflowBuilder } from '@/code-builder';
import type { HistoryContext } from '@/code-builder';
import type { ConversationEntry } from '@/code-builder/utils/code-builder-session';
import type { CoordinationLogEntry } from '@/types/coordination';
import type { StreamChunk, WorkflowUpdateChunk } from '@/types/streaming';
import type { SimpleWorkflow } from '@/types/workflow';
@@ -46,6 +48,7 @@ import {
extractSubgraphMetrics,
getChatPayload,
} from '../harness/evaluation-helpers';
import type { DatasetInputContext } from '../harness/harness-types';
import { createLogger } from '../harness/logger';
import type { GenerationCollectors, SubgraphMetricsCollector } from '../harness/runner';
import { TokenUsageTrackingHandler } from '../harness/token-tracking-handler';
@@ -132,8 +135,16 @@ function createWorkflowGenerator(
parsedNodeTypes: INodeTypeDescription[],
llms: ResolvedStageLLMs,
featureFlags?: BuilderFeatureFlags,
): (prompt: string, collectors?: GenerationCollectors) => Promise<SimpleWorkflow> {
return async (prompt: string, collectors?: GenerationCollectors): Promise<SimpleWorkflow> => {
): (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
) => Promise<SimpleWorkflow> {
return async (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
): Promise<SimpleWorkflow> => {
const runId = generateRunId();
const agent = createAgent({
@@ -153,10 +164,13 @@ function createWorkflowGenerator(
message: prompt,
workflowId: runId,
featureFlags,
workflowContext: datasetInputContext?.workflowContext,
mode: datasetInputContext?.mode,
}),
EVAL_USERS.LANGSMITH,
undefined, // abortSignal
tokenTracker ? [tokenTracker] : undefined, // externalCallbacks
datasetInputContext?.historicalMessages,
),
);
@@ -230,6 +244,58 @@ function createEvaluators(params: {
return evaluators;
}
/**
* Convert raw LangChain messages from dataset into HistoryContext for the code builder.
* Pairs up human/AI messages as conversation entries.
*/
function buildHistoryContextFromMessages(messages: unknown[]): HistoryContext | undefined {
if (messages.length === 0) return undefined;
const entries: ConversationEntry[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!isUnknownRecord(msg)) continue;
const msgId = msg.id;
const isHuman = Array.isArray(msgId) && msgId.includes('HumanMessage');
if (!isHuman) continue;
const kwargs = msg.kwargs;
if (!isUnknownRecord(kwargs) || typeof kwargs.content !== 'string') continue;
const userContent = kwargs.content;
// Look for a following AI message to pair with
const nextMsg = i + 1 < messages.length ? messages[i + 1] : undefined;
const nextIsAI =
isUnknownRecord(nextMsg) && Array.isArray(nextMsg.id) && nextMsg.id.includes('AIMessage');
if (nextIsAI && isUnknownRecord(nextMsg)) {
const nextKwargs = nextMsg.kwargs;
const aiContent =
isUnknownRecord(nextKwargs) && typeof nextKwargs.content === 'string'
? nextKwargs.content
: '';
entries.push({
type: 'assistant-exchange',
userQuery: userContent,
assistantSummary: aiContent,
});
i++; // Skip the AI message
} else {
entries.push({ type: 'build-request', message: userContent });
}
}
return entries.length > 0 ? { conversationEntries: entries } : undefined;
}
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Create a CodeWorkflowBuilder generator function.
* Uses the CodeWorkflowBuilder which coordinates planning and coding agents to generate
@@ -245,9 +311,17 @@ function createCodeWorkflowBuilderGenerator(
llms: ResolvedStageLLMs,
timeoutMs?: number,
nodeDefinitionDirs?: string[],
): (prompt: string, collectors?: GenerationCollectors) => Promise<GenerationResult> {
): (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
) => Promise<GenerationResult> {
// Subgraph metrics are not applicable since CodeWorkflowBuilder doesn't use coordination logs.
return async (prompt: string, collectors?: GenerationCollectors): Promise<GenerationResult> => {
return async (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
): Promise<GenerationResult> => {
const runId = generateRunId();
// Accumulate token usage across all LLM calls
@@ -271,8 +345,15 @@ function createCodeWorkflowBuilderGenerator(
message: prompt,
workflowId: runId,
featureFlags: { codeBuilder: true },
workflowContext: datasetInputContext?.workflowContext,
mode: datasetInputContext?.mode,
});
// Build history context from dataset messages if available
const historyContext = datasetInputContext?.historicalMessages
? buildHistoryContextFromMessages(datasetInputContext.historicalMessages)
: undefined;
let workflow: SimpleWorkflow | null = null;
let generatedCode: string | undefined;
@@ -293,6 +374,7 @@ function createCodeWorkflowBuilderGenerator(
payload,
EVAL_USERS.LANGSMITH,
abortController.signal,
historyContext,
)) {
for (const message of output.messages) {
if (isWorkflowUpdateChunk(message)) {
@@ -484,6 +566,7 @@ export async function runV2Evaluation(): Promise<void> {
const logger = createLogger(args.verbose);
const lifecycle = createConsoleLifecycle({ verbose: args.verbose, logger });
const stageModels = argsToStageModels(args);
const env = await setupTestEnvironment(stageModels, logger);
// Validate LangSmith client early if langsmith backend is requested
@@ -67,18 +67,35 @@ export interface GetChatPayloadOptions {
message: string;
workflowId: string;
featureFlags?: BuilderFeatureFlags;
/** Full workflowContext from dataset (overrides default empty context) */
workflowContext?: ChatPayload['workflowContext'];
/** Builder mode from dataset */
mode?: 'build' | 'plan';
}
export function getChatPayload(options: GetChatPayloadOptions): ChatPayload {
const { evalType, message, workflowId, featureFlags } = options;
const { evalType, message, workflowId, featureFlags, workflowContext, mode } = options;
// Always use the eval runId as currentWorkflow.id so getState() can find the thread.
// When workflowContext is provided from a dataset, override its currentWorkflow.id.
const resolvedContext = workflowContext
? {
...workflowContext,
currentWorkflow: {
nodes: [],
connections: {},
...((workflowContext.currentWorkflow as Record<string, unknown>) ?? {}),
id: workflowId,
},
}
: { currentWorkflow: { id: workflowId, nodes: [], connections: {} } };
return {
id: `${evalType}-${uuid()}`,
featureFlags: featureFlags ?? DEFAULTS.FEATURE_FLAGS,
message,
workflowContext: {
currentWorkflow: { id: workflowId, nodes: [], connections: {} },
},
workflowContext: resolvedContext,
...(mode ? { mode } : {}),
};
}
@@ -1,3 +1,4 @@
import type { BaseMessage } from '@langchain/core/messages';
import type { Client as LangsmithClient } from 'langsmith/client';
import type { IPinData } from 'n8n-workflow';
import type pLimit from 'p-limit';
@@ -6,9 +7,27 @@ import type { EvalLogger } from './logger';
import type { GenerationCollectors } from './runner';
import type { IntrospectionEvent } from '../../src/tools/introspect.tool.js';
import type { SimpleWorkflow } from '../../src/types/workflow';
import type { ChatPayload } from '../../src/workflow-builder-agent';
export type LlmCallLimiter = ReturnType<typeof pLimit>;
/**
* Full dataset input context from a LangSmith example.
* Contains all the context fields the agent needs for realistic generation.
*/
export interface DatasetInputContext {
/** The complete workflowContext from the dataset (executionSchema, executionData, etc.) */
workflowContext?: ChatPayload['workflowContext'];
/** The existing workflow JSON before this turn */
existingWorkflow?: SimpleWorkflow;
/** Historical messages from prior conversation turns (deserialized BaseMessage instances) */
historicalMessages?: BaseMessage[];
/** Builder mode from dataset */
mode?: 'build' | 'plan';
/** Feature flags from dataset metadata */
featureFlags?: ChatPayload['featureFlags'];
}
/**
* Shared context passed to all evaluators.
*
@@ -43,6 +62,8 @@ export interface EvaluationContext {
pinData?: IPinData;
/** Per-example annotations (e.g., code_necessary) from CSV or LangSmith dataset */
annotations?: Record<string, unknown>;
/** Full dataset input context for trace-based evaluation */
datasetInputContext?: DatasetInputContext;
}
/** Context attached to an individual test case (prompt is provided separately). */
@@ -123,6 +144,7 @@ export interface RunConfigBase {
/** Function to generate workflow from prompt. May return GenerationResult with source code. Optional collectors receive metrics. */
generateWorkflow: (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
) => Promise<SimpleWorkflow | GenerationResult>;
/** Evaluators to run on each generated workflow */
@@ -248,7 +270,7 @@ export interface SubgraphExampleOutput {
response?: string;
/** The workflow produced by the subgraph (for builder/configurator) */
workflow?: SimpleWorkflow;
};
}
/**
* Result from workflow generation that may include source code.
@@ -1,4 +1,4 @@
import type { BaseMessage } from '@langchain/core/messages';
import { AIMessage, HumanMessage, type BaseMessage } from '@langchain/core/messages';
import { evaluate } from 'langsmith/evaluation';
import type { Run, Example } from 'langsmith/schemas';
import { traceable } from 'langsmith/traceable';
@@ -9,6 +9,7 @@ import { runWithOptionalLimiter, withTimeout } from './evaluation-helpers';
import { toLangsmithEvaluationResult } from './feedback';
import {
isGenerationResult,
type DatasetInputContext,
type Evaluator,
type TestCase,
type EvaluationContext,
@@ -34,6 +35,7 @@ import {
} from './score-calculator';
import type { IntrospectionEvent } from '../../src/tools/introspect.tool.js';
import type { SimpleWorkflow } from '../../src/types/workflow';
import type { ChatPayload } from '../../src/workflow-builder-agent';
import { extractMessageContent } from '../langsmith/types';
const DEFAULT_PASS_THRESHOLD = 0.7;
@@ -201,9 +203,17 @@ function buildContext(args: {
referenceWorkflows?: SimpleWorkflow[];
generatedCode?: string;
pinData?: IPinData;
datasetInputContext?: DatasetInputContext;
}): EvaluationContext {
const { prompt, globalContext, testCaseContext, referenceWorkflows, generatedCode, pinData } =
args;
const {
prompt,
globalContext,
testCaseContext,
referenceWorkflows,
generatedCode,
pinData,
datasetInputContext,
} = args;
return {
prompt,
@@ -212,6 +222,7 @@ function buildContext(args: {
...(referenceWorkflows?.length ? { referenceWorkflows } : {}),
...(generatedCode ? { generatedCode } : {}),
...(pinData ? { pinData } : {}),
...(datasetInputContext ? { datasetInputContext } : {}),
};
}
@@ -543,6 +554,7 @@ async function runLocalExampleSuccess(args: {
testCase: TestCase;
generateWorkflow: (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
) => Promise<SimpleWorkflow | GenerationResult>;
evaluators: Array<Evaluator<EvaluationContext>>;
@@ -571,7 +583,7 @@ async function runLocalExampleSuccess(args: {
const genResult = await runWithOptionalLimiter(async () => {
return await withTimeout({
promise: generateWorkflow(testCase.prompt, collectors),
promise: generateWorkflow(testCase.prompt, testCase.context?.datasetInputContext, collectors),
timeoutMs,
label: 'workflow_generation',
});
@@ -604,6 +616,7 @@ async function runLocalExampleSuccess(args: {
referenceWorkflows: testCase.referenceWorkflows,
generatedCode,
pinData,
datasetInputContext: testCase.context?.datasetInputContext,
});
// Run evaluators in parallel
@@ -641,6 +654,7 @@ async function runLocalExample(args: {
testCase: TestCase;
generateWorkflow: (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
) => Promise<SimpleWorkflow | GenerationResult>;
evaluators: Array<Evaluator<EvaluationContext>>;
@@ -720,6 +734,7 @@ async function runLocalDataset(params: {
testCases: TestCase[];
generateWorkflow: (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
) => Promise<SimpleWorkflow | GenerationResult>;
evaluators: Array<Evaluator<EvaluationContext>>;
@@ -954,6 +969,12 @@ interface LangsmithDatasetInput {
prompt?: string;
messages?: BaseMessage[];
evals?: Record<string, unknown>;
workflowJSON?: unknown;
workflowContext?: unknown;
workflowOperations?: unknown[];
mode?: string;
/** Injected by enrichExamplesWithHistory - historical messages from outputs */
_historicalMessages?: unknown[];
[key: string]: unknown;
}
@@ -975,6 +996,116 @@ function extractPrompt(inputs: LangsmithDatasetInput): string {
throw new Error('No prompt found in inputs - expected "prompt" string or "messages" array');
}
/**
* Pre-process LangSmith examples to extract conversation history from outputs
* and inject it into inputs for the target function.
*
* The dataset format has:
* - inputs.messages[0]: The latest user turn
* - outputs.messages: The FULL conversation (all prior turns + latest + AI response)
*
* We find the latest turn in outputs and extract everything before it as historical.
*/
function enrichExamplesWithHistory(examples: Example[]): Example[] {
return examples.map((example) => {
const outputMessages = (example.outputs as Record<string, unknown> | undefined)?.messages;
if (!Array.isArray(outputMessages) || outputMessages.length <= 1) {
return example; // No history to extract
}
const inputMessages = (example.inputs as Record<string, unknown> | undefined)?.messages;
if (!Array.isArray(inputMessages) || inputMessages.length === 0) {
return example;
}
// Get the content of the latest turn from inputs
const inputMsg = inputMessages[0] as Record<string, unknown> | undefined;
const inputContent = (inputMsg?.kwargs as Record<string, unknown> | undefined)?.content;
if (typeof inputContent !== 'string') return example;
// Find the index of the latest turn in output messages by matching content
const latestTurnIndex = outputMessages.findIndex((msg: unknown) => {
if (!isUnknownRecord(msg)) return false;
const kwargs = msg.kwargs;
if (!isUnknownRecord(kwargs)) return false;
return kwargs.content === inputContent;
});
if (latestTurnIndex <= 0) return example; // No history before the latest turn
// Extract all messages before the latest turn as historical
const historicalMessages = outputMessages.slice(0, latestTurnIndex);
return {
...example,
inputs: {
...example.inputs,
_historicalMessages: historicalMessages,
},
};
});
}
/**
* Deserialize raw LangChain `lc` serialization format messages into BaseMessage instances.
* Dataset messages use the format: {id, lc: 1, type: "constructor", kwargs: {content, ...}}
* with the class ID in `id` array (e.g. ["langchain_core", "messages", "HumanMessage"]).
*
* Only keeps HumanMessage and AIMessage — tool calls/results are stripped
* to simplify the context passed to the code builder.
*/
function deserializeLcMessages(rawMessages: unknown[]): BaseMessage[] {
return rawMessages
.filter((msg): msg is Record<string, unknown> => {
if (!isUnknownRecord(msg)) return false;
const id = Array.isArray(msg.id) ? (msg.id as unknown[]) : [];
const last: unknown = id[id.length - 1];
return last === 'HumanMessage' || last === 'AIMessage';
})
.map((msg) => {
const kwargs = isUnknownRecord(msg.kwargs) ? msg.kwargs : {};
const content = typeof kwargs.content === 'string' ? kwargs.content : '';
const id = Array.isArray(msg.id) ? (msg.id as unknown[]) : [];
return id[id.length - 1] === 'HumanMessage'
? new HumanMessage(content)
: new AIMessage(content);
});
}
/**
* Extract DatasetInputContext from LangSmith dataset inputs.
* Captures the full agent context (workflowContext, existing workflow, mode)
* needed to replay the generation realistically.
*/
function extractDatasetInputContext(
inputs: LangsmithDatasetInput,
): DatasetInputContext | undefined {
const hasContext =
inputs.workflowContext ?? inputs.workflowJSON ?? inputs.mode ?? inputs._historicalMessages;
if (!hasContext) return undefined;
const context: DatasetInputContext = {};
if (isUnknownRecord(inputs.workflowContext)) {
context.workflowContext = inputs.workflowContext as ChatPayload['workflowContext'];
}
if (isSimpleWorkflow(inputs.workflowJSON)) {
context.existingWorkflow = inputs.workflowJSON;
}
if (inputs.mode === 'build' || inputs.mode === 'plan') {
context.mode = inputs.mode;
}
if (Array.isArray(inputs._historicalMessages) && inputs._historicalMessages.length > 0) {
context.historicalMessages = deserializeLcMessages(inputs._historicalMessages);
}
return Object.keys(context).length > 0 ? context : undefined;
}
function createLangsmithFeedbackExtractor(): (
rootRun: Run,
_example?: Example,
@@ -1171,6 +1302,35 @@ function updateStats(
else stats.errors++;
}
/**
* Resolve LangSmith dataset examples and enrich them with conversation history.
* Handles fallback preloading when filters/maxExamples are requested on a string dataset.
*/
async function resolveAndEnrichLangsmithData(params: {
dataset: string;
langsmithOptions: LangsmithRunConfig['langsmithOptions'];
lsClient: LangsmithRunConfig['langsmithClient'];
logger: EvalLogger;
}): Promise<string | Example[]> {
const { dataset, langsmithOptions, lsClient, logger } = params;
let data = await resolveLangsmithData({ dataset, langsmithOptions, lsClient, logger });
// Defensive: if maxExamples/filters were requested but we still got a dataset name,
// fall back to preloading so we can honor limits instead of streaming everything.
if (
typeof data === 'string' &&
((langsmithOptions.maxExamples ?? 0) > 0 || langsmithOptions.filters !== undefined)
) {
data = await loadExamplesFromDataset({
lsClient,
datasetName: data,
maxExamples: langsmithOptions.maxExamples,
filters: langsmithOptions.filters,
});
}
// Enrich pre-loaded examples with conversation history from outputs
return Array.isArray(data) ? enrichExamplesWithHistory(data) : data;
}
/**
* Run evaluation in LangSmith mode.
*/
@@ -1215,15 +1375,17 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
prompt: string;
genFn: (
prompt: string,
datasetInputContext?: DatasetInputContext,
collectors?: GenerationCollectors,
) => Promise<SimpleWorkflow | GenerationResult>;
collectors?: GenerationCollectors;
limiter?: LlmCallLimiter;
genTimeoutMs?: number;
datasetInputContext?: DatasetInputContext;
}): Promise<SimpleWorkflow | GenerationResult> => {
return await runWithOptionalLimiter(async () => {
return await withTimeout({
promise: args.genFn(args.prompt, args.collectors),
promise: args.genFn(args.prompt, args.datasetInputContext, args.collectors),
timeoutMs: args.genTimeoutMs,
label: 'workflow_generation',
});
@@ -1278,6 +1440,7 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
const index = targetCallCount;
// Extract prompt from inputs (supports both direct prompt and messages array)
const prompt = extractPrompt(inputs);
const datasetInputContext = extractDatasetInputContext(inputs);
const { evals: datasetContext, ...rest } = inputs;
lifecycle?.onExampleStart?.(index, totalExamples, prompt);
@@ -1292,6 +1455,7 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
collectors,
limiter: effectiveGlobalContext.llmCallLimiter,
genTimeoutMs: timeoutMs,
datasetInputContext,
});
const genDurationMs = Date.now() - genStart;
@@ -1321,6 +1485,7 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
testCaseContext: extracted,
generatedCode,
pinData,
datasetInputContext,
});
// Run all evaluators in parallel (wrapped in traceable so it appears
@@ -1411,25 +1576,11 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
const feedbackExtractor = createLangsmithFeedbackExtractor();
// Load examples if maxExamples is set
if (typeof dataset !== 'string') {
throw new Error('LangSmith mode requires dataset to be a dataset name string');
}
let data = await resolveLangsmithData({ dataset, langsmithOptions, lsClient, logger });
// Defensive: if maxExamples/filters were requested but we still got a dataset name,
// fall back to preloading so we can honor limits instead of streaming everything.
if (
typeof data === 'string' &&
((langsmithOptions.maxExamples ?? 0) > 0 || langsmithOptions.filters !== undefined)
) {
data = await loadExamplesFromDataset({
lsClient,
datasetName: data,
maxExamples: langsmithOptions.maxExamples,
filters: langsmithOptions.filters,
});
}
const data = await resolveAndEnrichLangsmithData({ dataset, langsmithOptions, lsClient, logger });
const effectiveData = applyRepetitions(data, langsmithOptions.repetitions);
@@ -23,8 +23,9 @@ import type { StreamOutput } from '../types/streaming';
import type { ChatPayload } from '../workflow-builder-agent';
import { CodeBuilderAgent } from './code-builder-agent';
import { SessionChatHandler } from './handlers/session-chat-handler';
import type { HistoryContext } from './prompts';
import type { TokenUsage } from './types';
export type { TokenUsage };
export type { HistoryContext, TokenUsage };
/**
* Configuration for CodeWorkflowBuilder
@@ -122,12 +123,14 @@ export class CodeWorkflowBuilder {
* @param payload - Chat payload with message and workflow context
* @param userId - User ID for logging
* @param abortSignal - Optional abort signal
* @param historyContext - Optional conversation history (used in evals when no session handler)
* @yields StreamOutput chunks for messages, tool progress, and workflow updates
*/
async *chat(
payload: ChatPayload,
userId: string,
abortSignal?: AbortSignal,
historyContext?: HistoryContext,
): AsyncGenerator<StreamOutput, void, unknown> {
const workflowId = payload.workflowContext?.currentWorkflow?.id;
@@ -150,7 +153,12 @@ export class CodeWorkflowBuilder {
// No session handler - track generation success and call callback manually
let generationSucceeded = false;
for await (const chunk of this.codeBuilderAgent.chat(payload, userId, abortSignal)) {
for await (const chunk of this.codeBuilderAgent.chat(
payload,
userId,
abortSignal,
historyContext,
)) {
// Check if this chunk indicates successful workflow generation
if (chunk.messages?.some((msg) => msg.type === 'workflow-updated')) {
generationSucceeded = true;
@@ -12,7 +12,7 @@ export type { CodeBuilderAgentConfig, ParseAndValidateResult, ValidationWarning
// Code Workflow Builder
export { CodeWorkflowBuilder } from './code-workflow-builder';
export type { CodeWorkflowBuilderConfig } from './code-workflow-builder';
export type { CodeWorkflowBuilderConfig, HistoryContext } from './code-workflow-builder';
// Session utilities
export { generateCodeBuilderThreadId } from './utils/code-builder-session';