mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
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:
co-authored by
Claude Opus 5.5
parent
50c2ca0a27
commit
5bc6021477
@@ -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' });
|
||||
|
||||
+105
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user