mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
fix(ai-builder): Mock agent-triggered executions in eval threads (no-changelog)
The agent's own runs of seeded or built workflows inside an eval thread called real hosts and died on DNS. Route them through the eval mock layer: the thread's credential allowlist entry marks it as an eval thread, restore-thread carries the case's last prior-run hint per seed workflow, and the execution adapter attaches the mock on run, step run and single-node execution. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
07a741b595
commit
cb65fc0aa3
|
|
@ -600,9 +600,15 @@ nodes get LLM-generated pin data). So:
|
|||
static data** (`removeItemsSeenInPreviousExecutions`, `$getWorkflowStaticData`)
|
||||
is **not** seedable, because static data starts empty every run, so such a
|
||||
scenario reds vacuously (it sees everything as "new"). To get a seedable
|
||||
change-detection scenario, steer the build toward a Data Table; otherwise
|
||||
accept the static-data red as a harness limit and carry the logic in
|
||||
change-detection scenario, say "keep the state in a data table" in the prompt;
|
||||
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.
|
||||
- **The agent's own runs are mocked too.** When the agent runs a seeded or built
|
||||
workflow itself inside the eval thread, its HTTP goes through the same mock
|
||||
layer; a seeded workflow reuses the case's last `seed.priorRuns` hint for it.
|
||||
Internal faults are not steered by hints, so a seeded fault in a Code, Set, IF
|
||||
or Merge node must really fail in n8n (see
|
||||
[`case-shapes.md`](case-shapes.md#seeded-faults-must-really-fail)).
|
||||
- Don't assert exact counts that depend on mock generation ("exactly 7 posts").
|
||||
Say "fewer than the original 10".
|
||||
|
||||
|
|
|
|||
|
|
@ -662,6 +662,29 @@ really had one — and the agent has to guess from prose that deliberately names
|
|||
nothing. It will list workflows and pick, or ask which one, and you will score a
|
||||
clarification failure the real user never hit.
|
||||
|
||||
#### Seeded faults must really fail
|
||||
|
||||
A debug case stages a failure in the seed. Two layers are involved, and only one
|
||||
of them listens to `seed.priorRuns[].hints`:
|
||||
|
||||
- **Hints steer outbound mocks only.** The staged prior run and the agent's own
|
||||
rerun of the workflow both go through the HTTP mock, and the last hint for the
|
||||
workflow steers both, so "the API returns 500" reproduces on the rerun. Code,
|
||||
Set, IF, Filter and Merge run for real; a hint cannot make them fail.
|
||||
- **An internal fault must really fail in n8n.** A Code node that returns a plain
|
||||
object does not fail: n8n wraps it as one item and the run continues. Real Code
|
||||
failures: return an array of plain values, reference a node that does not exist
|
||||
(`$('Missing')`), or throw. Check the node's own validation before you rely on
|
||||
it.
|
||||
- **An empty branch needs a Filter that keeps nothing.** A Split Out over an empty
|
||||
array still emits a placeholder item, so "Merge waits for an empty branch" never
|
||||
arms that way.
|
||||
- **Walk the item flow before you assert counts.** A Slack node fed three items
|
||||
posts three times; "exactly one message" then fails the faithful fix. Add an
|
||||
Aggregate step to the seed or assert per-item behaviour.
|
||||
- **Put the seeded fault early.** External calls before it are mocked on the
|
||||
agent's run, but each one is generated data the case does not control.
|
||||
|
||||
#### Before you ship a seeded case — three checks
|
||||
|
||||
1. **The defect still bites.** A seed whose workflow isn't broken any more makes the
|
||||
|
|
|
|||
|
|
@ -2738,6 +2738,8 @@ const instanceAiEvalSeedWorkflowSchema = z.object({
|
|||
/** The seed folder (`folders[].id`) the workflow is created in. Omit for the
|
||||
* project root. Must name a declared folder; see `findSeedFolderIssues`. */
|
||||
parentFolderId: instanceAiEvalSeedArtifactIdSchema.optional(),
|
||||
/** Steers the mock layer when the agent itself runs this workflow in the eval thread. */
|
||||
executionMockHints: z.string().min(1).max(2000).optional(),
|
||||
});
|
||||
|
||||
export type InstanceAiEvalSeedWorkflow = z.infer<typeof instanceAiEvalSeedWorkflowSchema>;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,23 @@ split them out of the old `runner.ts` monolith):
|
|||
- `harness/cleanup.ts` — `cleanupBuild`, per-case timeout policy, bounded
|
||||
concurrency, binary workflow checks and shared failure summaries.
|
||||
|
||||
## Execution and mocking paths
|
||||
|
||||
Three paths execute a workflow during a case. All three end in the same
|
||||
LLM mock (`packages/cli/src/modules/instance-ai/eval/mock-handler.ts`):
|
||||
|
||||
```
|
||||
scenario execution harness → POST eval/execute-with-llm-mock (dataSetup as hints,
|
||||
Phase-1 hint pass, pinned triggers/AI roots)
|
||||
prior run (seed) harness → POST eval/execute-with-llm-mock (priorRuns[].hints)
|
||||
agent-triggered run agent → executions[run|step] / execute-node
|
||||
→ adapter sees the thread's eval allowlist entry
|
||||
→ configureAdditionalData attaches the mock
|
||||
(seed workflow: its last priorRuns hint, carried on
|
||||
restore-thread as workflows[].executionMockHints;
|
||||
otherwise generic realistic responses)
|
||||
```
|
||||
|
||||
## Where to add things
|
||||
|
||||
| You want to… | Touch exactly |
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ Each run:
|
|||
- **Mocked nodes** — anything that makes HTTP requests (Gmail, Slack, Google Sheets, HTTP Request, Notion…). The request is intercepted before it leaves the process; an LLM generates the response.
|
||||
- **Pinned nodes** — nodes that don't go through the HTTP layer (triggers/webhooks, LangChain/AI nodes, database nodes). They receive LLM-generated pin data.
|
||||
- **Real nodes** — logic nodes (Code, Set, Merge, Filter, IF, Switch) execute on the mocked data.
|
||||
- **Agent-triggered runs** — when the agent runs, step-runs or node-tests a workflow inside an eval thread, its HTTP goes through the same mock layer. A seeded workflow reuses the case's last `seed.priorRuns` hint for it; anything else gets the mock's generic realistic responses. The thread's credential allowlist entry is what marks it as an eval thread.
|
||||
|
||||
~95% of node types are covered. See [Known limitations](#known-limitations) for the gaps.
|
||||
|
||||
|
|
@ -859,6 +860,10 @@ requires the agent to read it.
|
|||
- **A run that never happens is not a staged failure.** If no execution record lands, the
|
||||
case is reported as a framework issue rather than scored — it would otherwise be graded
|
||||
against history the instance does not have.
|
||||
- **The agent's own rerun sees the same failure.** The last `hints` for a workflow also
|
||||
steer the mock when the agent runs that workflow itself during the live turn, so "run it
|
||||
again" reproduces the staged state instead of a fresh success. Internal faults (Code,
|
||||
Set, IF, Merge) are not steered by hints — they must really fail in n8n.
|
||||
- Runs execute sequentially in declared order, before the live turn, on a 120s budget —
|
||||
tighter than a scenario execution, which gets the case's build budget (900s by default).
|
||||
|
||||
|
|
@ -1216,6 +1221,7 @@ packages/cli/src/modules/instance-ai/eval/
|
|||
## Known limitations
|
||||
|
||||
- **LangChain/AI nodes** — use their own SDKs, not the HTTP mock layer. They fail with credential errors; use pin data instead.
|
||||
- **Agent-triggered runs** — mocked at the HTTP layer only. AI sub-nodes on those runs still call their vendor SDK with the placeholder credential, and no Phase-1 consistency pass runs, so data may not line up across nodes the way it does in a scenario execution.
|
||||
- **Binary / file nodes** — minimal-valid synthetic fixtures (PDF, PNG, JPEG, OGG/Opus, WAV, MP3, MP4, ZIP, plaintext) are generated per content type and round-trip correctly through `prepareBinaryData`. Image-content correctness and OOXML formats (docx/xlsx — currently mime-sniffed as `application/zip`) remain out of scope. See [Binary / file scenarios](#binary--file-scenarios) for the synthesis path.
|
||||
- **Streaming nodes** — mocks return complete responses, not streams.
|
||||
- **GraphQL APIs** — response shape depends on the query, not just the endpoint. Quality depends on the LLM knowing the API schema.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { mock } from 'vitest-mock-extended';
|
|||
import type { N8nClient } from '../clients/n8n-client';
|
||||
import { remapSeedArtifactIds, type ConversationSeed } from '../harness/conversation-seed';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import { executePriorRuns, STAGING_TIMEOUT_MS } from '../harness/prior-runs';
|
||||
import {
|
||||
executePriorRuns,
|
||||
executionMockHintsByWorkflow,
|
||||
STAGING_TIMEOUT_MS,
|
||||
} from '../harness/prior-runs';
|
||||
import { EvalTestCaseSchema } from '../harness/schema';
|
||||
import { MAX_EXEC_ATTEMPTS } from '../harness/transient-error';
|
||||
|
||||
|
|
@ -369,6 +373,30 @@ describe('executePriorRuns', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('executionMockHintsByWorkflow', () => {
|
||||
it('keys the last declared hint by the restored workflow id', () => {
|
||||
const hints = executionMockHintsByWorkflow(
|
||||
[
|
||||
{ workflow: 'dS8xQ2mV6bTn4Kp1', hints: 'the run succeeds' },
|
||||
{ workflow: 'dS8xQ2mV6bTn4Kp1', hints: 'the HTTP node returns 500' },
|
||||
],
|
||||
seeded,
|
||||
);
|
||||
|
||||
expect(hints).toEqual(new Map([['wf-1', 'the HTTP node returns 500']]));
|
||||
});
|
||||
|
||||
it('skips runs without hints and unknown workflows, and tolerates no prior runs', () => {
|
||||
expect(
|
||||
executionMockHintsByWorkflow(
|
||||
[{ workflow: 'dS8xQ2mV6bTn4Kp1' }, { workflow: 'nOtDeClArEd12345', hints: 'x' }],
|
||||
seeded,
|
||||
),
|
||||
).toEqual(new Map());
|
||||
expect(executionMockHintsByWorkflow(undefined, seeded)).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('priorRuns schema validation', () => {
|
||||
function caseWith(priorRuns: Array<{ workflow: string; hints?: string }>) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import {
|
|||
import { loadProviderFixtures } from './fixture-server';
|
||||
import { reconstructSeedFromThread } from './langsmith-seed';
|
||||
import type { EvalLogger } from './logger';
|
||||
import { executePriorRuns } from './prior-runs';
|
||||
import { executePriorRuns, executionMockHintsByWorkflow } from './prior-runs';
|
||||
import { redactSecretsInTextDeep } from './redact';
|
||||
import type { CaseSeed } from './schema';
|
||||
import {
|
||||
|
|
@ -836,11 +836,21 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
|
|||
remapped.dataTables.length > 0 ||
|
||||
remapped.agents.length > 0 ||
|
||||
remapped.folders.length > 0;
|
||||
// The agent's own run of a seeded workflow is mocked with the case's last
|
||||
// prior-run hint for it, so a rerun reproduces the staged failure.
|
||||
const mockHints = executionMockHintsByWorkflow(
|
||||
config.seed?.mode === 'inline' ? config.seed.priorRuns : undefined,
|
||||
seedWorkflowsBySeedId,
|
||||
);
|
||||
const workflowsToRestore = remapped.workflows.map((workflow) => {
|
||||
const executionMockHints = mockHints.get(workflow.id);
|
||||
return executionMockHints ? { ...workflow, executionMockHints } : workflow;
|
||||
});
|
||||
const restoreResult = hasThreadScopedSeed
|
||||
? await client.restoreThread(
|
||||
threadId,
|
||||
remapped.messages,
|
||||
remapped.workflows,
|
||||
workflowsToRestore,
|
||||
remapped.dataTables,
|
||||
remapped.agents,
|
||||
{ folders: remapped.folders },
|
||||
|
|
|
|||
|
|
@ -49,6 +49,22 @@ export interface PriorRunsOptions {
|
|||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last declared hint per seeded workflow, keyed by the id the restore creates, so
|
||||
* the agent's own run of the workflow is mocked the way the staged run was.
|
||||
*/
|
||||
export function executionMockHintsByWorkflow(
|
||||
priorRuns: SeedPriorRun[] | undefined,
|
||||
seedWorkflows: Map<string, { id: string; name: string }>,
|
||||
): Map<string, string> {
|
||||
const hints = new Map<string, string>();
|
||||
for (const priorRun of priorRuns ?? []) {
|
||||
const restored = seedWorkflows.get(priorRun.workflow);
|
||||
if (restored && priorRun.hints) hints.set(restored.id, priorRun.hints);
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run seeded workflows BEFORE the graded turn, so the instance carries real execution
|
||||
* history the agent can look up.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ vi.mock('@n8n/ai-utilities', () => ({
|
|||
searxngSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../eval/thread-run-mock', () => ({
|
||||
createEvalThreadRunConfigurer: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
import type { PolicyCleared } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import { generateWorkflowCode, parseWorkflowCode } from '@n8n/workflow-sdk';
|
||||
|
|
@ -86,6 +90,8 @@ import type { NodeCatalogService } from '@/node-catalog';
|
|||
import type { NodeTypes } from '@/node-types';
|
||||
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
|
||||
import { EvalThreadCredentialAllowlistService } from '../eval/thread-credential-allowlist.service';
|
||||
import { createEvalThreadRunConfigurer } from '../eval/thread-run-mock';
|
||||
import { InstanceAiMcpRegistryService } from '../mcp';
|
||||
import type { InstanceContextService } from '../instance-context.service';
|
||||
|
||||
|
|
@ -4552,6 +4558,7 @@ function createRunAdapterForTests(
|
|||
threadId?: string;
|
||||
queueMode?: boolean;
|
||||
allowSendingParameterValues?: boolean;
|
||||
evalThreadAllowlists?: EvalThreadCredentialAllowlistService;
|
||||
},
|
||||
) {
|
||||
const mockWorkflowFinderService = {
|
||||
|
|
@ -4640,6 +4647,15 @@ function createRunAdapterForTests(
|
|||
createMockPolicyEnforcementService() as unknown as ConstructorParameters<
|
||||
typeof InstanceAiAdapterService
|
||||
>[35],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
options?.evalThreadAllowlists,
|
||||
);
|
||||
|
||||
const adapter = service.createContext(mockUser, { threadId: options?.threadId }).executionService;
|
||||
|
|
@ -4688,6 +4704,69 @@ describe('createExecutionAdapter run()', () => {
|
|||
expect(live).not.toHaveProperty('injectedTriggerNodeName');
|
||||
});
|
||||
|
||||
it('routes an eval thread run through the mock layer with the seeded workflow hints', async () => {
|
||||
const evalThreadAllowlists = new EvalThreadCredentialAllowlistService();
|
||||
evalThreadAllowlists.set('thread-eval', []);
|
||||
evalThreadAllowlists.setExecutionMockHints('thread-eval', {
|
||||
'wf-1': 'the HTTP node returns 500',
|
||||
});
|
||||
const { adapter, mockWorkflowRunner } = createRunAdapterForTests(
|
||||
{ id: 'wf-1', nodes: [], connections: {} },
|
||||
{
|
||||
threadId: 'thread-eval',
|
||||
evalThreadAllowlists,
|
||||
execution: makeExecution({ status: 'success' }),
|
||||
},
|
||||
);
|
||||
|
||||
await adapter.run('wf-1');
|
||||
|
||||
expect(createEvalThreadRunConfigurer).toHaveBeenCalledWith(
|
||||
'the HTTP node returns 500',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockWorkflowRunner.run.mock.calls[0][0].configureAdditionalData).toBe(
|
||||
vi.mocked(createEvalThreadRunConfigurer).mock.results[0].value,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a run outside an eval thread unmocked', async () => {
|
||||
const evalThreadAllowlists = new EvalThreadCredentialAllowlistService();
|
||||
evalThreadAllowlists.set('thread-eval', []);
|
||||
const { adapter, mockWorkflowRunner } = createRunAdapterForTests(
|
||||
{ id: 'wf-1', nodes: [], connections: {} },
|
||||
{
|
||||
threadId: 'thread-prod',
|
||||
evalThreadAllowlists,
|
||||
execution: makeExecution({ status: 'success' }),
|
||||
},
|
||||
);
|
||||
|
||||
await adapter.run('wf-1');
|
||||
|
||||
expect(createEvalThreadRunConfigurer).not.toHaveBeenCalled();
|
||||
expect(mockWorkflowRunner.run.mock.calls[0][0]).not.toHaveProperty('configureAdditionalData');
|
||||
});
|
||||
|
||||
it('skips the mock in queue mode, where the hook cannot reach the worker', async () => {
|
||||
const evalThreadAllowlists = new EvalThreadCredentialAllowlistService();
|
||||
evalThreadAllowlists.set('thread-eval', []);
|
||||
const { adapter, mockWorkflowRunner } = createRunAdapterForTests(
|
||||
{ id: 'wf-1', nodes: [], connections: {} },
|
||||
{
|
||||
threadId: 'thread-eval',
|
||||
evalThreadAllowlists,
|
||||
queueMode: true,
|
||||
execution: makeExecution({ status: 'success' }),
|
||||
},
|
||||
);
|
||||
|
||||
await adapter.run('wf-1');
|
||||
|
||||
expect(createEvalThreadRunConfigurer).not.toHaveBeenCalled();
|
||||
expect(mockWorkflowRunner.run.mock.calls[0][0]).not.toHaveProperty('configureAdditionalData');
|
||||
});
|
||||
|
||||
it('reports workflow-pinned nodes on the run result', async () => {
|
||||
const { adapter } = createRunAdapterForTests(
|
||||
{
|
||||
|
|
@ -5427,6 +5506,22 @@ describe('createExecutionAdapter runStep()', () => {
|
|||
return { ...harness, result, runData: harness.mockWorkflowRunner.run.mock.calls[0]?.[0] };
|
||||
}
|
||||
|
||||
it('routes an eval thread step run through the mock layer', async () => {
|
||||
const evalThreadAllowlists = new EvalThreadCredentialAllowlistService();
|
||||
evalThreadAllowlists.set('thread-eval', []);
|
||||
|
||||
const { runData } = await runStepOn(chainWorkflow, 'Send', undefined, {
|
||||
threadId: 'thread-eval',
|
||||
evalThreadAllowlists,
|
||||
});
|
||||
|
||||
// No hints for a workflow the case never staged: the mock runs in its generic mode.
|
||||
expect(createEvalThreadRunConfigurer).toHaveBeenCalledWith(undefined, expect.anything());
|
||||
expect(runData.configureAdditionalData).toBe(
|
||||
vi.mocked(createEvalThreadRunConfigurer).mock.results[0].value,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the chain up to the target when given no input', async () => {
|
||||
const { runData, result } = await runStepOn(chainWorkflow, 'Send');
|
||||
|
||||
|
|
|
|||
|
|
@ -1067,6 +1067,26 @@ describe('InstanceAiController', () => {
|
|||
expect(result).toMatchObject({ dataTableIds: ['dt-new'] });
|
||||
});
|
||||
|
||||
it('should keep the seed workflows execution mock hints on the thread', async () => {
|
||||
memoryService.checkThreadOwnership.mockResolvedValue('owned');
|
||||
memoryService.getThreadProjectId.mockResolvedValue('project-1');
|
||||
memoryService.restoreThreadMessages.mockResolvedValue({ restored: 1 });
|
||||
evalThreadRestore.restoreDataTables.mockResolvedValue(new Map());
|
||||
|
||||
await controller.restoreEvalThread(req, res, {
|
||||
...payload,
|
||||
workflows: [
|
||||
{ ...seedWorkflow, executionMockHints: 'the HTTP node returns 500' },
|
||||
{ ...seedWorkflow, id: 'wf-2' },
|
||||
],
|
||||
} as InstanceAiEvalRestoreThreadRequest);
|
||||
|
||||
expect(evalCredentialAllowlists.getExecutionMockHints(THREAD_ID, 'wf-1')).toBe(
|
||||
'the HTTP node returns 500',
|
||||
);
|
||||
expect(evalCredentialAllowlists.getExecutionMockHints(THREAD_ID, 'wf-2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should hand the pinned credential allowlist to the workflow restore', async () => {
|
||||
memoryService.checkThreadOwnership.mockResolvedValue('owned');
|
||||
memoryService.getThreadProjectId.mockResolvedValue('project-1');
|
||||
|
|
|
|||
|
|
@ -54,6 +54,17 @@ 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.
|
||||
|
||||
## Agent-triggered runs in eval threads
|
||||
|
||||
The allowlist entry also marks the thread as an eval thread. When the agent
|
||||
runs, step-runs or node-tests a workflow from such a thread, the execution
|
||||
adapter attaches `evalLlmMockHandler` and the `EvalMockedCredentialsHelper`
|
||||
through `configureAdditionalData` (`thread-run-mock.ts`), so HTTP nodes are
|
||||
mocked the way a scenario execution mocks them. A seeded workflow's hints
|
||||
arrive on `restore-thread` as `workflows[].executionMockHints` and are kept per
|
||||
thread and workflow id. Two deliberate gaps: no Phase-1 hint pass runs, and AI
|
||||
roots are not routed to the wire server.
|
||||
|
||||
## Agent model catalogs
|
||||
|
||||
The eval credential allowlist also marks Agent Builder sessions as evaluations.
|
||||
|
|
|
|||
|
|
@ -54,4 +54,31 @@ describe('EvalThreadCredentialAllowlistService', () => {
|
|||
expect(service.shouldBypassTest('thread-1', 'cred-a')).toBe(false);
|
||||
expect(service.get('thread-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
// An entry is what marks a thread as an eval thread; an empty allowlist still counts.
|
||||
it('marks a thread as eval once an allowlist is set, even an empty one', () => {
|
||||
service.set('thread-1', []);
|
||||
|
||||
expect(service.isEvalThread('thread-1')).toBe(true);
|
||||
expect(service.isEvalThread('thread-2')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps execution mock hints per workflow and merges across restores', () => {
|
||||
service.setExecutionMockHints('thread-1', { 'wf-1': 'the HTTP node returns 500' });
|
||||
service.setExecutionMockHints('thread-1', { 'wf-2': 'the API returns no rows' });
|
||||
|
||||
expect(service.getExecutionMockHints('thread-1', 'wf-1')).toBe('the HTTP node returns 500');
|
||||
expect(service.getExecutionMockHints('thread-1', 'wf-2')).toBe('the API returns no rows');
|
||||
expect(service.getExecutionMockHints('thread-1', 'wf-3')).toBeUndefined();
|
||||
expect(service.getExecutionMockHints('thread-2', 'wf-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forgets the execution mock hints when the thread is cleared', () => {
|
||||
service.set('thread-1', []);
|
||||
service.setExecutionMockHints('thread-1', { 'wf-1': 'the HTTP node returns 500' });
|
||||
service.clearThread('thread-1');
|
||||
|
||||
expect(service.isEvalThread('thread-1')).toBe(false);
|
||||
expect(service.getExecutionMockHints('thread-1', 'wf-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { ICredentialsHelper, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { EvalMockedCredentialsHelper } from '../eval-mocked-credentials-helper';
|
||||
import { createLlmMockHandler } from '../mock-handler';
|
||||
import { createEvalThreadRunConfigurer } from '../thread-run-mock';
|
||||
|
||||
vi.mock('../mock-handler', () => ({
|
||||
createLlmMockHandler: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
describe('createEvalThreadRunConfigurer', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createLlmMockHandler).mockClear();
|
||||
});
|
||||
|
||||
it('routes the run through the HTTP mock steered by the given hints', async () => {
|
||||
const additionalData = {
|
||||
credentialsHelper: mock<ICredentialsHelper>(),
|
||||
} as unknown as IWorkflowExecuteAdditionalData;
|
||||
|
||||
await createEvalThreadRunConfigurer(
|
||||
'the HTTP node returns 500',
|
||||
mock<Logger>(),
|
||||
)(additionalData);
|
||||
|
||||
expect(createLlmMockHandler).toHaveBeenCalledWith({
|
||||
scenarioHints: 'the HTTP node returns 500',
|
||||
});
|
||||
expect(additionalData.evalLlmMockHandler).toBe(
|
||||
vi.mocked(createLlmMockHandler).mock.results[0].value,
|
||||
);
|
||||
});
|
||||
|
||||
it('synthesizes missing credentials the way a scenario execution does', async () => {
|
||||
const additionalData = {
|
||||
credentialsHelper: mock<ICredentialsHelper>(),
|
||||
} as unknown as IWorkflowExecuteAdditionalData;
|
||||
|
||||
await createEvalThreadRunConfigurer(undefined, mock<Logger>())(additionalData);
|
||||
|
||||
expect(createLlmMockHandler).toHaveBeenCalledWith({ scenarioHints: undefined });
|
||||
expect(additionalData.credentialsHelper).toBeInstanceOf(EvalMockedCredentialsHelper);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,8 @@ import { Service } from '@n8n/di';
|
|||
* Per-thread credential visibility for evaluation runs. The eval harness
|
||||
* declares which credentials a build thread may see; the builder context's
|
||||
* credential `list()` is filtered to that set. Threads without an entry see
|
||||
* the unfiltered instance listing.
|
||||
* the unfiltered instance listing. An entry also marks the thread as an eval
|
||||
* thread, so the agent's own workflow runs go through the mock layer.
|
||||
*/
|
||||
@Service()
|
||||
export class EvalThreadCredentialAllowlistService {
|
||||
|
|
@ -13,6 +14,8 @@ export class EvalThreadCredentialAllowlistService {
|
|||
* without contacting the provider — see `bypassCredentialTest` on
|
||||
* `InstanceAiEvalCredentialAllowlistRequest`. */
|
||||
private readonly testBypassByThread = new Map<string, string[]>();
|
||||
/** Mock hints for the agent's own runs of seeded workflows, by workflow id. */
|
||||
private readonly executionMockHintsByThread = new Map<string, Map<string, string>>();
|
||||
|
||||
set(threadId: string, credentialIds: string[], bypassCredentialTest?: string[]): void {
|
||||
this.byThread.set(threadId, [...credentialIds]);
|
||||
|
|
@ -30,6 +33,10 @@ export class EvalThreadCredentialAllowlistService {
|
|||
return this.byThread.get(threadId);
|
||||
}
|
||||
|
||||
isEvalThread(threadId: string): boolean {
|
||||
return this.byThread.has(threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this thread should treat `credentialId`'s connection test as passing.
|
||||
* Queried per test rather than snapshotted into the credential adapter: the
|
||||
|
|
@ -41,8 +48,21 @@ export class EvalThreadCredentialAllowlistService {
|
|||
return this.testBypassByThread.get(threadId)?.includes(credentialId) ?? false;
|
||||
}
|
||||
|
||||
setExecutionMockHints(threadId: string, hintsByWorkflowId: Record<string, string>): void {
|
||||
const hints = this.executionMockHintsByThread.get(threadId) ?? new Map<string, string>();
|
||||
for (const [workflowId, hint] of Object.entries(hintsByWorkflowId)) {
|
||||
hints.set(workflowId, hint);
|
||||
}
|
||||
this.executionMockHintsByThread.set(threadId, hints);
|
||||
}
|
||||
|
||||
getExecutionMockHints(threadId: string, workflowId: string): string | undefined {
|
||||
return this.executionMockHintsByThread.get(threadId)?.get(workflowId);
|
||||
}
|
||||
|
||||
clearThread(threadId: string): void {
|
||||
this.byThread.delete(threadId);
|
||||
this.testBypassByThread.delete(threadId);
|
||||
this.executionMockHintsByThread.delete(threadId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
packages/cli/src/modules/instance-ai/eval/thread-run-mock.ts
Normal file
30
packages/cli/src/modules/instance-ai/eval/thread-run-mock.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
||||
|
||||
import { EvalMockedCredentialsHelper } from './eval-mocked-credentials-helper';
|
||||
import { createLlmMockHandler } from './mock-handler';
|
||||
|
||||
type ConfigureAdditionalData = NonNullable<
|
||||
IWorkflowExecutionDataProcess['configureAdditionalData']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Mocks a run the agent starts itself inside an eval thread the way a scenario
|
||||
* execution is mocked: HTTP goes to the LLM mock, missing credentials are
|
||||
* synthesized. `hints` come from the case's prior runs of the workflow.
|
||||
*/
|
||||
export function createEvalThreadRunConfigurer(
|
||||
hints: string | undefined,
|
||||
logger: Logger,
|
||||
): ConfigureAdditionalData {
|
||||
// ponytail: no Phase-1 hint pass, so cross-node consistency rests on `hints`; add it if agent reruns drift.
|
||||
const mockHandler = createLlmMockHandler({ scenarioHints: hints });
|
||||
return (additionalData) => {
|
||||
additionalData.credentialsHelper = new EvalMockedCredentialsHelper(
|
||||
additionalData.credentialsHelper,
|
||||
undefined,
|
||||
logger,
|
||||
);
|
||||
additionalData.evalLlmMockHandler = mockHandler;
|
||||
};
|
||||
}
|
||||
|
|
@ -187,6 +187,8 @@ import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-hi
|
|||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
||||
|
||||
import { EvalThreadCredentialAllowlistService } from './eval/thread-credential-allowlist.service';
|
||||
import { createEvalThreadRunConfigurer } from './eval/thread-run-mock';
|
||||
import { extractResolvedNodeParameters } from './extract-resolved-node-parameters';
|
||||
import { waitForInstanceAiExecution } from './instance-ai-execution-wait';
|
||||
import {
|
||||
|
|
@ -410,6 +412,7 @@ export class InstanceAiAdapterService {
|
|||
private readonly folderFinderService?: FolderFinderService,
|
||||
private readonly instanceContext?: InstanceContextService,
|
||||
private readonly executeNodeService?: ExecuteNodeService,
|
||||
private readonly evalThreadAllowlists?: EvalThreadCredentialAllowlistService,
|
||||
) {
|
||||
this.logger = logger.scoped('instance-ai');
|
||||
this.allowSendingParameterValues = globalConfig.ai.allowSendingParameterValues;
|
||||
|
|
@ -507,7 +510,7 @@ export class InstanceAiAdapterService {
|
|||
: {}),
|
||||
mcpService: mcpConnectionsAvailable ? this.createMcpAdapter(user) : undefined,
|
||||
executeNodeService: this.executeNodeService
|
||||
? this.createExecuteNodeAdapter(this.executeNodeService, user, projectId)
|
||||
? this.createExecuteNodeAdapter(this.executeNodeService, user, projectId, threadId)
|
||||
: undefined,
|
||||
conversationHistoryService: conversationHistory,
|
||||
// The tool and context block use the same instance gate result.
|
||||
|
|
@ -713,12 +716,14 @@ export class InstanceAiAdapterService {
|
|||
executeNodeService: ExecuteNodeService,
|
||||
user: User,
|
||||
boundProjectId?: string,
|
||||
threadId?: string,
|
||||
): InstanceAiExecuteNodeService {
|
||||
const { resolveProjectId } = this.createProjectScopeHelpers(user, boundProjectId);
|
||||
return {
|
||||
execute: async (request) => {
|
||||
this.assertInstanceNotReadOnly('executions');
|
||||
const projectId = await resolveProjectId(['workflow:execute']);
|
||||
const configureAdditionalData = this.evalRunConfigurer(threadId);
|
||||
const result = await executeNodeService.run(user, {
|
||||
type: request.type,
|
||||
version: request.version,
|
||||
|
|
@ -729,12 +734,31 @@ export class InstanceAiAdapterService {
|
|||
input: request.input as Array<{ json: IDataObject }> | undefined,
|
||||
timeoutMs: request.timeoutMs,
|
||||
projectId,
|
||||
...(configureAdditionalData ? { configureAdditionalData } : {}),
|
||||
});
|
||||
return redactExecuteNodeResult(result, this.allowSendingParameterValues);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eval threads run seeded and built workflows against the mock layer, like
|
||||
* scenario executions do. Production threads never have an allowlist entry.
|
||||
*/
|
||||
private evalRunConfigurer(
|
||||
threadId: string | undefined,
|
||||
workflowId?: string,
|
||||
): IWorkflowExecutionDataProcess['configureAdditionalData'] {
|
||||
if (!threadId || !this.evalThreadAllowlists?.isEvalThread(threadId)) return undefined;
|
||||
// The closure does not survive queue serialization (see EvalExecutionService).
|
||||
if (this.globalConfig.executions?.mode === 'queue') return undefined;
|
||||
const hints =
|
||||
workflowId === undefined
|
||||
? undefined
|
||||
: this.evalThreadAllowlists.getExecutionMockHints(threadId, workflowId);
|
||||
return createEvalThreadRunConfigurer(hints, this.logger);
|
||||
}
|
||||
|
||||
/** Reads the connection rows rather than resolving them into loadable servers:
|
||||
* resolving decrypts credentials per connection, and a row that fails to resolve
|
||||
* is still connected (one connection per user+slug). */
|
||||
|
|
@ -1810,6 +1834,7 @@ export class InstanceAiAdapterService {
|
|||
globalConfig,
|
||||
} = this;
|
||||
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('executions');
|
||||
const evalRunConfigurer = (workflowId: string) => this.evalRunConfigurer(threadId, workflowId);
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5 * Time.minutes.toMilliseconds;
|
||||
const MAX_TIMEOUT_MS = 10 * Time.minutes.toMilliseconds;
|
||||
|
|
@ -1950,6 +1975,8 @@ export class InstanceAiAdapterService {
|
|||
// it is dispatched by the execution lifecycle, not by a node.
|
||||
...(options?.isVerificationRun ? { suppressErrorWorkflow: true } : {}),
|
||||
};
|
||||
const configureAdditionalData = evalRunConfigurer(workflowId);
|
||||
if (configureAdditionalData) runData.configureAdditionalData = configureAdditionalData;
|
||||
|
||||
const pinDataPlan = buildInstanceAiRunPinDataPlan({
|
||||
workflowPinData: workflow.pinData ?? {},
|
||||
|
|
@ -2202,6 +2229,8 @@ export class InstanceAiAdapterService {
|
|||
dirtyNodeNames: plan.dirtyNodeNames,
|
||||
source: 'instance_ai',
|
||||
};
|
||||
const configureAdditionalData = evalRunConfigurer(workflowId);
|
||||
if (configureAdditionalData) runData.configureAdditionalData = configureAdditionalData;
|
||||
|
||||
// A trigger has no upstream to run, so the chain and the partial paths
|
||||
// are the same thing. Naming it keeps the engine from auto-detecting a
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,16 @@ export class InstanceAiController {
|
|||
await this.evalThreadRestore.deleteFolders(folders, folderIdMap, projectId, req.user);
|
||||
throw error;
|
||||
}
|
||||
// After the rollback-able block, so a failed restore leaves no hints behind.
|
||||
const executionMockHints = Object.fromEntries(
|
||||
workflows.flatMap(
|
||||
(workflow): Array<[string, string]> =>
|
||||
workflow.executionMockHints ? [[workflow.id, workflow.executionMockHints]] : [],
|
||||
),
|
||||
);
|
||||
if (Object.keys(executionMockHints).length > 0) {
|
||||
this.evalCredentialAllowlists.setExecutionMockHints(payload.threadId, executionMockHints);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
threadId: payload.threadId,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
INodeType,
|
||||
IWorkflowExecutionDataProcess,
|
||||
} from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
|
|
@ -42,6 +43,8 @@ export interface ExecuteNodeRequest {
|
|||
input?: Array<{ json: IDataObject }>;
|
||||
timeoutMs?: number;
|
||||
projectId: string;
|
||||
/** Eval-only additionalData decoration (e.g. HTTP mock handler) — never set on production paths. */
|
||||
configureAdditionalData?: IWorkflowExecutionDataProcess['configureAdditionalData'];
|
||||
}
|
||||
|
||||
export interface ExecuteNodeOutputItem {
|
||||
|
|
@ -107,7 +110,14 @@ export class ExecuteNodeService {
|
|||
|
||||
const workflow = await this.createTemporaryWorkflow(request.projectId, node, timeoutMs);
|
||||
try {
|
||||
return await this.execute(user, workflow, node, inputItems, timeoutMs);
|
||||
return await this.execute(
|
||||
user,
|
||||
workflow,
|
||||
node,
|
||||
inputItems,
|
||||
timeoutMs,
|
||||
request.configureAdditionalData,
|
||||
);
|
||||
} catch (error) {
|
||||
return { status: 'error', error: this.toErrorResponse(error) };
|
||||
} finally {
|
||||
|
|
@ -237,6 +247,7 @@ export class ExecuteNodeService {
|
|||
node: INode,
|
||||
inputItems: INodeExecutionData[],
|
||||
timeoutMs: number,
|
||||
configureAdditionalData?: IWorkflowExecutionDataProcess['configureAdditionalData'],
|
||||
): Promise<ExecuteNodeResult> {
|
||||
const executionData = createRunExecutionData({
|
||||
startData: {},
|
||||
|
|
@ -257,6 +268,7 @@ export class ExecuteNodeService {
|
|||
workflowData,
|
||||
executionData,
|
||||
userId: user.id,
|
||||
...(configureAdditionalData ? { configureAdditionalData } : {}),
|
||||
});
|
||||
|
||||
const timedOut = await this.waitForCompletion(executionId, timeoutMs);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user