fix(ai-builder): Run agents that a workflow calls with mocked tools in eval runs (no-changelog) (#40584)

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
José Braulio González Valido
2026-10-09 15:56:40 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 50c2ca0a27
commit 5bc6021477
10 changed files with 785 additions and 82 deletions
@@ -659,6 +659,12 @@ nodes get LLM-generated pin data). So:
otherwise
accept the static-data red as a harness limit and carry the logic in
`outcomeExpectations`. Note the agent may *choose* static-data dedup on its own.
- **Message an Agent runs the called Agent with its real model.** The harness
mocks the Agent's tools, MCP servers and web search, and records those calls
under the calling node. It turns off the Agent's memory, vector stores and chat
integrations. Only OpenAI models are supported today, and only when
`EVAL_OPENAI_API_KEY` is set (CI sets it): put the Agent on an `openai/*` model
and declare an `openAiApi` credential.
- Don't assert exact counts that depend on mock generation ("exactly 7 posts").
Say "fewer than the original 10".
@@ -1817,6 +1817,102 @@ describe('WorkflowExecuteAdditionalData', () => {
);
});
describe('eval runs', () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
projectId: 'project-1',
workflowId: 'workflow-1',
});
it('runs the draft of a stored agent with the eval hook', async () => {
const prepareForEval = vi.fn();
await executeAgent(
{ agentId: AGENT_ID },
MESSAGE,
EXEC_ID,
THREAD_ID,
additionalData,
'evaluation',
undefined,
undefined,
undefined,
prepareForEval,
);
expect(agentWorkflowExecutionService.executeForWorkflow).toHaveBeenCalledWith(
AGENT_ID,
MESSAGE,
EXEC_ID,
PROJECT_THREAD_ID,
'project-1',
'user-1',
true,
undefined,
undefined,
executionSandboxScope,
{ streamObserver: undefined, prepareForEval },
);
});
it('runs an inline agent as a test run with the eval hook', async () => {
const prepareForEval = vi.fn();
const inlineAgent = {
config: { name: 'Inline', model: 'openai/gpt-5', credential: 'c1', instructions: '' },
};
await executeAgent(
{ inlineAgent },
MESSAGE,
EXEC_ID,
THREAD_ID,
additionalData,
'evaluation',
undefined,
undefined,
undefined,
prepareForEval,
);
expect(agentWorkflowExecutionService.executeInlineForWorkflow).toHaveBeenCalledWith(
inlineAgent,
MESSAGE,
EXEC_ID,
PROJECT_THREAD_ID,
'project-1',
'user-1',
'test',
undefined,
undefined,
{ streamObserver: undefined, prepareForEval },
);
});
it('keeps the published version for evaluation runs without the eval hook', async () => {
await executeAgent(
{ agentId: AGENT_ID },
MESSAGE,
EXEC_ID,
THREAD_ID,
additionalData,
'evaluation',
);
expect(agentWorkflowExecutionService.executeForWorkflow).toHaveBeenCalledWith(
AGENT_ID,
MESSAGE,
EXEC_ID,
PROJECT_THREAD_ID,
'project-1',
'user-1',
false,
undefined,
undefined,
executionSandboxScope,
);
});
});
it('uses projectId from additionalData when present', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
userId: 'user-1',
@@ -2067,7 +2163,8 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(agentWorkflowExecutionService.executeForWorkflow.mock.calls[0]?.[9]).toEqual(
executionSandboxScope,
);
const streamObserver = agentWorkflowExecutionService.executeForWorkflow.mock.calls[0]?.[10];
const streamObserver =
agentWorkflowExecutionService.executeForWorkflow.mock.calls[0]?.[10]?.streamObserver;
expect(streamObserver).toEqual(expect.any(Function));
await streamObserver?.({ type: 'response-delta', delta: 'hello' });
@@ -20,6 +20,10 @@ import type { AgentExecutionService } from '../agent-execution.service';
import type { AgentMessageQueueService } from '../agent-message-queue.service';
import type { AgentChatExecutionService } from '../agent-chat-execution.service';
import type { AgentRunTracingService } from '../agent-run-tracing.service';
import type {
AgentRuntimeInstrumentation,
PrepareWorkflowAgentForEval,
} from '../agent-runtime-instrumentation';
import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
import { AgentTurnExecutionService } from '../agent-turn-execution.service';
import type { AgentToolApprovalService } from '../agent-tool-approval.service';
@@ -920,7 +924,7 @@ describe('AgentWorkflowExecutionService', () => {
'production',
undefined,
workflowContext,
streamObserver,
{ streamObserver },
);
// No entity lookup — the embedded config is the source of truth.
@@ -1302,7 +1306,7 @@ describe('AgentWorkflowExecutionService', () => {
undefined,
undefined,
undefined,
streamObserver,
{ streamObserver },
);
await expect(execution).rejects.toMatchObject({
message: 'reader failed while consuming stream',
@@ -1340,7 +1344,7 @@ describe('AgentWorkflowExecutionService', () => {
undefined,
undefined,
undefined,
streamObserver,
{ streamObserver },
);
await expect(execution).rejects.toMatchObject({
@@ -1458,4 +1462,102 @@ describe('AgentWorkflowExecutionService', () => {
}),
);
});
describe('eval runs', () => {
const evalConfig: AgentJsonConfig = { ...schema, instructions: 'Help users (eval copy)' };
const integrations = [{ type: 'slack', credentialId: 'slack-1' }] as Agent['integrations'];
function makePrepareForEval() {
const instrumentation: AgentRuntimeInstrumentation = {
configureToolAdditionalData: vi.fn(),
};
const prepareForEval = vi
.fn<PrepareWorkflowAgentForEval>()
.mockResolvedValue({ config: evalConfig, instrumentation });
return { prepareForEval, instrumentation };
}
it('compiles a stored agent from the eval config and seams, without chat integrations', async () => {
const { service, agentRepository, reconstructionService, executionService } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ integrations }));
reconstructionService.reconstructFromAgentEntity.mockResolvedValue(makeRuntime());
const { prepareForEval, instrumentation } = makePrepareForEval();
await service.executeForWorkflow(
agentId,
'hello',
'execution-1',
'thread-1',
projectId,
userId,
true,
undefined,
undefined,
undefined,
{ prepareForEval },
);
expect(prepareForEval).toHaveBeenCalledWith(schema, { hasChatIntegrations: true });
const [entity, , , , , passedInstrumentation] =
reconstructionService.reconstructFromAgentEntity.mock.calls[0];
expect(entity.schema).toEqual(evalConfig);
expect(entity.integrations).toEqual([]);
expect(passedInstrumentation).toBe(instrumentation);
// Run telemetry describes the config that ran, not the stored one.
expect(executionService.startExecutionRecording).toHaveBeenCalledWith(
expect.objectContaining({
telemetry: expect.objectContaining({
configuration: expect.objectContaining({ channels: [] }),
}),
}),
expect.anything(),
);
});
it('compiles an inline agent from the eval config and seams', async () => {
const { service, reconstructionService } = makeService();
reconstructionService.reconstructFromResolvedSource.mockResolvedValue(makeRuntime());
const { prepareForEval, instrumentation } = makePrepareForEval();
await service.executeInlineForWorkflow(
{
config: {
name: 'Inline Agent',
model: 'openai/gpt-5',
credential: 'cred-1',
instructions: 'Help users',
},
},
'hello',
'execution-1',
'thread-1',
projectId,
userId,
'test',
undefined,
undefined,
{ prepareForEval },
);
expect(prepareForEval).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Inline Agent' }),
);
expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledWith(
expect.objectContaining({ config: evalConfig, instrumentation }),
);
});
it('passes no seams and keeps chat integrations outside eval runs', async () => {
const { service, agentRepository, reconstructionService } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ integrations }));
reconstructionService.reconstructFromAgentEntity.mockResolvedValue(makeRuntime());
await service.executeForWorkflow(agentId, 'hello', 'execution-1', 'thread-1', projectId);
const [entity, , , , , passedInstrumentation] =
reconstructionService.reconstructFromAgentEntity.mock.calls[0];
expect(entity.integrations).toEqual(integrations);
expect(passedInstrumentation).toBeUndefined();
});
});
});
@@ -65,3 +65,12 @@ export interface AgentRuntimeInstrumentation {
export type InstrumentToolAdditionalData = NonNullable<
AgentRuntimeInstrumentation['configureToolAdditionalData']
>;
/**
* Set by the Instance AI workflow eval when a workflow calls an agent. Returns
* the config to run (features only removed) and the seams that fake its I/O.
*/
export type PrepareWorkflowAgentForEval = (
config: AgentJsonConfig,
context?: { hasChatIntegrations?: boolean },
) => Promise<{ config: AgentJsonConfig; instrumentation: AgentRuntimeInstrumentation }>;
@@ -33,6 +33,10 @@ import { Telemetry } from '@/telemetry';
import type { StartExecutionParams } from './agent-execution.service';
import { AgentRunTracingService } from './agent-run-tracing.service';
import type {
AgentRuntimeInstrumentation,
PrepareWorkflowAgentForEval,
} from './agent-runtime-instrumentation';
import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service';
import { AgentsSettingsService } from './agents-settings.service';
import {
@@ -123,10 +127,17 @@ interface WorkflowExecutionContext {
streamObserver?: WorkflowAgentStreamObserver;
}
/** Optional hooks for one workflow agent run. */
export interface WorkflowAgentRunOptions {
streamObserver?: WorkflowAgentStreamObserver;
prepareForEval?: PrepareWorkflowAgentForEval;
}
interface StoredWorkflowExecutionContext extends WorkflowExecutionContext {
agentId: string;
useDraftVersion?: boolean;
sandboxScope?: WorkflowSandboxScope;
prepareForEval?: PrepareWorkflowAgentForEval;
}
function getFinalWorkflowResponse(messageRecord: MessageRecord): string {
@@ -240,6 +251,7 @@ export class AgentWorkflowExecutionService {
outputSchema?: JSONSchema7,
extraTools?: BuiltTool[],
sandboxPrincipalHash?: AgentSandboxPrincipalHash,
instrumentation?: AgentRuntimeInstrumentation,
): Promise<{ ok: boolean; agent?: BuiltAgent; budget?: BudgetGuardrailConfig; error?: string }> {
if (!agentEntity.schema) {
return { ok: false, error: 'Agent has no JSON config. Create a config first.' };
@@ -259,7 +271,7 @@ export class AgentWorkflowExecutionService {
runType,
undefined,
undefined,
undefined,
instrumentation,
'manual',
sandboxPrincipalHash,
// A workflow execution cannot resume a suspended run — it throws
@@ -294,6 +306,7 @@ export class AgentWorkflowExecutionService {
runType: AgentRunTelemetryType,
outputSchema?: JSONSchema7,
extraTools?: BuiltTool[],
instrumentation?: AgentRuntimeInstrumentation,
): Promise<{ ok: boolean; agent?: BuiltAgent; budget?: BudgetGuardrailConfig; error?: string }> {
try {
const reconstructed =
@@ -307,6 +320,7 @@ export class AgentWorkflowExecutionService {
skills,
runtimeProfile: 'inline',
runType,
...(instrumentation ? { instrumentation } : {}),
});
const applied = this.applyPerCallAgentExtras(reconstructed.agent, outputSchema, extraTools);
if (!applied.ok) return applied;
@@ -623,7 +637,7 @@ export class AgentWorkflowExecutionService {
outputSchema?: JSONSchema7,
workflowContext?: ExecuteAgentWorkflowContext,
sandboxScope?: WorkflowSandboxScope,
streamObserver?: WorkflowAgentStreamObserver,
{ streamObserver, prepareForEval }: WorkflowAgentRunOptions = {},
): Promise<ExecuteAgentData> {
return await this.executeForWorkflowInternal({
agentId,
@@ -637,6 +651,7 @@ export class AgentWorkflowExecutionService {
workflowContext,
sandboxScope,
streamObserver,
prepareForEval,
});
}
@@ -676,10 +691,12 @@ export class AgentWorkflowExecutionService {
outputSchema,
workflowContext,
sandboxScope,
prepareForEval,
} = params;
// Keep the original intent if deletion happens during preparation.
const sessionMode = await this.turnExecutionService.getSessionMode(threadId);
const { agentData, credentialProvider } = await this.loadWorkflowAgent(params);
const instrumentation = await this.applyEvalSetup(agentData, prepareForEval);
const telemetryConfiguration = buildAgentConfigurationTelemetry(agentData);
const runType: AgentRunTelemetryType = useDraftVersion ? 'test' : 'production';
@@ -704,6 +721,7 @@ export class AgentWorkflowExecutionService {
outputSchema,
extraTools?.length ? extraTools : undefined,
sandboxScope?.principalHash,
instrumentation,
);
if (!compiled.ok || !compiled.agent) {
const error = new OperationalError(
@@ -737,6 +755,17 @@ export class AgentWorkflowExecutionService {
return { agentData, credentialProvider };
}
/** Chat integrations post through chat SDKs, which an eval cannot fake, so eval runs drop them. */
private async applyEvalSetup(agent: Agent, prepareForEval?: PrepareWorkflowAgentForEval) {
if (!prepareForEval || !agent.schema) return undefined;
const { config, instrumentation } = await prepareForEval(agent.schema, {
hasChatIntegrations: (agent.integrations ?? []).length > 0,
});
agent.schema = config;
agent.integrations = [];
return instrumentation;
}
private async streamCompiledWorkflowAgent(
agentInstance: BuiltAgent,
params: WorkflowExecutionContext,
@@ -797,7 +826,7 @@ export class AgentWorkflowExecutionService {
runType: AgentRunTelemetryType = 'production',
outputSchema?: JSONSchema7,
workflowContext?: ExecuteAgentWorkflowContext,
streamObserver?: WorkflowAgentStreamObserver,
{ streamObserver, prepareForEval }: WorkflowAgentRunOptions = {},
): Promise<ExecuteAgentData> {
await this.settingsService.assertEnabled();
const { runtimeConfig, skills, credentialProvider } = await this.prepareInlineRuntime(
@@ -805,6 +834,7 @@ export class AgentWorkflowExecutionService {
projectId,
workflowContext,
);
const prepared = prepareForEval ? await prepareForEval(runtimeConfig) : undefined;
// For telemetry/logging and memory-owner keying — never persisted, and
// stable enough to aggregate runs of the same node across executions.
const syntheticAgentId = `inline:${workflowContext?.workflowId ?? 'unknown'}:${
@@ -813,7 +843,7 @@ export class AgentWorkflowExecutionService {
const extraTools = this.buildWorkflowExtraTools(workflowContext);
const compiled = await this.compileIsolatedFromSource(
runtimeConfig,
prepared?.config ?? runtimeConfig,
skills,
syntheticAgentId,
projectId,
@@ -821,6 +851,7 @@ export class AgentWorkflowExecutionService {
runType,
outputSchema,
extraTools?.length ? extraTools : undefined,
prepared?.instrumentation,
);
if (!compiled.ok || !compiled.agent) {
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
@@ -54,6 +54,18 @@ The bypass is queried per test rather than snapshotted into the adapter: the
harness registers ids mid-run, when the simulated user creates a credential on
a card, long after the run's context was built.
## Agents a workflow calls
A Message an Agent node, or its tool version, runs another agent in-process, so
no HTTP mock reaches it by itself. In an eval run the workflow eval gives that
agent the Agent eval's seams (`prepareCalledAgent` in `execution.service.ts`):
its node and workflow tools, streamable-HTTP MCP servers and fallback web search
are mocked, and each mocked call is recorded under the calling node. The agent's
own model call stays real, so its model needs a credential with a real key. The
eval runs the agent's draft and turns off its memory, vector stores, SSE MCP
servers and chat integrations. A sub-agent keeps only the MCP servers its calling
agent also has; the eval turns off the others and adds a warning.
## Agent model catalogs
The eval credential allowlist also marks Agent Builder sessions as evaluations.
@@ -1,5 +1,6 @@
import type { Mock } from 'vitest';
import type { Logger } from '@n8n/backend-common';
import type { AgentJsonConfig } from '@n8n/api-types';
import type { Logger, ModuleRegistry } from '@n8n/backend-common';
import type { ExecutionsConfig, InstanceAiConfig } from '@n8n/config';
import type { ProcessedDataRepository, User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
@@ -16,6 +17,7 @@ import { NodeConnectionTypes, TimeoutExecutionCancelledError, UserError } from '
import type { ActiveExecutions } from '@/active-executions';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { PrepareWorkflowAgentForEval } from '@/modules/agents/agent-runtime-instrumentation';
import type { NodeTypes } from '@/node-types';
import type { PostHogClient } from '@/posthog';
import type { DataTableService } from '@/modules/data-table/data-table.service';
@@ -101,12 +103,22 @@ vi.mock('n8n-workflow', async () => {
};
});
const mockExecuteAgent = vi.fn();
vi.mock('@/workflow-execute-additional-data', async (importOriginal) => ({
...(await importOriginal<typeof import('@/workflow-execute-additional-data')>()),
executeAgent: async (...args: unknown[]) => await mockExecuteAgent(...args),
}));
vi.mock('../mcp-mock-fetch', () => ({ createMcpMockFetch: vi.fn() }));
vi.mock('../web-search-mock', () => ({ createWebSearchMock: vi.fn() }));
// ---------------------------------------------------------------------------
// Import SUT and mocked modules (after vi.mock calls)
// ---------------------------------------------------------------------------
import { EvalExecutionService } from '../execution.service';
import { createMcpMockFetch } from '../mcp-mock-fetch';
import { createLlmMockHandler } from '../mock-handler';
import { createWebSearchMock } from '../web-search-mock';
import { generatePinData } from '../pin-data-generator';
import {
detectBinaryDependencies,
@@ -227,6 +239,7 @@ describe('EvalExecutionService', () => {
const dataTableService = mock<DataTableService>();
const processedDataRepository = mock<ProcessedDataRepository>();
const instanceAiConfig = { evalInstance: true } as InstanceAiConfig;
const moduleRegistry = mock<ModuleRegistry>();
// Captured configureAdditionalData closure so tests can re-invoke it on a
// stub additionalData without booting the real runner.
@@ -265,6 +278,7 @@ describe('EvalExecutionService', () => {
dataTableService,
processedDataRepository,
instanceAiConfig,
moduleRegistry,
);
// Reset to safe default — tests that flip queue mode reassign in-test.
Object.assign(executionsConfig, { mode: 'regular' });
@@ -2078,6 +2092,227 @@ describe('EvalExecutionService', () => {
});
});
// ── agents a workflow calls ──────────────────────────────────────
describe('agents a workflow calls', () => {
const callingNode = 'Ask Support';
const agentConfig = {
name: 'Support Bot',
model: 'openai/gpt-5',
credential: 'cred-1',
instructions: 'Answer support questions.',
memory: { enabled: true, storage: 'n8n' },
vectorStores: [{ id: 'store-1' }],
mcpServers: [{ name: 'linear', url: 'https://mcp.linear.app/mcp' }],
} as unknown as AgentJsonConfig;
const toolNode: INode = {
id: 'tool-1',
name: 'slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2,
position: [0, 0],
parameters: {},
};
type AgentCallData = StubAdditionalData & {
executeAgent: (...args: unknown[]) => Promise<unknown>;
};
let runData: AgentCallData | undefined;
/** Makes the run call an agent through the swapped `executeAgent`, once per entry. */
function callAgentsDuringRun(...calls: Array<[threadId: string, workflowContext: object]>) {
workflowRunner.run.mockImplementation(async (data) => {
const ad = makeMockedAdditionalData() as AgentCallData;
runData = ad;
await data.configureAdditionalData?.(ad as never);
for (const [threadId, workflowContext] of calls) {
await ad.executeAgent(
{ agentId: 'agent-1' },
'hi',
'exec-1',
threadId,
ad,
'evaluation',
undefined,
workflowContext,
undefined,
);
}
return DB_EXECUTION_ID;
});
}
beforeEach(() => {
workflowFinderService.findWorkflowForUser.mockResolvedValue(makeWorkflowEntity() as never);
createLlmMockHandlerMock.mockReturnValue(
vi.fn().mockResolvedValue({ body: { ok: true }, headers: {}, statusCode: 200 }),
);
});
it('runs the called agent with fake I/O and records it under the calling node', async () => {
let prepared: Awaited<ReturnType<PrepareWorkflowAgentForEval>> | undefined;
const toolData = makeMockedAdditionalData();
mockExecuteAgent.mockImplementation(async (...args: unknown[]) => {
const prepareForEval = args[9] as PrepareWorkflowAgentForEval;
prepared = await prepareForEval(agentConfig);
prepared.instrumentation.configureToolAdditionalData?.(toolData as never, {
toolName: 'slack',
toolKind: 'node',
});
await toolData.evalLlmMockHandler?.(
{ url: 'https://slack.com/api/chat.postMessage', method: 'POST', body: { text: 'hi' } },
toolNode,
);
vi.mocked(createMcpMockFetch).mock.calls[0][0].onToolCall({
serverName: 'linear',
toolName: 'list_issues',
args: { query: 'refund' },
result: { issues: [] },
});
vi.mocked(createWebSearchMock).mock.calls[0][0].onSearch(
{ query: 'refund policy' },
{ query: 'refund policy', results: [] },
);
return { response: 'done' };
});
callAgentsDuringRun(['thread-1', { callingNodeName: callingNode }]);
const result = await service.executeWithLlmMock('wf-1', makeUser());
expect(mockExecuteAgent).toHaveBeenCalledWith(
{ agentId: 'agent-1' },
'hi',
'exec-1',
'thread-1',
expect.anything(),
'evaluation',
undefined,
{ callingNodeName: callingNode },
undefined,
expect.any(Function),
);
expect(prepared?.config.mcpServers).toEqual(agentConfig.mcpServers);
expect(prepared?.config.memory).toBeUndefined();
expect(prepared?.config.vectorStores).toBeUndefined();
const entry = result.nodeResults[callingNode];
expect(entry.executionMode).toBe('mocked');
expect(entry.interceptedRequests.map((request) => request.nodeType)).toEqual([
'n8n-nodes-base.slack',
'mcp:linear',
'web-search:fallback',
]);
expect(result.nodeResults.slack).toBeUndefined();
// The run's helper reports credentials the tools needed mocked.
expect(toolData.credentialsHelper).toBe(runData?.credentialsHelper);
// Memory goes off without a flag; a lost capability gets one.
expect(result.hints.warnings).toEqual([expect.stringContaining('vectorStores')]);
});
it('reuses one set of fakes per node and flags memory when a call continues a session', async () => {
mockExecuteAgent.mockImplementation(async (...args: unknown[]) => {
await (args[9] as PrepareWorkflowAgentForEval)(agentConfig);
return { response: 'done' };
});
const session = { callingNodeName: callingNode, hasCallerSessionId: true };
callAgentsDuringRun(['session-1', session], ['session-1', session]);
const result = await service.executeWithLlmMock('wf-1', makeUser());
expect(createMcpMockFetch).toHaveBeenCalledTimes(1);
expect(createWebSearchMock).toHaveBeenCalledTimes(1);
expect(result.hints.warnings).toEqual([
expect.stringContaining('vectorStores'),
expect.stringContaining('memory'),
]);
});
it('flags the chat integrations that the eval drops from a called agent', async () => {
mockExecuteAgent.mockImplementation(async (...args: unknown[]) => {
await (args[9] as PrepareWorkflowAgentForEval)(agentConfig, { hasChatIntegrations: true });
return { response: 'done' };
});
callAgentsDuringRun(['thread-1', { callingNodeName: callingNode }]);
const result = await service.executeWithLlmMock('wf-1', makeUser());
expect(result.hints.warnings).toContainEqual(
expect.stringContaining('turned off its chat integrations'),
);
});
it("flags sub-agent features and mocks agents that the called agent's tools call", async () => {
mockExecuteAgent.mockImplementation(async (...args: unknown[]) => {
const { instrumentation } = await (args[9] as PrepareWorkflowAgentForEval)(agentConfig);
const toolData = makeMockedAdditionalData() as AgentCallData;
instrumentation.configureToolAdditionalData?.(toolData as never, {
toolName: 'triage',
toolKind: 'workflow',
});
const { callingNodeName } = args[7] as { callingNodeName: string };
if (callingNodeName === callingNode) {
instrumentation.transformDelegatedAgentConfig?.(agentConfig, { subAgentId: 'child-1' });
await toolData.executeAgent(
{ agentId: 'agent-2' },
'hi',
'exec-1',
'thread-2',
toolData,
'evaluation',
undefined,
{ callingNodeName: 'Inner Message' },
undefined,
);
} else {
await toolData.evalLlmMockHandler?.(
{ url: 'https://slack.com/api/chat.postMessage', method: 'POST' },
toolNode,
);
}
return { response: 'done' };
});
callAgentsDuringRun(['thread-1', { callingNodeName: callingNode }]);
const result = await service.executeWithLlmMock('wf-1', makeUser());
expect(
result.nodeResults[callingNode].interceptedRequests.map((request) => request.nodeType),
).toEqual(['n8n-nodes-base.slack']);
expect(result.nodeResults['Inner Message']).toBeUndefined();
expect(result.hints.warnings).toContainEqual(expect.stringContaining('sub-agent child-1'));
});
it("turns off a sub-agent's MCP servers that the calling agent's mock does not cover", async () => {
let childConfig: AgentJsonConfig | undefined;
mockExecuteAgent.mockImplementation(async (...args: unknown[]) => {
const { instrumentation } = await (args[9] as PrepareWorkflowAgentForEval)(agentConfig);
childConfig = instrumentation.transformDelegatedAgentConfig?.(
{
...agentConfig,
memory: undefined,
vectorStores: undefined,
mcpServers: [
{ name: 'linear', url: 'https://mcp.linear.app/mcp' },
{ name: 'github', url: 'https://mcp.github.com/mcp' },
],
} as unknown as AgentJsonConfig,
{ subAgentId: 'child-1' },
);
return { response: 'done' };
});
callAgentsDuringRun(['thread-1', { callingNodeName: callingNode }]);
const result = await service.executeWithLlmMock('wf-1', makeUser());
expect(childConfig?.mcpServers).toEqual([
{ name: 'linear', url: 'https://mcp.linear.app/mcp' },
]);
expect(result.hints.warnings).toContainEqual(
expect.stringMatching(/sub-agent child-1's MCP server "github"/),
);
});
});
// ── reserved node names ──────────────────────────────────────────
describe('reserved node names', () => {
@@ -94,6 +94,55 @@ export function mcpUrlsMatch(configUrl: string, remoteUrl: string): boolean {
return longer === shorter || longer === base || longer.startsWith(`${base}/`);
}
/**
* Resolve canonical tool catalogs for configured MCP servers, so the mock
* exposes the tools the real server would: registry entries carry the
* server's declared catalog (matched by remote URL), and an allow-mode
* toolFilter pins names even without a registry match. Best-effort —
* without a match the mock's LLM-designed catalog applies.
*/
export async function resolveCanonicalMcpCatalogs(
mcpServers: NonNullable<AgentJsonConfig['mcpServers']>,
moduleRegistry: ModuleRegistry,
logger: Logger,
): Promise<Record<string, McpMockCanonicalTool[]> | undefined> {
if (mcpServers.length === 0) return undefined;
const result: Record<string, McpMockCanonicalTool[]> = {};
if (moduleRegistry.isActive('mcp-registry')) {
try {
const entries = await Container.get(McpRegistryService).getAll();
for (const server of mcpServers) {
const entry = entries.find((candidate) =>
candidate.remotes.some((remote) => mcpUrlsMatch(server.url, remote.url)),
);
if (entry && entry.tools.length > 0) {
result[server.name] = entry.tools.map((tool) => ({
name: tool.name,
description: tool.title ?? tool.name,
}));
}
}
} catch (error) {
logger.debug(
`[EvalAgentMock] MCP registry catalog lookup failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
for (const server of mcpServers) {
if (result[server.name]) continue;
if (server.toolFilter?.mode === 'allow' && server.toolFilter.tools.length > 0) {
result[server.name] = server.toolFilter.tools.map((name) => ({
name,
description: name,
}));
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
@Service()
export class EvalAgentExecutionService {
constructor(
@@ -216,7 +265,11 @@ export class EvalAgentExecutionService {
// Always created — delegated sub-agents may bring their own servers.
const mcpServers = config.mcpServers ?? [];
const knownToolsByServer = await this.resolveCanonicalMcpCatalogs(mcpServers);
const knownToolsByServer = await resolveCanonicalMcpCatalogs(
mcpServers,
this.moduleRegistry,
this.logger,
);
const mcpFetch = createMcpMockFetch({
servers: mcpServers.map((server) => ({
name: server.name,
@@ -493,53 +546,6 @@ export class EvalAgentExecutionService {
};
}
/**
* Resolve canonical tool catalogs for configured MCP servers, so the mock
* exposes the tools the real server would: registry entries carry the
* server's declared catalog (matched by remote URL), and an allow-mode
* toolFilter pins names even without a registry match. Best-effort —
* without a match the mock's LLM-designed catalog applies.
*/
private async resolveCanonicalMcpCatalogs(
mcpServers: NonNullable<AgentJsonConfig['mcpServers']>,
): Promise<Record<string, McpMockCanonicalTool[]> | undefined> {
if (mcpServers.length === 0) return undefined;
const result: Record<string, McpMockCanonicalTool[]> = {};
if (this.moduleRegistry.isActive('mcp-registry')) {
try {
const entries = await Container.get(McpRegistryService).getAll();
for (const server of mcpServers) {
const entry = entries.find((candidate) =>
candidate.remotes.some((remote) => mcpUrlsMatch(server.url, remote.url)),
);
if (entry && entry.tools.length > 0) {
result[server.name] = entry.tools.map((tool) => ({
name: tool.name,
description: tool.title ?? tool.name,
}));
}
}
} catch (error) {
this.logger.debug(
`[EvalAgentMock] MCP registry catalog lookup failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
for (const server of mcpServers) {
if (result[server.name]) continue;
if (server.toolFilter?.mode === 'allow' && server.toolFilter.tools.length > 0) {
result[server.name] = server.toolFilter.tools.map((name) => ({
name,
description: name,
}));
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Wraps the shared mock handler to record intercepted requests against the
* owning tool — the agent-eval analog of the workflow eval's
@@ -1,10 +1,12 @@
import {
type AgentJsonConfig,
EVAL_VENDOR_SDK_INTERCEPTION_FLAG,
type InstanceAiEvalExecutionRequest,
type InstanceAiEvalInterceptedRequest,
type InstanceAiEvalNodeResult,
type InstanceAiEvalExecutionResult,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Logger, ModuleRegistry } from '@n8n/backend-common';
import { ensureHostsBypassProxy } from '@n8n/backend-network/proxy';
import { ExecutionsConfig, InstanceAiConfig } from '@n8n/config';
import { ProcessedDataRepository, type User } from '@n8n/db';
@@ -38,6 +40,7 @@ import {
MANUAL_TRIGGER_NODE_TYPE,
NodeHelpers,
TimeoutExecutionCancelledError,
UnexpectedError,
UserError,
Workflow,
type IWorkflowIssues,
@@ -46,16 +49,24 @@ import { randomUUID } from 'node:crypto';
import { ActiveExecutions } from '@/active-executions';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type {
AgentRuntimeInstrumentation,
PrepareWorkflowAgentForEval,
} from '@/modules/agents/agent-runtime-instrumentation';
import { DataTableService } from '@/modules/data-table/data-table.service';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { OwnershipService } from '@/services/ownership.service';
import { executeAgent } from '@/workflow-execute-additional-data';
import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { pruneConfigForEval, resolveCanonicalMcpCatalogs } from './agent-execution.service';
import { createLlmCompletionMockHandler } from './llm-completion-mock';
import { EvalMockedCredentialsHelper } from './eval-mocked-credentials-helper';
import { createMcpMockFetch } from './mcp-mock-fetch';
import { createWebSearchMock } from './web-search-mock';
import { EvalTimings } from './eval-timings';
import { snapshotLedgerBody } from './ledger-snapshot';
import { type InterceptedTurn, LlmWireServer } from './llm-wire-server';
@@ -95,6 +106,22 @@ interface RunBudget {
deadlineAt: number;
}
type CalledAgentMocks = Pick<AgentRuntimeInstrumentation, 'mcpFetch' | 'webSearch'>;
/** What the agents a workflow calls share during one eval run. */
interface CalledAgentRun {
mockHandler: EvalLlmMockHandler;
nodeResults: Record<string, InstanceAiEvalNodeResult>;
timings: EvalTimings;
hints: MockHints;
scenarioHints?: string;
credentialsHelper: EvalMockedCredentialsHelper;
/** MCP and web-search fakes by calling node and agent config. */
mocks: Map<string, Promise<CalledAgentMocks>>;
/** Agent sessions this run has already called. */
sessions: Set<string>;
}
/** A Data Table node's locator: the id it carries, or the name when it is in `name` mode. */
function dataTableLocator(node: INode): { mode: 'name' | 'id'; value: string } | undefined {
const locator = node.parameters?.dataTableId as
@@ -131,6 +158,7 @@ export class EvalExecutionService {
private readonly dataTableService: DataTableService,
private readonly processedDataRepository: ProcessedDataRepository,
private readonly instanceAiConfig: InstanceAiConfig,
private readonly moduleRegistry: ModuleRegistry,
) {}
async executeWithLlmMock(
@@ -652,18 +680,29 @@ export class EvalExecutionService {
executionData,
pinData,
configureAdditionalData: (additionalData: IWorkflowExecuteAdditionalData) => {
credentialsHelper = new EvalMockedCredentialsHelper(
const runCredentialsHelper = new EvalMockedCredentialsHelper(
additionalData.credentialsHelper,
serverUrl,
this.logger,
vendorLlmRouting?.subNodeToRoot,
);
additionalData.credentialsHelper = credentialsHelper;
credentialsHelper = runCredentialsHelper;
additionalData.credentialsHelper = runCredentialsHelper;
additionalData.evalLlmMockHandler = this.createInterceptingHandler(
mockHandler,
nodeResults,
timings,
);
additionalData.executeAgent = this.createEvalExecuteAgent({
mockHandler,
nodeResults,
timings,
hints,
scenarioHints,
credentialsHelper: runCredentialsHelper,
mocks: new Map(),
sessions: new Set(),
});
},
};
@@ -1021,31 +1060,197 @@ export class EvalExecutionService {
);
}
/**
* Replaces `executeAgent` for a run. `attributeTo` records the calls of an
* agent that a called agent's tool runs under that called agent's node.
*/
private createEvalExecuteAgent(
run: CalledAgentRun,
attributeTo?: string,
): NonNullable<IWorkflowExecuteAdditionalData['executeAgent']> {
return async (
source,
message,
executionId,
threadId,
agentAdditionalData,
mode,
outputSchema,
workflowContext,
invocationContext,
) => {
if (!workflowContext) {
throw new UnexpectedError('An eval run cannot attribute an agent call without its node');
}
const callingNode = attributeTo ?? workflowContext.callingNodeName;
const session = `${source.agentId ?? `inline:${workflowContext.callingNodeName}`}:${threadId}`;
const continuesSession =
workflowContext.hasCallerSessionId === true && run.sessions.has(session);
run.sessions.add(session);
return await executeAgent(
source,
message,
executionId,
threadId,
agentAdditionalData,
mode,
outputSchema,
workflowContext,
invocationContext,
this.prepareCalledAgent(callingNode, run, continuesSession),
);
};
}
/**
* Fakes the I/O of an agent the workflow calls, as the Agent eval does: tools,
* MCP servers and web search are mocked; the model call stays real.
*/
private prepareCalledAgent(
callingNode: string,
run: CalledAgentRun,
continuesSession: boolean,
): PrepareWorkflowAgentForEval {
const warn = (feature: string, reason: string) => {
const warning = `"${callingNode}" calls an Agent; the eval turned off its ${feature}: ${reason}`;
if (!run.hints.warnings.includes(warning)) run.hints.warnings.push(warning);
};
return async (original: AgentJsonConfig, { hasChatIntegrations } = {}) => {
const { config, skippedFeatures } = pruneConfigForEval(original);
if (hasChatIntegrations) {
warn('chat integrations', 'an eval run cannot fake the chat SDKs they post through.');
}
for (const { feature, reason } of skippedFeatures) {
// Memory matters only to a call that continues an earlier session of this run.
if (feature !== 'memory') warn(feature, reason);
else if (continuesSession) warn(feature, 'this call does not see its earlier turns.');
}
return {
config,
instrumentation: {
...(await this.calledAgentMocks(callingNode, config, run)),
configureToolAdditionalData: (toolData) => {
toolData.credentialsHelper = run.credentialsHelper;
toolData.evalLlmMockHandler = this.createInterceptingHandler(
run.mockHandler,
run.nodeResults,
run.timings,
callingNode,
);
toolData.executeAgent = this.createEvalExecuteAgent(run, callingNode);
},
transformDelegatedAgentConfig: (childConfig, { subAgentId }) => {
const pruned = pruneConfigForEval(childConfig);
for (const { feature, reason } of pruned.skippedFeatures) {
if (feature !== 'memory') warn(`sub-agent ${subAgentId}'s ${feature}`, reason);
}
// The MCP mock knows only the calling agent's servers; another server
// would get a catalog invented without its own description.
const mocked = new Set((config.mcpServers ?? []).map((server) => server.url));
const mcpServers = pruned.config.mcpServers?.filter((server) => {
if (mocked.has(server.url)) return true;
warn(
`sub-agent ${subAgentId}'s MCP server "${server.name}"`,
'the eval mocks only the MCP servers of the agent the workflow calls.',
);
return false;
});
return mcpServers ? { ...pruned.config, mcpServers } : pruned.config;
},
},
};
};
}
/** One MCP and web-search fake per node and agent config, so repeated calls share one MCP catalog. */
private async calledAgentMocks(
callingNode: string,
config: AgentJsonConfig,
run: CalledAgentRun,
): Promise<CalledAgentMocks> {
const mcpServers = config.mcpServers ?? [];
const key = JSON.stringify([callingNode, config.instructions, mcpServers]);
const cached = run.mocks.get(key);
if (cached) return await cached;
const record = (request: InstanceAiEvalInterceptedRequest) => {
this.mockedEntry(run.nodeResults, callingNode).interceptedRequests.push(request);
};
const mockContext = {
agentInstructions: config.instructions,
scenarioHints: run.scenarioHints,
globalContext: run.hints.globalContext,
logger: this.logger,
};
const mocks = (async (): Promise<CalledAgentMocks> => ({
mcpFetch: createMcpMockFetch({
...mockContext,
servers: mcpServers.map(({ name, url, description }) => ({ name, url, description })),
knownToolsByServer: await resolveCanonicalMcpCatalogs(
mcpServers,
this.moduleRegistry,
this.logger,
),
onToolCall: (call) =>
record({
url:
mcpServers.find((server) => server.name === call.serverName)?.url ?? call.serverName,
method: 'POST',
nodeType: `mcp:${call.serverName}`,
requestBody: call.args,
mockResponse: call.result,
}),
}),
webSearch: createWebSearchMock({
...mockContext,
onSearch: (args, result) =>
record({
url: 'mock://web-search',
method: 'POST',
nodeType: 'web-search:fallback',
requestBody: args,
mockResponse: result,
}),
}),
}))();
run.mocks.set(key, mocks);
return await mocks;
}
/** A node's entry, marked mocked: `checkNodeConfig` may have pre-created it as 'real'. */
private mockedEntry(
nodeResults: Record<string, InstanceAiEvalNodeResult>,
nodeName: string,
): InstanceAiEvalNodeResult {
const entry = (nodeResults[nodeName] ??= {
outputs: {},
outputCount: 0,
iterationCount: 0,
interceptedRequests: [],
executionMode: 'mocked',
});
entry.executionMode = 'mocked';
return entry;
}
/**
* Wraps the mock handler to collect intercepted request metadata for diagnostics.
* `attributeTo` records a called agent's tool requests under the calling node.
*/
private createInterceptingHandler(
mockHandler: EvalLlmMockHandler,
nodeResults: Record<string, InstanceAiEvalNodeResult>,
timings: EvalTimings,
attributeTo?: string,
): EvalLlmMockHandler {
return async (
requestOptions: IHttpRequestOptions,
node: INode,
): Promise<EvalMockHttpResponse | undefined> => {
// A node may make multiple HTTP requests — ensure it's marked as mocked.
// checkNodeConfig may have pre-created the entry as 'real', so always override.
const entry = (nodeResults[node.name] ??= {
outputs: {},
outputCount: 0,
iterationCount: 0,
interceptedRequests: [],
executionMode: 'mocked',
});
entry.executionMode = 'mocked';
const entry = this.mockedEntry(nodeResults, attributeTo ?? node.name);
let response = await timings.time(
'http-mock',
node.name,
attributeTo ?? node.name,
async () => await mockHandler(requestOptions, node),
);
@@ -51,10 +51,9 @@ import {
summarizeDynamicCredentialsUsage,
} from 'n8n-workflow';
import {
createWorkflowAgentStreamObserver,
type WorkflowAgentStreamObserver,
} from './modules/agents/workflow-agent-stream';
import type { PrepareWorkflowAgentForEval } from './modules/agents/agent-runtime-instrumentation';
import type { WorkflowAgentRunOptions } from './modules/agents/agent-workflow-execution.service';
import { createWorkflowAgentStreamObserver } from './modules/agents/workflow-agent-stream';
import { RuntimeCredentialProxyService } from './services/runtime-credential-proxy.service';
import { ActiveExecutions } from '@/active-executions';
@@ -412,6 +411,7 @@ export async function executeAgent(
outputSchema?: JSONSchema7,
workflowContext?: ExecuteAgentWorkflowContext,
invocationContext?: ExecuteAgentInvocationContext,
prepareForEval?: PrepareWorkflowAgentForEval,
): Promise<ExecuteAgentData> {
assertAgentsModuleActive();
@@ -446,14 +446,15 @@ export async function executeAgent(
invocation: invocationContext,
})
: undefined;
const streamObserverArguments: [] | [WorkflowAgentStreamObserver] = streamObserver
? [streamObserver]
: [];
const runOptions: [] | [WorkflowAgentRunOptions] =
streamObserver || prepareForEval ? [{ streamObserver, prepareForEval }] : [];
if (!additionalData.workflowId) {
throw new UnexpectedError('Cannot execute agent without a workflowId in additional data');
}
const scopedThreadId = `workflow:project-${projectId}:${threadId}`;
// Eval runs grade what was built, so they run the draft, as a manual run does.
const useDraftVersion = prepareForEval !== undefined || isManualOrChatExecution(executionMode);
if (source.inlineAgent) {
return await agentWorkflowExecutionService.executeInlineForWorkflow(
@@ -463,15 +464,14 @@ export async function executeAgent(
scopedThreadId,
projectId,
telemetryUserId,
isManualOrChatExecution(executionMode) ? 'test' : 'production',
useDraftVersion ? 'test' : 'production',
outputSchema,
workflowContext,
...streamObserverArguments,
...runOptions,
);
}
const { hashAgentSandboxPrincipal } = await import('@/modules/agents/agent-sandbox-principal.js');
const useDraftVersion = isManualOrChatExecution(executionMode);
const sandboxScope =
workflowContext?.hasCallerSessionId === true
? {
@@ -500,7 +500,7 @@ export async function executeAgent(
outputSchema,
workflowContext,
sandboxScope,
...streamObserverArguments,
...runOptions,
);
// Callers see the session id they supplied (or the derived per-call id), so