diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md
index 5396d475282..7aa9d9354fd 100644
--- a/.github/WORKFLOWS.md
+++ b/.github/WORKFLOWS.md
@@ -201,8 +201,7 @@ suite. Test cases are pulled at run time from the LangTracer suite
keep `--source disk` for authoring). To
re-run after pushing a fix, dispatch `ci-instance-ai-evals.yml` with the PR
number (optionally `tier: full` for broader coverage) — results post back to
-the PR. The lighter `test-evals-discovery.yml` still runs on every push as part
-of `ci-pull-requests.yml`.
+the PR.
**`ci-instance-ai-evals.yml` is the PR gate; `test-evals-instance-ai.yml` is
the lab bench.** The gate deliberately exposes only PR re-runs. Anything that
diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml
index d93bd7aa947..e6dc7f6fff7 100644
--- a/.github/workflows/ci-pull-requests.yml
+++ b/.github/workflows/ci-pull-requests.yml
@@ -176,7 +176,6 @@ jobs:
packages/@n8n/instance-ai/evaluations/**
packages/cli/src/modules/instance-ai/**
packages/core/src/execution-engine/eval-mock-helpers.ts
- .github/workflows/test-evals-discovery.yml
db:
packages/cli/src/databases/**
packages/cli/src/modules/*/database/**
@@ -510,25 +509,6 @@ jobs:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
- # In-process discovery eval — asserts the orchestrator reaches for browser/computer-use
- # tools at OAuth/screenshot moments. Lightweight (no Docker), runs in parallel with the
- # heavy workflow eval. Non-blocking initially; promote to required after stability.
- instance-ai-discovery-evals:
- name: Instance AI Discovery Evals
- needs: [install-and-build, format-check]
- if: >-
- !cancelled() &&
- needs.install-and-build.result == 'success' &&
- needs.format-check.result == 'success' &&
- needs.install-and-build.outputs.instance_ai_workflow_eval == 'true' &&
- github.repository == 'n8n-io/n8n' &&
- github.event_name != 'merge_group' &&
- (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
- uses: ./.github/workflows/test-evals-discovery.yml
- with:
- branch: ${{ needs.install-and-build.outputs.commit_sha }}
- secrets: inherit
-
# This job is required by GitHub branch protection rules.
# PRs cannot be merged unless this job passes.
required-checks:
diff --git a/.github/workflows/test-evals-discovery.yml b/.github/workflows/test-evals-discovery.yml
deleted file mode 100644
index 3c97253a232..00000000000
--- a/.github/workflows/test-evals-discovery.yml
+++ /dev/null
@@ -1,125 +0,0 @@
-name: 'Test: Instance AI Discovery Evals'
-run-name: "Test: Instance AI Discovery Evals (branch=${{ inputs.branch || 'master' }}, filter=${{ inputs.filter || 'all' }}, trials=${{ inputs.trials }})"
-
-on:
- workflow_call:
- inputs:
- branch:
- description: 'GitHub branch to test'
- required: false
- type: string
- default: 'master'
- filter:
- description: 'Filter scenarios by id (e.g. "slack-oauth")'
- required: false
- type: string
- default: ''
- trials:
- description: 'Trials per scenario'
- required: false
- type: number
- default: 3
- workflow_dispatch:
- inputs:
- branch:
- description: 'GitHub branch to test'
- required: false
- default: 'master'
- filter:
- description: 'Filter scenarios by id (e.g. "slack-oauth")'
- required: false
- default: ''
- trials:
- description: 'Trials per scenario'
- required: false
- default: '3'
-
-jobs:
- run-discovery-evals:
- name: 'Run Discovery Evals'
- runs-on: blacksmith-4vcpu-ubuntu-2204
- timeout-minutes: 15
- env:
- NODE_OPTIONS: '--max-old-space-size=7168'
- permissions:
- contents: read
- pull-requests: write
-
- steps:
- - name: Checkout
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
- with:
- ref: ${{ inputs.branch || github.ref }}
- fetch-depth: 1
-
- - name: Setup Environment
- uses: ./.github/actions/setup-nodejs
-
- - name: Export Node Types
- run: |
- ./packages/cli/bin/n8n export:nodes --output ./packages/@n8n/ai-workflow-builder.ee/evaluations/nodes.json
-
- - name: Run Discovery Evals
- id: eval
- working-directory: packages/@n8n/instance-ai
- env:
- ANTHROPIC_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
- N8N_ENABLED_MODULES: agents,instance-ai
- LANGSMITH_TRACING: 'true'
- LANGSMITH_ENDPOINT: ${{ secrets.EVALS_LANGSMITH_ENDPOINT }}
- LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
- LANGSMITH_REVISION_ID: ${{ github.sha }}
- LANGSMITH_BRANCH: ${{ github.event.pull_request.head.ref || github.head_ref || github.ref_name }}
- FILTER: ${{ inputs.filter }}
- TRIALS: ${{ inputs.trials || 3 }}
- run: |
- set -o pipefail
- EVAL_ARGS=(--trials "$TRIALS" --fail-on-zero-pass)
- if [ -n "$FILTER" ]; then
- EVAL_ARGS+=(--filter "$FILTER")
- fi
- pnpm eval:discovery "${EVAL_ARGS[@]}" 2>&1 | tee discovery-eval-output.txt
-
- - name: Post eval results to PR
- if: ${{ always() && github.event.pull_request.number && hashFiles('packages/@n8n/instance-ai/discovery-eval-output.txt') != '' }}
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- EVAL_OUTCOME: ${{ steps.eval.outcome }}
- HEAD_REF: ${{ github.event.pull_request.head.ref || github.head_ref || github.ref_name }}
- COMMIT_SHA: ${{ github.sha }}
- run: |
- if [ "$EVAL_OUTCOME" = "success" ]; then
- STATUS_ICON="✅"
- else
- STATUS_ICON="❌"
- fi
- {
- echo "### Instance AI Discovery Eval ${STATUS_ICON}"
- echo ""
- echo "Branch: \`${HEAD_REF}\` · Commit: \`${COMMIT_SHA}\`"
- echo ""
- echo "Eval output
"
- echo ""
- echo '```'
- cat packages/@n8n/instance-ai/discovery-eval-output.txt
- echo '```'
- echo ""
- echo " "
- } > /tmp/discovery-comment.md
-
- COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
- --jq '.[] | select(.body | startswith("### Instance AI Discovery Eval")) | .id' | tail -1)
-
- if [ -n "$COMMENT_ID" ]; then
- gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -X PATCH -F body=@/tmp/discovery-comment.md
- else
- gh pr comment "${{ github.event.pull_request.number }}" --body-file /tmp/discovery-comment.md
- fi
-
- - name: Upload Results
- if: ${{ always() }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- with:
- name: instance-ai-discovery-eval-results
- path: packages/@n8n/instance-ai/discovery-eval-output.txt
- retention-days: 14
diff --git a/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts b/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts
index 10f53beebbf..a0e50285304 100644
--- a/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts
+++ b/packages/@n8n/decorators/src/pubsub/pubsub-metadata.ts
@@ -34,7 +34,6 @@ export type PubSubEventName =
| 'resume-agent-workflow-tool'
| 'relay-chat-stream-event'
| 'relay-instance-ai-event'
- | 'relay-instance-ai-task-control'
| 'relay-chat-human-message'
| 'relay-chat-message-edit'
| 'reload-sso-provisioning-configuration'
diff --git a/packages/@n8n/instance-ai/evaluations/README.md b/packages/@n8n/instance-ai/evaluations/README.md
index 0a678f2a78f..df7997f76b8 100644
--- a/packages/@n8n/instance-ai/evaluations/README.md
+++ b/packages/@n8n/instance-ai/evaluations/README.md
@@ -9,7 +9,6 @@ Six harnesses live here:
- **`eval:instance-ai`** — end-to-end build + mocked execution + LLM verification (drives a running n8n instance)
- **`eval:agents`** — standalone Agent build cases from the LangTracer `agents` suite (author new ones in `data/agents/`)
- **`eval:subagent`** — legacy command name for the workflow-build compatibility corpus; it drives the live orchestrator/skill build path, scored by binary checks
-- **`eval:discovery`** — orchestrator in-process, scored against required or forbidden tool/dispatch events (no n8n server)
- **`eval:pairwise`** — live orchestrator workflow builds, scored by an LLM judge panel against do/don't lists. Intended for head-to-head comparison with `ai-workflow-builder.ee` on the same dataset
- **`eval:computer-use`** — grades the computer-use agent (file / OAuth / doc-reading tasks) against fixtures; see [`computer-use/README.md`](computer-use/README.md)
@@ -20,7 +19,6 @@ Sections:
- [Running e2e + workflow-build evals](#running-evals)
- [Regression detection](#regression-detection)
- [Running evals against pre-built workflows](#running-evals-against-pre-built-workflows)
-- [Running discovery evals](#discovery-evals)
- [Running pairwise evals](#pairwise-evals)
- [How the e2e harness works](#how-the-e2e-harness-works)
- [How the workflow-build harness works](#how-the-workflow-build-harness-works)
@@ -546,24 +544,6 @@ lanes flat-out at `lanes * 4` (each lane's per-lane build cap) starves it,
surfacing as verifier/MCP timeouts. Treat `lanes * 4` as an aggressive upper
bound for keys with rate-limit headroom, not the starting point.
-## Discovery evals
-
-Discovery evals run the orchestrator in-process and assert first-hop tool or
-background-agent routing from captured `tool-call`, `tool-result`, `tool-error`, and
-`agent-spawned` events. Use them when a regression is about which path the
-agent chooses, not whether a generated workflow executes.
-
-To inspect runtime skill loading, run a focused verbose pass:
-
-```bash
-pnpm eval:discovery --filter data-table-skill-loading --trials 3 --verbose --fail-on-zero-pass
-```
-
-Verbose output lists each trial's completed tool calls with argument previews.
-For data-table routing, look for `load_skill(skillId="data-table-manager")`
-and `data-tables(action="list")`, and verify there are no planning,
-workflow-builder, or spawned-agent entries in the spawned-agent section.
-
## Pairwise evals
Pairwise evals score a built workflow against the dataset's `dos` / `donts`
diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/data-discovery.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/data-discovery.test.ts
deleted file mode 100644
index bd443fefc42..00000000000
--- a/packages/@n8n/instance-ai/evaluations/__tests__/data-discovery.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * Smoke + shape tests for the discovery scenario loader.
- *
- * Loads every JSON in evaluations/data/discovery/ off disk, parses it, and
- * asserts the shape required by `runExpectedToolsInvokedCheck`. Catches
- * malformed scenario files at unit-test time before they reach the eval
- * runner.
- */
-
-import { discoveryTestCaseSchema, loadDiscoveryTestCasesWithFiles } from '../data/discovery';
-import { runExpectedToolsInvokedCheck } from '../discovery/expected-tools-invoked';
-
-describe('loadDiscoveryTestCasesWithFiles', () => {
- const cases = loadDiscoveryTestCasesWithFiles();
-
- it('finds at least the v1 scenario set', () => {
- const slugs = cases.map((c) => c.fileSlug).sort();
-
- expect(slugs).toEqual(
- expect.arrayContaining([
- 'slack-oauth-credential-setup',
- 'google-oauth-credential-setup',
- 'screenshot-dashboard',
- 'http-node-config-no-browser',
- 'oauth-with-computer-use-disabled',
- 'workflow-builder-no-credential-ask',
- 'folder-scoped-workflow-list',
- ]),
- );
- });
-
- it.each([
- 'slack-oauth-credential-setup',
- 'google-oauth-credential-setup',
- 'screenshot-dashboard',
- 'http-node-config-no-browser',
- 'oauth-with-computer-use-disabled',
- 'workflow-builder-no-credential-ask',
- 'folder-scoped-workflow-list',
- ])('%s parses with a valid expectedToolInvocations rule', (slug) => {
- const entry = cases.find((c) => c.fileSlug === slug);
- expect(entry).toBeDefined();
-
- const { testCase } = entry!;
-
- expect(testCase.id).toBe(slug);
- expect(testCase.userMessage.length).toBeGreaterThan(0);
-
- // Calling the check with an empty outcome must not throw on rule validation;
- // it should simply report pass/fail. This pins the JSON shape.
- expect(() =>
- runExpectedToolsInvokedCheck(testCase, {
- workflowIds: [],
- executionIds: [],
- dataTableIds: [],
- artifactRefs: [],
- finalText: '',
- toolCalls: [],
- agentActivities: [],
- }),
- ).not.toThrow();
- });
-
- it('keeps positive and negative scenarios in the set (so we test both directions)', () => {
- const positive = cases.filter((c) => Boolean(c.testCase.expectedToolInvocations.anyOf?.length));
- const negative = cases.filter((c) =>
- Boolean(c.testCase.expectedToolInvocations.noneOf?.length),
- );
-
- expect(positive.length).toBeGreaterThan(0);
- expect(negative.length).toBeGreaterThan(0);
- });
-});
-
-describe('discoveryTestCaseSchema', () => {
- const valid = {
- id: 'my-scenario',
- userMessage: 'do the thing',
- expectedToolInvocations: { anyOf: ['build-workflow'] },
- };
-
- it('accepts a minimal valid case', () => {
- expect(discoveryTestCaseSchema.safeParse(valid).success).toBe(true);
- });
-
- it('rejects a typo-d key instead of passing vacuously', () => {
- const typo = { ...valid, expectedToolInvocation: { anyOf: ['x'] } };
- expect(discoveryTestCaseSchema.safeParse(typo).success).toBe(false);
- });
-
- it('rejects empty expectations — a case must assert something', () => {
- const empty = { ...valid, expectedToolInvocations: {} };
- expect(discoveryTestCaseSchema.safeParse(empty).success).toBe(false);
- });
-
- it('rejects an empty expectation list — dead config must fail at load time', () => {
- const emptyList = { ...valid, expectedToolInvocations: { allOfToolCalls: [] } };
- expect(discoveryTestCaseSchema.safeParse(emptyList).success).toBe(false);
- });
-
- it.each([
- [
- 'connected with tool categories',
- { status: 'connected', toolCategories: ['screenshot'] },
- true,
- ],
- ['connected without tool categories', { status: 'connected' }, false],
- ['disconnected', { status: 'disconnected' }, true],
- ['disabledByUser', { status: 'disabledByUser' }, true],
- ['unavailable', { status: 'unavailable' }, true],
- ['an unknown status', { status: 'on-fire' }, false],
- ])('validates each computerUse channel strictly: %s', (_name, channel, ok) => {
- const withChannel = {
- ...valid,
- instanceState: {
- computerUse: { localComputer: channel, browser: { status: 'unavailable' } },
- },
- };
- expect(discoveryTestCaseSchema.safeParse(withChannel).success).toBe(ok);
- });
-
- it('requires both channels — a half-filled state is dead config', () => {
- const halfFilled = {
- ...valid,
- instanceState: { computerUse: { localComputer: { status: 'disconnected' } } },
- };
- expect(discoveryTestCaseSchema.safeParse(halfFilled).success).toBe(false);
- });
-
- it('rejects the retired localGateway key instead of passing vacuously', () => {
- const retired = { ...valid, instanceState: { localGateway: { status: 'disconnected' } } };
- expect(discoveryTestCaseSchema.safeParse(retired).success).toBe(false);
- });
-});
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/config-evals-skill-loading.json b/packages/@n8n/instance-ai/evaluations/data/discovery/config-evals-skill-loading.json
deleted file mode 100644
index c1a2ffa17e0..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/config-evals-skill-loading.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "id": "config-evals-skill-loading",
- "userMessage": "Set up an evaluation on my existing workflow that scores the AI Agent's answers against a test dataset. Use a config-based evaluation — do not add any evaluation nodes to the canvas.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["config-evals"]
- }
- ],
- "noneOf": ["evals", "spawn_sub_agent:eval-setup", "eval-setup-with-agent"]
- },
- "rationale": "Regression coverage for runtime skill loading of the config-evals skill. Config-based evaluation work must load the config-evals skill, and must never wire on-canvas evaluation nodes via the node-based evals tool or the eval-setup sub-agent."
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-natural-list-skill-loading.json b/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-natural-list-skill-loading.json
deleted file mode 100644
index 646a10bf95d..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-natural-list-skill-loading.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "id": "data-table-natural-list-skill-loading",
- "userMessage": "What data tables do I have?",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["data-table-manager"]
- },
- {
- "toolName": "data-tables",
- "argsContainAny": ["list"]
- }
- ],
- "noneOf": ["create-tasks", "build-workflow", "spawn_sub_agent:workflow-builder"]
- },
- "rationale": "Regression coverage for natural standalone data-table requests. A plain request to list existing Data Tables should load the data-table-manager skill and use the direct data-tables list action, without routing through planning, workflow-building, or background agents."
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-skill-loading.json b/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-skill-loading.json
deleted file mode 100644
index 4c767feed20..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-skill-loading.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "id": "data-table-skill-loading",
- "userMessage": "Use the data-table-manager skill to list my n8n Data Tables and summarize what tables are available. This is standalone data-table work; do not build or modify a workflow.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["data-table-manager"]
- },
- {
- "toolName": "data-tables",
- "argsContainAny": ["list"]
- }
- ],
- "noneOf": ["create-tasks", "build-workflow", "spawn_sub_agent:workflow-builder"]
- },
- "rationale": "Regression coverage for runtime skill loading. Standalone data-table work must load the data-table-manager skill and call data-tables directly, without routing through planning, workflow-building, or task creation."
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-workflow-skill-loading.json b/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-workflow-skill-loading.json
deleted file mode 100644
index ff671d1d5b9..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/data-table-workflow-skill-loading.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "id": "data-table-workflow-skill-loading",
- "userMessage": "Create me a workflow that implements an n8n form to capture responses into a data table. The form should be a decklist submission form for a MTG tournament, asking for player name and a deck list as text fields they'll fill.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["data-table-manager"]
- },
- {
- "toolName": "load_skill",
- "argsContainAny": ["workflow-builder"]
- },
- {
- "toolName": "build-workflow"
- }
- ],
- "noneOf": ["create-tasks"]
- },
- "rationale": "Regression coverage for single-workflow build prompts that depend on Data Tables. The orchestrator should load both the data-table-manager skill and the workflow-builder skill, include workflow-local table requirements in the build, and avoid task planning or delegation.",
- "timeoutMs": 180000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/folder-scoped-workflow-list.json b/packages/@n8n/instance-ai/evaluations/data/discovery/folder-scoped-workflow-list.json
deleted file mode 100644
index d1114fc7bfa..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/folder-scoped-workflow-list.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "id": "folder-scoped-workflow-list",
- "userMessage": "Which workflows are in the Clients/Acme folder? Just list them, do not change anything.",
- "instanceState": { "folderExploration": true },
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "workflows",
- "args": { "action": "list" },
- "argsContainAny": ["folderPath"]
- }
- ],
- "noneOf": ["build-workflow", "create-tasks", "spawn_sub_agent:workflow-builder"]
- },
- "rationale": "When the user names a FOLDER, the agent must address it with folderPath on workflows(list) rather than guessing a name filter or dumping the inventory. Folder membership is stored, not encoded in workflow names; a query prefix returns exactly the wrong set. Guards the discoverability of the folder-exploration capability."
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/google-oauth-credential-setup.json b/packages/@n8n/instance-ai/evaluations/data/discovery/google-oauth-credential-setup.json
deleted file mode 100644
index fa2879372ed..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/google-oauth-credential-setup.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "id": "google-oauth-credential-setup",
- "userMessage": "I need to set up a Google OAuth credential so I can read my calendar. Walk me through the Google Cloud Console so I can find my Client ID and Client Secret to enter into n8n.",
- "instanceState": {
- "computerUse": {
- "localComputer": { "status": "disconnected" },
- "browser": { "status": "connected", "toolCategories": ["browser"] }
- }
- },
- "expectedToolInvocations": {
- "anyOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["credential-setup-with-computer-use"]
- },
- { "toolName": "browser_connect" },
- { "toolName": "browser_navigate" },
- { "toolName": "browser_snapshot" },
- { "toolName": "browser_screenshot" }
- ]
- },
- "rationale": "Google OAuth involves multiple console screens; the orchestrator must reach for the Computer Use credential skill or browser_* tools rather than asking the user to copy values into chat.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/http-node-config-no-browser.json b/packages/@n8n/instance-ai/evaluations/data/discovery/http-node-config-no-browser.json
deleted file mode 100644
index 777cbb73338..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/http-node-config-no-browser.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "id": "http-node-config-no-browser",
- "userMessage": "How do I set the request timeout on my HTTP Request node?",
- "instanceState": {
- "computerUse": {
- "localComputer": { "status": "disconnected" },
- "browser": { "status": "connected", "toolCategories": ["browser"] }
- }
- },
- "expectedToolInvocations": {
- "noneOf": ["browser_navigate", "browser_snapshot", "browser_screenshot"]
- },
- "rationale": "Negative control. A pure node-config question must NOT trigger Computer Use browser tooling. Guards against an over-eager regression that would bias every credential-adjacent question into browser mode."
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/index.ts b/packages/@n8n/instance-ai/evaluations/data/discovery/index.ts
deleted file mode 100644
index f01d33a77fc..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/index.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import { readFileSync, readdirSync } from 'fs';
-import { basename, join } from 'path';
-import { z } from 'zod';
-
-import type { ComputerUseChannelState, ComputerUseState } from '../../../src/types';
-import type { DiscoveryMcpState } from '../../discovery/stub-mcp-registry';
-import type { DiscoveryConfirmations, DiscoveryTestCase } from '../../discovery/types';
-
-const forbiddenToolCallSchema = z
- .object({
- toolName: z.string().min(1),
- args: z
- .record(z.string(), z.unknown())
- .refine((pattern) => Object.keys(pattern).length > 0, { message: 'args must not be empty' })
- .optional(),
- argsContainAny: z.array(z.string().min(1)).min(1).optional(),
- declined: z.boolean().optional(),
- })
- .strict();
-
-/** Mirrors `ComputerUseState` (src/types.ts) — the annotation makes tsc flag
- * these schemas when the source union drifts. */
-const computerUseChannelStateSchema: z.ZodType = z.discriminatedUnion(
- 'status',
- [
- z.object({ status: z.literal('unavailable') }).strict(),
- z.object({ status: z.literal('disconnected') }).strict(),
- z.object({ status: z.literal('disabledByUser') }).strict(),
- z.object({ status: z.literal('connected'), toolCategories: z.array(z.string()) }).strict(),
- ],
-);
-
-const computerUseStateSchema: z.ZodType = z
- .object({
- localComputer: computerUseChannelStateSchema,
- browser: computerUseChannelStateSchema,
- })
- .strict();
-
-const mcpStateSchema: z.ZodType = z
- .object({
- registry: z.array(z.string().min(1)).optional(),
- connected: z
- .array(
- z
- .object({ slug: z.string().min(1), tools: z.array(z.string().min(1)).optional() })
- .strict(),
- )
- .optional(),
- })
- .strict();
-
-const confirmationDecisionSchema = z.enum(['approve', 'deny']);
-
-/** An empty map is dead config: the default (approve everything) already applies. */
-const confirmationsSchema: z.ZodType = z
- .record(
- z.string().min(1),
- z.union([
- confirmationDecisionSchema,
- z
- .object({
- decision: confirmationDecisionSchema,
- resumeWith: z
- .record(z.string(), z.unknown())
- .refine((fields) => Object.keys(fields).length > 0, {
- message: 'resumeWith must not be empty',
- })
- .refine((fields) => !('approved' in fields), {
- message: 'resumeWith must not set `approved` — use `decision` instead',
- })
- .optional(),
- })
- .strict(),
- ]),
- )
- .refine((entries) => Object.keys(entries).length > 0, {
- message: 'confirmations must not be empty',
- });
-
-/** Strict authoring schema for discovery cases — a typo'd key or an empty
- * expectation must fail at load time, not pass vacuously at run time (the
- * workflow-case loader has had this guarantee for a while; discovery cases
- * were a blind JSON.parse cast until TRUST-261's cleanup). */
-export const discoveryTestCaseSchema = z
- .object({
- id: z.string().min(1),
- userMessage: z.string().min(1),
- instanceState: z
- .object({
- computerUse: computerUseStateSchema.optional(),
- mcp: mcpStateSchema.optional(),
- folderExploration: z.boolean().optional(),
- })
- .strict()
- .optional(),
- confirmations: confirmationsSchema.optional(),
- expectedToolInvocations: z
- .object({
- // min(1) on every list: an empty expectation array is dead config that
- // would otherwise pass here and only surface as a run-time failure.
- anyOf: z.array(z.string().min(1)).min(1).optional(),
- noneOf: z.array(z.string().min(1)).min(1).optional(),
- anyOfToolCalls: z.array(forbiddenToolCallSchema).min(1).optional(),
- allOfToolCalls: z.array(forbiddenToolCallSchema).min(1).optional(),
- noneOfToolCalls: z.array(forbiddenToolCallSchema).min(1).optional(),
- })
- .strict()
- .refine((expectations) => Object.values(expectations).some((v) => v !== undefined), {
- message: 'expectedToolInvocations needs at least one expectation key',
- }),
- rationale: z.string().optional(),
- maxSteps: z.number().int().positive().optional(),
- timeoutMs: z.number().int().positive().optional(),
- })
- .strict();
-
-export interface DiscoveryTestCaseWithFile {
- testCase: DiscoveryTestCase;
- /** Filename without extension, e.g. "slack-oauth-credential-setup" */
- fileSlug: string;
-}
-
-function parseTestCaseFile(filePath: string): DiscoveryTestCase {
- const content = readFileSync(filePath, 'utf-8');
- let raw: unknown;
- try {
- raw = JSON.parse(content);
- } catch (error) {
- throw new Error(
- `Failed to parse discovery test case ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
- );
- }
- const parsed = discoveryTestCaseSchema.safeParse(raw);
- if (!parsed.success) {
- const issues = parsed.error.issues
- .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
- .join('; ');
- throw new Error(`Invalid discovery test case ${filePath}: ${issues}`);
- }
- return parsed.data;
-}
-
-function parseSubstringList(value: string | undefined): string[] {
- if (!value) return [];
- return value
- .split(',')
- .map((s) => s.trim().toLowerCase())
- .filter((s) => s.length > 0);
-}
-
-function getJsonFiles(filter?: string, exclude?: string): string[] {
- const dir = __dirname;
- let files = readdirSync(dir).filter((f) => f.endsWith('.json'));
-
- const includeTokens = parseSubstringList(filter);
- if (includeTokens.length > 0) {
- files = files.filter((f) => {
- const lower = f.toLowerCase();
- return includeTokens.some((t) => lower.includes(t));
- });
- }
-
- const excludeTokens = parseSubstringList(exclude);
- if (excludeTokens.length > 0) {
- files = files.filter((f) => {
- const lower = f.toLowerCase();
- return !excludeTokens.some((t) => lower.includes(t));
- });
- }
-
- return files.map((f) => join(dir, f));
-}
-
-export function loadDiscoveryTestCasesWithFiles(
- filter?: string,
- exclude?: string,
-): DiscoveryTestCaseWithFile[] {
- return getJsonFiles(filter, exclude).map((f) => ({
- testCase: parseTestCaseFile(f),
- fileSlug: basename(f, '.json'),
- }));
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-ambiguous-alert-response-clarifies.json b/packages/@n8n/instance-ai/evaluations/data/discovery/intent-ambiguous-alert-response-clarifies.json
deleted file mode 100644
index ad1006618cc..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-ambiguous-alert-response-clarifies.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "id": "intent-ambiguous-alert-response-clarifies",
- "userMessage": "I need to ingest a cybersecurity alert, analyze it, and take actions depending on the finding.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["intent-recognition"]
- },
- {
- "toolName": "ask-user",
- "argsContainAny": [
- "fixed rules",
- "predefined rules",
- "human judgment",
- "AI judgment",
- "case-by-case",
- "decide which action"
- ]
- }
- ]
- },
- "rationale": "The request does not say whether fixed rules or judgment choose each action. The assistant must clarify this anchor-defining choice before it selects an artifact.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-explicit-agent-creative-publisher.json b/packages/@n8n/instance-ai/evaluations/data/discovery/intent-explicit-agent-creative-publisher.json
deleted file mode 100644
index 70c4450f9c0..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-explicit-agent-creative-publisher.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "id": "intent-explicit-agent-creative-publisher",
- "userMessage": "Build me an agent that decides what faceless YouTube videos to make and drafts each script.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["agent-builder"]
- },
- {
- "toolName": "build-agent"
- }
- ],
- "noneOfToolCalls": [
- {
- "toolName": "build-workflow"
- }
- ]
- },
- "rationale": "An explicit Agent request with open-ended topic selection and creative work must use the Agent build path. This trims publishing integrations from the sourced failure so the case tests routing only.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-explicit-agent-fixed-duty.json b/packages/@n8n/instance-ai/evaluations/data/discovery/intent-explicit-agent-fixed-duty.json
deleted file mode 100644
index d5c3fc7c1d7..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-explicit-agent-fixed-duty.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "id": "intent-explicit-agent-fixed-duty",
- "userMessage": "Build me an agent that fetches new GitHub stars each night and sends me a digest.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["agent-builder"]
- },
- {
- "toolName": "build-agent"
- }
- ],
- "noneOfToolCalls": [
- {
- "toolName": "build-workflow"
- }
- ]
- },
- "rationale": "The user explicitly selected an Agent artifact. A fixed scheduled duty can be an Agent task, so the assistant must not override that selection with a workflow.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-fixed-scheduled-market-summary.json b/packages/@n8n/instance-ai/evaluations/data/discovery/intent-fixed-scheduled-market-summary.json
deleted file mode 100644
index 79a5f5f8812..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-fixed-scheduled-market-summary.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "id": "intent-fixed-scheduled-market-summary",
- "userMessage": "Every weekday at 8am, fetch the latest closing prices for the S&P 500 and Nasdaq, format them into a two-line summary, and email it to me.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["workflow-builder"]
- },
- {
- "toolName": "build-workflow"
- }
- ],
- "noneOfToolCalls": [
- {
- "toolName": "build-agent"
- },
- {
- "toolName": "load_skill",
- "argsContainAny": ["agent-builder"]
- }
- ]
- },
- "rationale": "This scheduled report has fixed inputs, formatting, and delivery. It guards against routing every recurring role-like task to an Agent.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-role-shaped-scheduled-analyst.json b/packages/@n8n/instance-ai/evaluations/data/discovery/intent-role-shaped-scheduled-analyst.json
deleted file mode 100644
index 3fafc89dc53..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/intent-role-shaped-scheduled-analyst.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "id": "intent-role-shaped-scheduled-analyst",
- "userMessage": "Set up a daily market analyst. It should research market changes, decide which developments matter, and send me a briefing each morning.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["intent-recognition"]
- },
- {
- "toolName": "load_skill",
- "argsContainAny": ["agent-builder"]
- },
- {
- "toolName": "build-agent"
- }
- ],
- "noneOfToolCalls": [
- {
- "toolName": "build-workflow"
- }
- ]
- },
- "rationale": "A persistent delegated role owns an open-ended research and judgment duty. The schedule is an Agent task and does not justify a workflow shell.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-broken-connection-reconnect.json b/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-broken-connection-reconnect.json
deleted file mode 100644
index 2f6c0c3f07b..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-broken-connection-reconnect.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "id": "mcp-broken-connection-reconnect",
- "userMessage": "Search my Notion for the Q3 roadmap page and tell me what's in it.",
- "instanceState": {
- "mcp": {
- "registry": ["notion", "linear"],
- "connected": [{ "slug": "notion", "tools": [] }]
- }
- },
- "expectedToolInvocations": {
- "allOfToolCalls": [
- { "toolName": "mcp-servers", "args": { "action": "connect", "serverSlugs": ["notion"] } }
- ],
- "noneOfToolCalls": [{ "toolName": "mcp-servers", "args": { "action": "search" } }]
- },
- "rationale": "A connected server serving no tools is a broken connection: the agent must reconnect the slug it already has, not fall back to a registry search that filters connected servers out and dead-ends.",
- "maxSteps": 12
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-connect-unconnected-service.json b/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-connect-unconnected-service.json
deleted file mode 100644
index 61dc0e23e10..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-connect-unconnected-service.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "id": "mcp-connect-unconnected-service",
- "userMessage": "Pull up the notes from my last product review in Notion and give me a short summary.",
- "instanceState": {
- "mcp": { "registry": ["notion", "linear"], "connected": [] }
- },
- "expectedToolInvocations": {
- "allOfToolCalls": [
- { "toolName": "mcp-servers", "args": { "action": "search" } },
- { "toolName": "mcp-servers", "args": { "action": "connect", "serverSlugs": ["notion"] } }
- ],
- "noneOfToolCalls": [
- { "toolName": "mcp-servers", "args": { "action": "connect", "serverSlugs": ["linear"] } }
- ]
- },
- "rationale": "Nothing is connected: the agent must find Notion in the registry and offer it, not declare it unreachable or recite manual setup steps. Linear is a decoy.",
- "maxSteps": 12
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-declined-tool-call-no-reconnect.json b/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-declined-tool-call-no-reconnect.json
deleted file mode 100644
index 3936154ad46..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-declined-tool-call-no-reconnect.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "id": "mcp-declined-tool-call-no-reconnect",
- "userMessage": "Find the 'Launch checklist' page in my Notion and summarise what's still open.",
- "instanceState": {
- "mcp": {
- "registry": ["notion", "linear"],
- "connected": [{ "slug": "notion", "tools": ["notion-search"] }]
- }
- },
- "confirmations": { "mcp_notion_notion-search": "deny" },
- "expectedToolInvocations": {
- "allOfToolCalls": [{ "toolName": "mcp_notion_notion-search", "declined": true }],
- "noneOfToolCalls": [{ "toolName": "mcp-servers", "args": { "action": "connect" } }]
- },
- "rationale": "A refused tool call is the user withholding permission, not a broken connection: the agent must report back rather than reconnect a server that is already connected.",
- "maxSteps": 12
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-no-registry-match.json b/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-no-registry-match.json
deleted file mode 100644
index 85bcdfd2551..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-no-registry-match.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "id": "mcp-no-registry-match",
- "userMessage": "Pull up my latest deals from HubSpot and give me a short summary.",
- "instanceState": {
- "mcp": { "registry": ["notion", "linear"], "connected": [] }
- },
- "expectedToolInvocations": {
- "allOfToolCalls": [{ "toolName": "mcp-servers", "args": { "action": "search" } }],
- "noneOfToolCalls": [{ "toolName": "mcp-servers", "args": { "action": "connect" } }]
- },
- "rationale": "HubSpot is not in the registry: the agent must search before declaring it unavailable, and must not offer an invented slug or a near-miss server.",
- "maxSteps": 12
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-not-offered-for-workflow-build.json b/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-not-offered-for-workflow-build.json
deleted file mode 100644
index 8e5856f5663..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-not-offered-for-workflow-build.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "id": "mcp-not-offered-for-workflow-build",
- "userMessage": "Build me a workflow that posts a standup reminder to our Slack channel every weekday at 9am.",
- "instanceState": {
- "mcp": { "registry": ["slack", "notion"], "connected": [] }
- },
- "expectedToolInvocations": {
- "allOfToolCalls": [{ "toolName": "build-workflow" }],
- "noneOfToolCalls": [
- { "toolName": "mcp-servers", "args": { "action": "search" } },
- { "toolName": "mcp-servers", "args": { "action": "connect" } }
- ]
- },
- "rationale": "A workflow talks to a service through its node and credential, not an MCP connection. Slack is in the registry to make the wrong move available. `connected` is allowed — only offering a connection is the regression.",
- "maxSteps": 24,
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-uses-connected-server-tools.json b/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-uses-connected-server-tools.json
deleted file mode 100644
index 8127700d78b..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/mcp-uses-connected-server-tools.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "id": "mcp-uses-connected-server-tools",
- "userMessage": "Pull up the notes from my last product review in Notion and give me a short summary.",
- "instanceState": {
- "mcp": { "registry": ["notion", "linear"], "connected": [{ "slug": "notion" }] }
- },
- "expectedToolInvocations": {
- "anyOfToolCalls": [
- { "toolName": "mcp_notion_notion-search" },
- { "toolName": "mcp_notion_notion-fetch" }
- ],
- "noneOfToolCalls": [{ "toolName": "mcp-servers", "args": { "action": "connect" } }]
- },
- "rationale": "Notion is already connected: the agent must use its tools instead of re-offering a connection the user already has.",
- "maxSteps": 12
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/oauth-with-computer-use-disabled.json b/packages/@n8n/instance-ai/evaluations/data/discovery/oauth-with-computer-use-disabled.json
deleted file mode 100644
index 5f42f431522..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/oauth-with-computer-use-disabled.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "id": "oauth-with-computer-use-disabled",
- "userMessage": "I need to set up a Slack OAuth credential. Walk me through the Slack admin so I can find my Client ID and Client Secret to enter into n8n.",
- "instanceState": {
- "computerUse": {
- "localComputer": { "status": "unavailable" },
- "browser": { "status": "unavailable" }
- }
- },
- "expectedToolInvocations": {
- "noneOf": ["browser_connect", "browser_navigate", "browser_snapshot", "browser_screenshot"]
- },
- "rationale": "When Computer Use is disabled globally the orchestrator must not pretend any browser tool exists. It should explain the standard credential setup path instead. Pairs with the OAuth-positive scenarios to prove discovery is gated on gateway state, not blindly triggered by the user message."
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/screenshot-dashboard.json b/packages/@n8n/instance-ai/evaluations/data/discovery/screenshot-dashboard.json
deleted file mode 100644
index 0a0fe49c395..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/screenshot-dashboard.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "id": "screenshot-dashboard",
- "userMessage": "Take a screenshot of the Stripe dashboard I have open in my browser so I can attach it to this workflow.",
- "instanceState": {
- "computerUse": {
- "localComputer": { "status": "disconnected" },
- "browser": { "status": "connected", "toolCategories": ["browser"] }
- }
- },
- "expectedToolInvocations": {
- "anyOf": ["browser_screenshot", "browser_snapshot", "browser_navigate"]
- },
- "rationale": "Direct browser-tool discovery — the user names a browser action verbatim. The orchestrator must reach for any browser tool (snapshot/screenshot/navigate are all valid first moves to inspect the page) rather than falling back to research."
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/slack-oauth-credential-setup.json b/packages/@n8n/instance-ai/evaluations/data/discovery/slack-oauth-credential-setup.json
deleted file mode 100644
index 69b9b4252aa..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/slack-oauth-credential-setup.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "id": "slack-oauth-credential-setup",
- "userMessage": "I need to set up a Slack OAuth credential. Walk me through the Slack admin so I can find my Client ID and Client Secret to enter into n8n.",
- "instanceState": {
- "computerUse": {
- "localComputer": { "status": "disconnected" },
- "browser": { "status": "connected", "toolCategories": ["browser"] }
- }
- },
- "expectedToolInvocations": {
- "anyOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["credential-setup-with-computer-use"]
- },
- { "toolName": "browser_connect" },
- { "toolName": "browser_navigate" },
- { "toolName": "browser_snapshot" },
- { "toolName": "browser_screenshot" }
- ]
- },
- "rationale": "OAuth credential setup is the canonical browser-discovery moment. The orchestrator must reach for the Computer Use credential skill or browser_* tools directly; falling through to research/ask-user without browser involvement is the regression.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-agent-builder-leak.json b/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-agent-builder-leak.json
deleted file mode 100644
index 7ecc57911f9..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-agent-builder-leak.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "id": "workflow-builder-no-agent-builder-leak",
- "userMessage": "Build a workflow that checks an RSS feed every hour and posts new items to a Slack channel.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["workflow-builder"]
- }
- ],
- "noneOfToolCalls": [
- {
- "toolName": "build-agent"
- }
- ]
- },
- "rationale": "Regression gate for the agent-builder path into Instance AI. A plain workflow-building prompt must route through the workflow-builder skill and must never invoke the build-agent orchestration tool. Meaningful in both module modes; the strongest signal is a run with N8N_ENABLED_MODULES including 'agents', where build-agent is registered (it is always-loaded, not deferred) yet must still not be selected.",
- "timeoutMs": 120000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-credential-ask.json b/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-credential-ask.json
deleted file mode 100644
index 70e72e90443..00000000000
--- a/packages/@n8n/instance-ai/evaluations/data/discovery/workflow-builder-no-credential-ask.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "id": "workflow-builder-no-credential-ask",
- "userMessage": "Build a workflow that lets customers request a meeting time in natural language, parses the preferred time with OpenAI, creates a Google Calendar event, and sends a Gmail confirmation email.",
- "expectedToolInvocations": {
- "allOfToolCalls": [
- {
- "toolName": "load_skill",
- "argsContainAny": ["workflow-builder"]
- },
- {
- "toolName": "build-workflow"
- }
- ],
- "noneOf": ["create-tasks"],
- "noneOfToolCalls": [
- {
- "toolName": "ask-user",
- "argsContainAny": ["credential", "credentials", "account", "auth", "api key", "token"]
- },
- {
- "toolName": "ask-user",
- "argsContainAny": ["timezone", "time zone"]
- }
- ]
- },
- "rationale": "Regression coverage for INS-204. A clear single-workflow build should route through the workflow-builder skill directly, may use credentials(action=\"list\") for discovery, and must not ask the user which credential/account to use when the builder can auto-select or mock unresolved credentials. It should also use contextual timezone data rather than asking for it.",
- "maxSteps": 24,
- "timeoutMs": 150000
-}
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/__tests__/confirmation-policy.test.ts b/packages/@n8n/instance-ai/evaluations/discovery/__tests__/confirmation-policy.test.ts
deleted file mode 100644
index 74b4826b05f..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/__tests__/confirmation-policy.test.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-import type { SuspensionInfo } from '../../../src/utils/stream-helpers';
-import { discoveryTestCaseSchema } from '../../data/discovery';
-import {
- buildConfirmationPolicy,
- resolveConfirmation,
- unmatchedConfirmations,
-} from '../confirmation-policy';
-import { credentialAutoSetupResponder } from '../credential-approval';
-import { createMcpConnectResponder, createStubMcpRegistry } from '../stub-mcp-registry';
-import type { DiscoveryTestCase } from '../types';
-
-function scenario(overrides: Partial = {}): DiscoveryTestCase {
- return {
- id: 'test',
- userMessage: 'do the thing',
- expectedToolInvocations: { anyOf: ['mcp-servers'] },
- ...overrides,
- };
-}
-
-function suspension(
- toolName: string,
- suspendPayload: Record = {},
-): SuspensionInfo {
- return { toolCallId: 'call-1', requestId: 'req-1', toolName, suspendPayload };
-}
-
-const connectPayload = {
- mcpConnectRequest: { servers: [{ serverSlug: 'notion' }, { serverSlug: 'linear' }] },
-};
-
-const credentialPayload = {
- credentialRequests: [{ credentialType: 'slackOAuth2Api' }],
-};
-
-function mcpResponders() {
- const registry = createStubMcpRegistry({ registry: ['notion', 'linear'], connected: [] });
- return { registry, responders: [createMcpConnectResponder(registry)] };
-}
-
-describe('buildConfirmationPolicy', () => {
- it('is empty when the scenario declares nothing', () => {
- expect(buildConfirmationPolicy(scenario()).size).toBe(0);
- });
-
- it('normalizes a bare decision into an answer', () => {
- const policy = buildConfirmationPolicy(scenario({ confirmations: { 'mcp-servers': 'deny' } }));
- expect(policy.get('mcp-servers')).toEqual({ decision: 'deny' });
- });
-
- it('keeps an answer object as declared', () => {
- const answer = { decision: 'approve' as const, resumeWith: { connectedSlugs: ['notion'] } };
- const policy = buildConfirmationPolicy(scenario({ confirmations: { 'mcp-servers': answer } }));
- expect(policy.get('mcp-servers')).toEqual(answer);
- });
-});
-
-describe('resolveConfirmation', () => {
- const empty = buildConfirmationPolicy(scenario());
-
- it('approves a suspension no policy entry covers', () => {
- expect(resolveConfirmation(suspension('some-tool'), empty)).toEqual({ approved: true });
- });
-
- it('approves when the suspension is unknown', () => {
- expect(resolveConfirmation(undefined, empty)).toEqual({ approved: true });
- });
-
- it('denies when the scenario says so', async () => {
- const policy = buildConfirmationPolicy(scenario({ confirmations: { 'mcp-servers': 'deny' } }));
- const { responders, registry } = mcpResponders();
- expect(
- resolveConfirmation(suspension('mcp-servers', connectPayload), policy, responders),
- ).toEqual({ approved: false });
- await expect(registry.service.listConnections()).resolves.toEqual([]);
- });
-
- it('denies an mcp tool call by its prefixed name', () => {
- const policy = buildConfirmationPolicy(
- scenario({ confirmations: { 'mcp_notion_notion-search': 'deny' } }),
- );
- const approval = { type: 'approval', toolName: 'mcp_notion_notion-search', args: {} };
- expect(resolveConfirmation(suspension('mcp_notion_notion-search', approval), policy)).toEqual({
- approved: false,
- });
- expect(resolveConfirmation(suspension('mcp_linear_list_issues', approval), policy)).toEqual({
- approved: true,
- });
- });
-
- it('falls back to the tool name the approval gate echoes into the payload', () => {
- const policy = buildConfirmationPolicy(
- scenario({ confirmations: { 'mcp_notion_notion-search': 'deny' } }),
- );
- const gated: SuspensionInfo = {
- toolCallId: 'call-1',
- requestId: 'req-1',
- suspendPayload: { type: 'approval', toolName: 'mcp_notion_notion-search', args: {} },
- };
- expect(resolveConfirmation(gated, policy)).toEqual({ approved: false });
- });
-
- it('takes the first responder that recognises the payload', () => {
- const { responders } = mcpResponders();
- const all = [credentialAutoSetupResponder, ...responders];
- expect(resolveConfirmation(suspension('mcp-servers', connectPayload), empty, all)).toEqual({
- approved: true,
- connectedSlugs: ['notion', 'linear'],
- });
- expect(resolveConfirmation(suspension('credentials', credentialPayload), empty, all)).toEqual({
- approved: true,
- autoSetup: { credentialType: 'slackOAuth2Api' },
- });
- });
-
- it('lets the scenario override responder fields without losing their side effects', async () => {
- const { registry, responders } = mcpResponders();
- const policy = buildConfirmationPolicy(
- scenario({
- confirmations: {
- 'mcp-servers': { decision: 'approve', resumeWith: { connectedSlugs: ['notion'] } },
- },
- }),
- );
- expect(
- resolveConfirmation(suspension('mcp-servers', connectPayload), policy, responders),
- ).toEqual({ approved: true, connectedSlugs: ['notion'] });
- await expect(registry.service.listConnections()).resolves.toEqual([
- { slug: 'notion' },
- { slug: 'linear' },
- ]);
- });
-
- it('keeps a deny denied even when resumeWith claims approval', () => {
- const policy = buildConfirmationPolicy(
- scenario({
- confirmations: { 'mcp-servers': { decision: 'deny', resumeWith: { approved: true } } },
- }),
- );
- const { responders } = mcpResponders();
- expect(
- resolveConfirmation(suspension('mcp-servers', connectPayload), policy, responders),
- ).toEqual({ approved: false });
- });
-
- it('keeps an approve approved even when resumeWith claims refusal', () => {
- const policy = buildConfirmationPolicy(
- scenario({
- confirmations: { 'ask-user': { decision: 'approve', resumeWith: { approved: false } } },
- }),
- );
- expect(resolveConfirmation(suspension('ask-user'), policy)).toEqual({ approved: true });
- });
-
- it('carries resumeWith when no responder recognises the payload', () => {
- const policy = buildConfirmationPolicy(
- scenario({
- confirmations: { 'ask-user': { decision: 'approve', resumeWith: { answer: 'yes' } } },
- }),
- );
- expect(resolveConfirmation(suspension('ask-user'), policy)).toEqual({
- approved: true,
- answer: 'yes',
- });
- });
-});
-
-describe('unmatchedConfirmations', () => {
- const policy = buildConfirmationPolicy(
- scenario({
- confirmations: {
- 'mcp_notion_notion-search': 'deny',
- credentials: { decision: 'approve', resumeWith: { autoSetup: { credentialType: 'x' } } },
- },
- }),
- );
-
- it('reports every behaviour-changing answer no suspension asked for', () => {
- expect(unmatchedConfirmations(policy, [])).toEqual(['mcp_notion_notion-search', 'credentials']);
- });
-
- it('reports nothing once each declared tool has suspended', () => {
- const asked = [suspension('mcp_notion_notion-search'), suspension('credentials')];
- expect(unmatchedConfirmations(policy, asked)).toEqual([]);
- });
-
- it('exempts a bare approve, which only asks for the default', () => {
- const approveOnly = buildConfirmationPolicy(
- scenario({ confirmations: { 'ask-user': 'approve' } }),
- );
- expect(unmatchedConfirmations(approveOnly, [])).toEqual([]);
- });
-
- it('matches the tool name echoed into the suspend payload', () => {
- const gated: SuspensionInfo = {
- toolCallId: 'call-1',
- requestId: 'req-1',
- suspendPayload: { type: 'approval', toolName: 'mcp_notion_notion-search' },
- };
- expect(unmatchedConfirmations(policy, [gated, suspension('credentials')])).toEqual([]);
- });
-
- it('ignores suspensions the scenario never declared', () => {
- expect(
- unmatchedConfirmations(buildConfirmationPolicy(scenario()), [suspension('nodes')]),
- ).toEqual([]);
- });
-});
-
-describe('credentialAutoSetupResponder', () => {
- it('requests browser auto-setup for the first credential', () => {
- expect(credentialAutoSetupResponder(credentialPayload)).toEqual({
- autoSetup: { credentialType: 'slackOAuth2Api' },
- });
- });
-
- it.each([
- ['a payload with no credential requests', {}],
- ['an empty request list', { credentialRequests: [] }],
- ['a request with no credential type', { credentialRequests: [{}] }],
- ])('passes on %s', (_label, payload) => {
- expect(credentialAutoSetupResponder(payload)).toBeUndefined();
- });
-});
-
-describe('createMcpConnectResponder', () => {
- it('marks the offered slugs live on the registry', async () => {
- const { registry, responders } = mcpResponders();
- expect(responders[0](connectPayload)).toEqual({ connectedSlugs: ['notion', 'linear'] });
- await expect(registry.service.listConnections()).resolves.toEqual([
- { slug: 'notion' },
- { slug: 'linear' },
- ]);
- });
-
- it('passes on a payload carrying no connect request', () => {
- const { responders } = mcpResponders();
- expect(responders[0]({ credentialRequests: [] })).toBeUndefined();
- });
-});
-
-describe('discoveryTestCaseSchema', () => {
- const base = { id: 'x', userMessage: 'y', expectedToolInvocations: { anyOf: ['a'] } };
-
- it.each([
- ['a bare decision', { 'mcp-servers': 'deny' }],
- ['an answer object', { 'mcp-servers': { decision: 'approve' } }],
- ['an answer with resume data', { credentials: { decision: 'approve', resumeWith: { a: 1 } } }],
- ])('accepts %s', (_label, confirmations) => {
- expect(discoveryTestCaseSchema.safeParse({ ...base, confirmations }).success).toBe(true);
- });
-
- it.each([
- ['an unknown decision', { confirmations: { 'mcp-servers': 'maybe' } }],
- ['an empty map', { confirmations: {} }],
- ['a missing decision', { confirmations: { 'mcp-servers': { resumeWith: { a: 1 } } } }],
- [
- 'an empty resumeWith',
- { confirmations: { 'mcp-servers': { decision: 'approve', resumeWith: {} } } },
- ],
- ['a misspelled key', { confirmation: { 'mcp-servers': 'deny' } }],
- [
- 'a resumeWith setting the reserved `approved` field',
- { confirmations: { 'mcp-servers': { decision: 'deny', resumeWith: { approved: true } } } },
- ],
- ])('rejects %s', (_label, invalid) => {
- expect(discoveryTestCaseSchema.safeParse({ ...base, ...invalid }).success).toBe(false);
- });
-});
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/__tests__/expected-tools-invoked.test.ts b/packages/@n8n/instance-ai/evaluations/discovery/__tests__/expected-tools-invoked.test.ts
deleted file mode 100644
index 19070525a86..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/__tests__/expected-tools-invoked.test.ts
+++ /dev/null
@@ -1,696 +0,0 @@
-import type { AgentActivity, CapturedToolCall, EventOutcome } from '../../types';
-import { evaluateDiscoveryTrial, runExpectedToolsInvokedCheck } from '../expected-tools-invoked';
-import type { DiscoveryTestCase, DiscoveryTrialFacts } from '../types';
-
-type ToolCallInput = Pick &
- Partial>;
-
-function makeToolCall(tc: ToolCallInput, i: number): CapturedToolCall {
- return {
- toolCallId: `call-${i}`,
- toolName: tc.toolName,
- args: tc.args ?? {},
- ...(tc.result === undefined ? {} : { result: tc.result }),
- durationMs: 0,
- };
-}
-
-function makeOutcome(opts: {
- toolCalls?: ToolCallInput[];
- agents?: Array & { toolCalls?: ToolCallInput[] }>;
-}): EventOutcome {
- return {
- workflowIds: [],
- executionIds: [],
- dataTableIds: [],
- artifactRefs: [],
- finalText: '',
- toolCalls: (opts.toolCalls ?? []).map(makeToolCall),
- agentActivities: (opts.agents ?? []).map((a, i) => ({
- agentId: `agent-${i}`,
- role: a.role,
- tools: a.tools,
- toolCalls: (a.toolCalls ?? []).map(makeToolCall),
- textContent: '',
- reasoning: '',
- status: 'completed',
- })),
- };
-}
-
-const slackOauthScenario: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'Help me set up Slack credentials',
- expectedToolInvocations: {
- anyOf: ['load_skill', 'browser_navigate'],
- },
-};
-
-describe('runExpectedToolsInvokedCheck', () => {
- describe('anyOf — positive cases', () => {
- it('passes when the expected top-level tool was invoked', () => {
- const result = runExpectedToolsInvokedCheck(
- slackOauthScenario,
- makeOutcome({ toolCalls: [{ toolName: 'load_skill' }] }),
- );
-
- expect(result.pass).toBe(true);
- expect(result.invokedTools).toContain('load_skill');
- });
-
- it('passes when a named sub-agent was spawned (matched via spawn_sub_agent: prefix)', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: '',
- expectedToolInvocations: { anyOf: ['spawn_sub_agent:researcher'] },
- },
- makeOutcome({
- agents: [{ role: 'researcher', tools: ['credentials'] }],
- }),
- );
-
- expect(result.pass).toBe(true);
- expect(result.spawnedAgents).toContain('spawn_sub_agent:researcher');
- });
-
- it('passes when the spawned sub-agent had the expected tool attached (sub-agent tools list)', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: '',
- expectedToolInvocations: { anyOf: ['credentials'] },
- },
- makeOutcome({
- agents: [{ role: 'researcher', tools: ['credentials'] }],
- }),
- );
-
- expect(result.pass).toBe(true);
- expect(result.invokedTools).toContain('credentials');
- });
- });
-
- describe('anyOf — negative cases', () => {
- it('fails when none of the expected tools or sub-agents appear', () => {
- const result = runExpectedToolsInvokedCheck(
- slackOauthScenario,
- makeOutcome({ toolCalls: [{ toolName: 'research' }] }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('Expected at least one of');
- expect(result.comment).toContain('browser_navigate');
- });
-
- it('fails when the orchestrator only ran research with no browser dispatch', () => {
- const result = runExpectedToolsInvokedCheck(
- slackOauthScenario,
- makeOutcome({
- toolCalls: [{ toolName: 'research' }, { toolName: 'ask-user' }],
- }),
- );
-
- expect(result.pass).toBe(false);
- });
- });
-
- describe('noneOf — negative scenarios (over-eager invocation guard)', () => {
- const httpNodeConfigScenario: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'How do I set the timeout on my HTTP node?',
- expectedToolInvocations: {
- noneOf: ['browser_navigate', 'spawn_sub_agent:credential-helper'],
- },
- };
-
- it('passes when the forbidden tool was not invoked', () => {
- const result = runExpectedToolsInvokedCheck(
- httpNodeConfigScenario,
- makeOutcome({ toolCalls: [{ toolName: 'nodes' }] }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('fails when the forbidden tool was invoked', () => {
- const result = runExpectedToolsInvokedCheck(
- httpNodeConfigScenario,
- makeOutcome({ toolCalls: [{ toolName: 'browser_navigate' }] }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('Expected none of');
- expect(result.comment).toContain('browser_navigate');
- });
-
- it('fails when the forbidden sub-agent was spawned', () => {
- const result = runExpectedToolsInvokedCheck(
- httpNodeConfigScenario,
- makeOutcome({
- agents: [{ role: 'credential-helper', tools: ['browser_navigate'] }],
- }),
- );
-
- expect(result.pass).toBe(false);
- });
- });
-
- describe('combined anyOf + noneOf', () => {
- const combinedScenario: DiscoveryTestCase = {
- id: 'test',
- userMessage: '',
- expectedToolInvocations: {
- anyOf: ['browser_navigate'],
- noneOf: ['delegate'],
- },
- };
-
- it('passes when anyOf matches and noneOf is not violated', () => {
- const result = runExpectedToolsInvokedCheck(
- combinedScenario,
- makeOutcome({ toolCalls: [{ toolName: 'browser_navigate' }] }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('fails when noneOf is violated even if anyOf matched', () => {
- const result = runExpectedToolsInvokedCheck(
- combinedScenario,
- makeOutcome({
- toolCalls: [{ toolName: 'browser_navigate' }, { toolName: 'delegate' }],
- }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('Expected none of');
- });
- });
-
- describe('noneOfToolCalls — actual tool-call guard', () => {
- const planningScenario: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'Build a Gmail and Calendar workflow',
- expectedToolInvocations: {
- anyOf: ['create-tasks'],
- noneOfToolCalls: [{ toolName: 'ask-user', argsContainAny: ['credential'] }],
- },
- };
-
- it('passes when ask-user is available to a spawned agent but is not called', () => {
- const result = runExpectedToolsInvokedCheck(
- planningScenario,
- makeOutcome({
- toolCalls: [{ toolName: 'create-tasks' }],
- agents: [{ role: 'delegate', tools: ['credentials', 'ask-user'] }],
- }),
- );
-
- expect(result.pass).toBe(true);
- expect(result.invokedTools).toContain('ask-user');
- });
-
- it('fails when the forbidden tool call happens with matching args', () => {
- const result = runExpectedToolsInvokedCheck(
- planningScenario,
- makeOutcome({
- toolCalls: [
- { toolName: 'create-tasks' },
- {
- toolName: 'ask-user',
- args: { question: 'Which Google Calendar credential should I use?' },
- },
- ],
- agents: [{ role: 'delegate', tools: ['credentials', 'ask-user'] }],
- }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('Expected no actual tool call matching');
- expect(result.comment).toContain('credential');
- });
-
- it('passes when the same tool is called for unrelated args', () => {
- const result = runExpectedToolsInvokedCheck(
- planningScenario,
- makeOutcome({
- toolCalls: [
- { toolName: 'create-tasks' },
- { toolName: 'ask-user', args: { question: 'Which failure branch should run?' } },
- ],
- agents: [{ role: 'delegate', tools: ['credentials', 'ask-user'] }],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
- });
-
- describe('anyOfToolCalls — actual tool-call alternatives', () => {
- const credentialSetupScenario: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'Help me set up Slack credentials',
- expectedToolInvocations: {
- anyOfToolCalls: [
- { toolName: 'load_skill', argsContainAny: ['credential-setup-with-computer-use'] },
- { toolName: 'browser_navigate' },
- ],
- },
- };
-
- it('passes when one expected actual tool call happened with matching args', () => {
- const result = runExpectedToolsInvokedCheck(
- credentialSetupScenario,
- makeOutcome({
- toolCalls: [
- {
- toolName: 'load_skill',
- args: { skillId: 'credential-setup-with-computer-use' },
- },
- ],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('passes when a browser fallback tool call happened', () => {
- const result = runExpectedToolsInvokedCheck(
- credentialSetupScenario,
- makeOutcome({ toolCalls: [{ toolName: 'browser_navigate' }] }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('fails when only an unrelated skill was loaded', () => {
- const result = runExpectedToolsInvokedCheck(
- credentialSetupScenario,
- makeOutcome({ toolCalls: [{ toolName: 'load_skill', args: { skillId: 'other-skill' } }] }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('credential-setup-with-computer-use');
- });
- });
-
- describe('allOfToolCalls — actual tool-call requirements', () => {
- const dataTableScenario: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'List my n8n Data Tables.',
- expectedToolInvocations: {
- allOfToolCalls: [
- { toolName: 'load_skill', argsContainAny: ['data-table-manager'] },
- { toolName: 'data-tables', argsContainAny: ['list'] },
- ],
- },
- };
-
- it('passes when every expected actual tool call happened with matching args', () => {
- const result = runExpectedToolsInvokedCheck(
- dataTableScenario,
- makeOutcome({
- toolCalls: [
- { toolName: 'load_skill', args: { skillId: 'data-table-manager' } },
- { toolName: 'data-tables', args: { action: 'list' } },
- ],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('fails when a tool is only available to a spawned agent but was not called', () => {
- const result = runExpectedToolsInvokedCheck(
- dataTableScenario,
- makeOutcome({
- toolCalls: [{ toolName: 'load_skill', args: { skillId: 'data-table-manager' } }],
- agents: [{ role: 'workflow-builder', tools: ['data-tables'] }],
- }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('Expected actual tool call matching');
- expect(result.comment).toContain('data-tables');
- });
-
- it('fails when the tool call args do not match the expectation', () => {
- const result = runExpectedToolsInvokedCheck(
- dataTableScenario,
- makeOutcome({
- toolCalls: [
- { toolName: 'load_skill', args: { skillId: 'data-table-manager' } },
- { toolName: 'data-tables', args: { action: 'schema' } },
- ],
- }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('list');
- });
- });
-
- describe('args — structured argument matching', () => {
- const connectNotion: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: {
- anyOfToolCalls: [
- { toolName: 'mcp-servers', args: { action: 'connect', serverSlugs: ['notion'] } },
- ],
- },
- };
-
- it('passes on a deep-partial match, ignoring unlisted keys', () => {
- const result = runExpectedToolsInvokedCheck(
- connectNotion,
- makeOutcome({
- toolCalls: [
- {
- toolName: 'mcp-servers',
- args: { action: 'connect', serverSlugs: ['notion'], reason: 'unlocks search' },
- },
- ],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('matches an array as a subset, not by length or order', () => {
- const result = runExpectedToolsInvokedCheck(
- connectNotion,
- makeOutcome({
- toolCalls: [
- {
- toolName: 'mcp-servers',
- args: { action: 'connect', serverSlugs: ['linear', 'notion'] },
- },
- ],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('fails when a listed key differs, even though a substring match would pass', () => {
- const result = runExpectedToolsInvokedCheck(
- connectNotion,
- makeOutcome({
- toolCalls: [
- {
- toolName: 'mcp-servers',
- args: {
- action: 'connect',
- serverSlugs: ['linear'],
- reason: 'Linear covers this instead of Notion',
- },
- },
- ],
- }),
- );
-
- expect(result.pass).toBe(false);
- });
-
- it('fails when the action differs', () => {
- const result = runExpectedToolsInvokedCheck(
- connectNotion,
- makeOutcome({
- toolCalls: [{ toolName: 'mcp-servers', args: { action: 'search', queries: ['notion'] } }],
- }),
- );
-
- expect(result.pass).toBe(false);
- });
- });
-
- describe('declined tool calls', () => {
- const declined = {
- declined: true,
- message: 'Tool "mcp_notion_notion-search" was not approved',
- };
-
- it('does not count a declined call as a violation', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: {
- noneOfToolCalls: [{ toolName: 'mcp_notion_notion-search' }],
- },
- },
- makeOutcome({
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: declined }],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('does not count a declined call as satisfying a positive expectation', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: {
- anyOfToolCalls: [{ toolName: 'mcp_notion_notion-search' }],
- },
- },
- makeOutcome({
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: declined }],
- }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('declined');
- });
-
- it('still counts a call that was approved and ran', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: {
- anyOfToolCalls: [{ toolName: 'mcp_notion_notion-search' }],
- },
- },
- makeOutcome({
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: { ok: true } }],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('matches a refusal when the expectation asks for one', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: {
- allOfToolCalls: [{ toolName: 'mcp_notion_notion-search', declined: true }],
- },
- },
- makeOutcome({
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: declined }],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('does not match a call that ran when the expectation asks for a refusal', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: {
- allOfToolCalls: [{ toolName: 'mcp_notion_notion-search', declined: true }],
- },
- },
- makeOutcome({
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: { ok: true } }],
- }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('a declined result');
- });
-
- it('does not count a sub-agent call the user refused as invoked', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: { noneOf: ['mcp_notion_notion-search'] },
- },
- makeOutcome({
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: declined }],
- agents: [
- {
- role: 'researcher',
- tools: [],
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: declined }],
- },
- ],
- }),
- );
-
- expect(result.pass).toBe(true);
- expect(result.invokedTools).not.toContain('mcp_notion_notion-search');
- });
-
- it('counts a sub-agent call that ran as invoked', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: { anyOf: ['mcp_notion_notion-search'] },
- },
- makeOutcome({
- agents: [
- {
- role: 'researcher',
- tools: [],
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: { ok: true } }],
- },
- ],
- }),
- );
-
- expect(result.pass).toBe(true);
- });
-
- it('forbids a refusal when noneOfToolCalls asks for one', () => {
- const result = runExpectedToolsInvokedCheck(
- {
- id: 'test',
- userMessage: 'Search my Notion.',
- expectedToolInvocations: {
- noneOfToolCalls: [{ toolName: 'mcp_notion_notion-search', declined: true }],
- },
- },
- makeOutcome({
- toolCalls: [{ toolName: 'mcp_notion_notion-search', result: declined }],
- }),
- );
-
- expect(result.pass).toBe(false);
- });
- });
-
- describe('rule validation', () => {
- it('throws when neither anyOf nor noneOf is provided', () => {
- expect(() =>
- runExpectedToolsInvokedCheck(
- { id: 'x', userMessage: '', expectedToolInvocations: {} },
- makeOutcome({}),
- ),
- ).toThrow(/anyOf.*noneOf/);
- });
-
- it('throws when both anyOf and noneOf are empty', () => {
- expect(() =>
- runExpectedToolsInvokedCheck(
- { id: 'x', userMessage: '', expectedToolInvocations: { anyOf: [], noneOf: [] } },
- makeOutcome({}),
- ),
- ).toThrow();
- });
- });
-});
-
-describe('evaluateDiscoveryTrial', () => {
- const trial = (overrides: Partial = {}): DiscoveryTrialFacts => ({
- streamStatus: 'completed',
- timeoutMs: 60_000,
- unmatchedConfirmations: [],
- ...overrides,
- });
-
- const negativeOnly: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'Set up a Slack credential.',
- expectedToolInvocations: { noneOf: ['browser_navigate'] },
- };
-
- const positiveOnly: DiscoveryTestCase = {
- id: 'test',
- userMessage: 'Screenshot my dashboard.',
- expectedToolInvocations: { anyOf: ['browser_navigate'] },
- };
-
- const satisfied = makeOutcome({ toolCalls: [{ toolName: 'browser_navigate' }] });
-
- it('passes a satisfied expectation on a completed run', () => {
- expect(evaluateDiscoveryTrial(negativeOnly, makeOutcome({}), trial()).pass).toBe(true);
- expect(evaluateDiscoveryTrial(positiveOnly, satisfied, trial()).pass).toBe(true);
- });
-
- it.each([
- ['negative-only', negativeOnly, makeOutcome({})],
- ['positive-only', positiveOnly, satisfied],
- ])('fails a satisfied %s expectation when the run stopped at its step cap', (_l, s, outcome) => {
- const result = evaluateDiscoveryTrial(s, outcome, trial({ streamStatus: 'step-exhausted' }));
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('step cap');
- });
-
- it.each(['errored', 'suspended'] as const)(
- 'fails a satisfied expectation when the run %s',
- (streamStatus) => {
- const result = evaluateDiscoveryTrial(positiveOnly, satisfied, trial({ streamStatus }));
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('Run did not complete');
- },
- );
-
- it('fails a satisfied expectation when the run exceeded its budget', () => {
- const result = evaluateDiscoveryTrial(
- positiveOnly,
- satisfied,
- trial({ streamStatus: 'timed-out', timeoutMs: 150_000 }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('exceeded its 150000ms budget');
- });
-
- it('reports the run error alongside the invalid trial', () => {
- const result = evaluateDiscoveryTrial(
- negativeOnly,
- makeOutcome({}),
- trial({ streamStatus: 'errored', runError: 'overloaded_error' }),
- );
-
- expect(result.comment).toContain('errored: overloaded_error');
- });
-
- it('keeps the expectation diagnostic when an invalid trial also failed its expectation', () => {
- const result = evaluateDiscoveryTrial(
- positiveOnly,
- makeOutcome({ toolCalls: [{ toolName: 'nodes' }] }),
- trial({ streamStatus: 'timed-out' }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('budget and was abandoned');
- expect(result.comment).toContain('Expected at least one of');
- });
-
- it('fails when a declared confirmation answer was never asked for', () => {
- const result = evaluateDiscoveryTrial(
- negativeOnly,
- makeOutcome({}),
- trial({ unmatchedConfirmations: ['mcp_notion_notion-search'] }),
- );
-
- expect(result.pass).toBe(false);
- expect(result.comment).toContain('mcp_notion_notion-search');
- expect(result.comment).toContain('never ran');
- });
-});
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/__tests__/stream-status.test.ts b/packages/@n8n/instance-ai/evaluations/discovery/__tests__/stream-status.test.ts
deleted file mode 100644
index 7fcf994fac0..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/__tests__/stream-status.test.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { ExecuteResumableStreamResult } from '../../../src/runtime/resumable-stream-executor';
-import { resolveStreamStatus } from '../stream-status';
-
-function streamResult(
- overrides: Partial = {},
-): ExecuteResumableStreamResult {
- return {
- status: 'completed',
- agentRunId: 'run-1',
- workSummary: {
- toolCalls: [],
- totalToolCalls: 0,
- totalToolErrors: 0,
- askedClarifyingQuestion: false,
- },
- ...overrides,
- };
-}
-
-describe('resolveStreamStatus', () => {
- it('reports a clean finish as completed', () => {
- expect(resolveStreamStatus(streamResult({ finishReason: 'stop' }), false)).toBe('completed');
- });
-
- it('reports a run that hit its step cap as step-exhausted', () => {
- expect(resolveStreamStatus(streamResult({ finishReason: 'max-iterations' }), false)).toBe(
- 'step-exhausted',
- );
- });
-
- it.each(['length', 'content-filter', 'error', 'other', 'tool-calls'] as const)(
- 'reports a run cut short by %s as errored',
- (finishReason) => {
- expect(resolveStreamStatus(streamResult({ finishReason }), false)).toBe('errored');
- },
- );
-
- it('never reads a missing finish chunk as a clean finish', () => {
- expect(resolveStreamStatus(streamResult(), false)).toBe('errored');
- });
-
- it('prefers timed-out over the step cap', () => {
- expect(resolveStreamStatus(streamResult({ finishReason: 'max-iterations' }), true)).toBe(
- 'timed-out',
- );
- });
-
- it('prefers errored over the step cap', () => {
- expect(
- resolveStreamStatus(
- streamResult({ status: 'errored', finishReason: 'max-iterations' }),
- false,
- ),
- ).toBe('errored');
- });
-
- it('reports the budget sentinel as timed-out', () => {
- expect(resolveStreamStatus('timed-out', false)).toBe('timed-out');
- });
-
- it.each([streamResult({ status: 'suspended' }), streamResult({ finishReason: 'paused' })])(
- 'reports a suspended or paused run as suspended',
- (result) => {
- expect(resolveStreamStatus(result, false)).toBe('suspended');
- },
- );
-
- it('never reads a cancelled run as a clean finish', () => {
- expect(resolveStreamStatus(streamResult({ status: 'cancelled' }), false)).toBe('timed-out');
- });
-});
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/cli.ts b/packages/@n8n/instance-ai/evaluations/discovery/cli.ts
deleted file mode 100644
index 163b436bc8a..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/cli.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-#!/usr/bin/env node
-// ---------------------------------------------------------------------------
-// CLI for browser/computer-use discovery evaluation.
-//
-// Usage:
-// pnpm eval:discovery # run all scenarios, 3 trials each
-// pnpm eval:discovery --filter slack-oauth --verbose
-// pnpm eval:discovery --trials 5
-//
-// Loads scenarios from evaluations/data/discovery/, runs each scenario × N
-// trials via the in-process runner, reports per-scenario pass-rates, exits
-// non-zero on any scenario below threshold, or on any scenario with zero passes
-// when --fail-on-zero-pass is set.
-// ---------------------------------------------------------------------------
-
-import { runDiscoveryScenario, type DiscoveryRunResult } from './runner';
-import type { DiscoveryTestCase } from './types';
-import { loadDiscoveryTestCasesWithFiles } from '../data/discovery';
-
-// ---------------------------------------------------------------------------
-// Args
-// ---------------------------------------------------------------------------
-
-interface CliArgs {
- filter?: string;
- verbose: boolean;
- trials: number;
- passThreshold: number;
- timeoutMs: number;
- /** Optional iteration cap; unset uses the SDK default. */
- maxSteps?: number;
- modelId: string;
- concurrency: number;
- nodesJsonPath?: string;
- failOnZeroPass: boolean;
-}
-
-const DEFAULT_MODEL = process.env.N8N_INSTANCE_AI_EVAL_MODEL ?? 'anthropic/claude-sonnet-4-6';
-
-function previewArgs(args: Record): string {
- const entries = Object.entries(args).slice(0, 3);
- if (entries.length === 0) return '';
- return entries
- .map(([k, v]) => {
- let preview: string;
- if (typeof v === 'string') {
- preview = v.length > 40 ? `"${v.slice(0, 40)}…"` : `"${v}"`;
- } else if (typeof v === 'number' || typeof v === 'boolean' || v === null) {
- preview = String(v);
- } else if (Array.isArray(v)) {
- preview = `[${String(v.length)}]`;
- } else {
- preview = '{…}';
- }
- return `${k}=${preview}`;
- })
- .join(', ');
-}
-
-function requirePositiveInt(raw: string | undefined, flag: string): number {
- const n = Number(raw);
- if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
- console.error(`Invalid value for ${flag}: ${String(raw)} (expected a positive integer)`);
- process.exit(1);
- }
- return n;
-}
-
-function requireNumberInRange(
- raw: string | undefined,
- flag: string,
- lo: number,
- hi: number,
-): number {
- const n = Number(raw);
- if (!Number.isFinite(n) || n < lo || n > hi) {
- console.error(`Invalid value for ${flag}: ${String(raw)} (expected number in [${lo}, ${hi}])`);
- process.exit(1);
- }
- return n;
-}
-
-function parseArgs(argv: string[]): CliArgs {
- const args: CliArgs = {
- verbose: false,
- trials: 3,
- passThreshold: 2 / 3,
- timeoutMs: 60_000,
- modelId: DEFAULT_MODEL,
- concurrency: 3,
- failOnZeroPass: false,
- };
-
- for (let i = 0; i < argv.length; i++) {
- const arg = argv[i];
- switch (arg) {
- case '--verbose':
- case '-v':
- args.verbose = true;
- break;
- case '--filter':
- args.filter = argv[++i];
- break;
- case '--trials':
- args.trials = requirePositiveInt(argv[++i], '--trials');
- break;
- case '--pass-threshold':
- args.passThreshold = requireNumberInRange(argv[++i], '--pass-threshold', 0, 1);
- break;
- case '--timeout':
- args.timeoutMs = requirePositiveInt(argv[++i], '--timeout');
- break;
- case '--max-steps':
- args.maxSteps = requirePositiveInt(argv[++i], '--max-steps');
- break;
- case '--model':
- args.modelId = argv[++i];
- break;
- case '--concurrency':
- args.concurrency = requirePositiveInt(argv[++i], '--concurrency');
- break;
- case '--nodes-json':
- args.nodesJsonPath = argv[++i];
- break;
- case '--fail-on-zero-pass':
- args.failOnZeroPass = true;
- break;
- default:
- break;
- }
- }
-
- return args;
-}
-
-// ---------------------------------------------------------------------------
-// Local mode
-// ---------------------------------------------------------------------------
-
-interface ScenarioAggregate {
- scenario: DiscoveryTestCase;
- results: DiscoveryRunResult[];
- passCount: number;
- passRate: number;
-}
-
-async function runLocalMode(args: CliArgs): Promise {
- const cases = loadDiscoveryTestCasesWithFiles(args.filter);
-
- if (cases.length === 0) {
- console.log('No discovery scenarios found.');
- return;
- }
-
- console.log(
- `Running ${String(cases.length)} discovery scenario(s) × ${String(args.trials)} trial(s) (model: ${args.modelId}, concurrency: ${String(args.concurrency)}).\n`,
- );
-
- const aggregates: ScenarioAggregate[] = [];
-
- // Per scenario, run trials in parallel up to `concurrency`. Across scenarios, run sequentially
- // — keeps the load profile predictable and per-scenario reports atomic.
- for (const { testCase, fileSlug } of cases) {
- process.stdout.write(`▸ ${fileSlug} ... `);
- const trialResults: DiscoveryRunResult[] = [];
-
- for (let i = 0; i < args.trials; i += args.concurrency) {
- const batchSize = Math.min(args.concurrency, args.trials - i);
- const batch = await Promise.all(
- Array.from(
- { length: batchSize },
- async () =>
- await runDiscoveryScenario({
- scenario: testCase,
- modelId: args.modelId,
- maxSteps: args.maxSteps,
- timeoutMs: args.timeoutMs,
- ...(args.nodesJsonPath ? { nodesJsonPath: args.nodesJsonPath } : {}),
- }),
- ),
- );
- trialResults.push(...batch);
- }
-
- const passCount = trialResults.filter((r) => r.check.pass).length;
- const passRate = passCount / trialResults.length;
- const status = passRate >= args.passThreshold ? '✓' : '✗';
- console.log(
- `${status} ${String(passCount)}/${String(trialResults.length)} passed (${(passRate * 100).toFixed(0)}%)`,
- );
-
- if (args.verbose) {
- for (const [idx, r] of trialResults.entries()) {
- const icon = r.check.pass ? ' ✓' : ' ✗';
- const trial = `trial ${String(idx + 1)}`;
- console.log(`${icon} ${trial} (${(r.durationMs / 1000).toFixed(1)}s) — ${r.check.comment}`);
- if (r.runError) console.log(` run error: ${r.runError}`);
- if (r.outcome.toolCalls.length > 0) {
- console.log(' tool calls:');
- for (const tc of r.outcome.toolCalls) {
- const argsPreview = previewArgs(tc.args);
- console.log(` • ${tc.toolName}(${argsPreview})`);
- }
- }
- if (r.outcome.agentActivities.length > 0) {
- console.log(' spawned sub-agents:');
- for (const a of r.outcome.agentActivities) {
- console.log(` • role=${a.role}, tools=[${a.tools.join(', ')}]`);
- }
- }
- }
- }
-
- aggregates.push({ scenario: testCase, results: trialResults, passCount, passRate });
- }
-
- printSummary(aggregates, args);
-
- const failingScenarios = aggregates.filter((a) => a.passRate < args.passThreshold);
- const zeroPassScenarios = aggregates.filter((a) => a.passCount === 0);
- const shouldFail = args.failOnZeroPass
- ? zeroPassScenarios.length > 0
- : failingScenarios.length > 0;
- if (shouldFail) {
- process.exitCode = 1;
- }
-}
-
-function printSummary(aggregates: ScenarioAggregate[], args: CliArgs): void {
- console.log('\n=== Summary ===');
- const totalTrials = aggregates.reduce((sum, a) => sum + a.results.length, 0);
- const totalPasses = aggregates.reduce((sum, a) => sum + a.passCount, 0);
- const passingScenarios = aggregates.filter((a) => a.passRate >= args.passThreshold).length;
- const totalDurationMs = aggregates
- .flatMap((a) => a.results)
- .reduce((sum, r) => sum + r.durationMs, 0);
-
- console.log(
- `Scenarios: ${String(passingScenarios)}/${String(aggregates.length)} above threshold (${(args.passThreshold * 100).toFixed(0)}%)`,
- );
- console.log(
- `Trials: ${String(totalPasses)}/${String(totalTrials)} passed (${((totalPasses / totalTrials) * 100).toFixed(0)}%)`,
- );
- console.log(`Total time: ${(totalDurationMs / 1000).toFixed(1)}s`);
-
- const failingScenarios = aggregates.filter((a) => a.passRate < args.passThreshold);
- if (failingScenarios.length > 0) {
- console.log('\nFailing scenarios:');
- for (const a of failingScenarios) {
- console.log(` ✗ ${a.scenario.id} (${(a.passRate * 100).toFixed(0)}%)`);
- const firstFailure = a.results.find((r) => !r.check.pass);
- if (firstFailure) {
- console.log(` ${firstFailure.check.comment}`);
- }
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
-
-async function main(): Promise {
- const args = parseArgs(process.argv.slice(2));
-
- if (!process.env.ANTHROPIC_API_KEY) {
- console.error(
- 'Error: ANTHROPIC_API_KEY is required to run discovery evaluations (the runner calls Anthropic in-process).',
- );
- process.exit(1);
- }
-
- await runLocalMode(args);
-}
-
-main()
- .then(() => {
- process.stdout.write('', () => process.exit(process.exitCode ?? 0));
- })
- .catch((error) => {
- console.error('Fatal error:', error);
- process.exit(1);
- });
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/confirmation-policy.ts b/packages/@n8n/instance-ai/evaluations/discovery/confirmation-policy.ts
deleted file mode 100644
index 68d419098ac..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/confirmation-policy.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-// ---------------------------------------------------------------------------
-// How the harness answers confirmation / HITL suspensions.
-//
-// Production waits for the user; the harness answers from the scenario, keyed on
-// the suspending tool's name and defaulting to approve, so a scenario only
-// declares the answers it wants to differ. A bare approval resumes
-// `{ approved: true }`, which is the whole contract for an `approval` card — an
-// MCP tool call included.
-//
-// Cards that need the user to *supply* something (a credential choice, a
-// connect card's slugs, a Q&A wizard's answers) are filled in by an
-// ApprovalResponder living with that domain — `credential-approval.ts`,
-// `stub-mcp-registry.ts`. This module stays payload-agnostic: to cover a new
-// card kind, write a responder next to its domain and register it in the
-// runner. A scenario can always bypass the lot with `resumeWith`.
-// ---------------------------------------------------------------------------
-
-import { isRecord } from '@n8n/utils/is-record';
-
-import type { ConfirmationAnswer, DiscoveryTestCase } from './types';
-import type { SuspensionInfo } from '../../src/utils/stream-helpers';
-
-export type ConfirmationPolicy = Map;
-
-// Supplies what an approving user would have filled in for one kind of card.
-export type ApprovalResponder = (
- payload: Record,
-) => Record | undefined;
-
-export function buildConfirmationPolicy(scenario: DiscoveryTestCase): ConfirmationPolicy {
- const policy: ConfirmationPolicy = new Map();
- for (const [toolName, answer] of Object.entries(scenario.confirmations ?? {})) {
- policy.set(toolName, typeof answer === 'string' ? { decision: answer } : answer);
- }
- return policy;
-}
-
-export function resolveConfirmation(
- suspension: SuspensionInfo | undefined,
- policy: ConfirmationPolicy,
- responders: ApprovalResponder[] = [],
-): Record {
- const answer = policy.get(suspendedToolName(suspension));
- if (answer?.decision === 'deny') return { ...answer.resumeWith, approved: false };
-
- const payload = suspension?.suspendPayload ?? {};
- for (const responder of responders) {
- const responderAnswer = responder(payload);
- if (responderAnswer) return { ...responderAnswer, ...answer?.resumeWith, approved: true };
- }
- return { ...answer?.resumeWith, approved: true };
-}
-
-export function unmatchedConfirmations(
- policy: ConfirmationPolicy,
- suspensions: Iterable,
-): string[] {
- const asked = new Set([...suspensions].map(suspendedToolName));
- return [...policy]
- .filter(
- ([tool, answer]) => !asked.has(tool) && (answer.decision === 'deny' || answer.resumeWith),
- )
- .map(([tool]) => tool);
-}
-
-function suspendedToolName(suspension: SuspensionInfo | undefined): string {
- if (suspension?.toolName) return suspension.toolName;
- const payload = suspension?.suspendPayload;
- return isRecord(payload) && typeof payload.toolName === 'string' ? payload.toolName : '';
-}
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/credential-approval.ts b/packages/@n8n/instance-ai/evaluations/discovery/credential-approval.ts
deleted file mode 100644
index 039680081a8..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/credential-approval.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { isRecord } from '@n8n/utils/is-record';
-
-import type { ApprovalResponder } from './confirmation-policy';
-
-/** Approving a credential card means "Auto-setup with browser". A bare approval
- * takes the "user picked an existing credential" branch, so the orchestrator never
- * sees `needsBrowserSetup=true` — the flag that wires it to the Computer Use
- * credential setup skill. The card offers one credential at a time once browser
- * setup is in play, so the first request is the one `autoSetup` names. */
-export const credentialAutoSetupResponder: ApprovalResponder = (payload) => {
- const requests = payload.credentialRequests;
- if (!Array.isArray(requests)) return undefined;
-
- const first: unknown = requests[0];
- if (!isRecord(first) || typeof first.credentialType !== 'string' || !first.credentialType) {
- return undefined;
- }
- return { autoSetup: { credentialType: first.credentialType } };
-};
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/expected-tools-invoked.ts b/packages/@n8n/instance-ai/evaluations/discovery/expected-tools-invoked.ts
deleted file mode 100644
index e385daeb249..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/expected-tools-invoked.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-// ---------------------------------------------------------------------------
-// Discovery check — assert the orchestrator reached for the expected tool(s).
-//
-// Reads the captured event outcome (`toolCalls` + `agentActivities`) and
-// compares against a `DiscoveryTestCase.expectedToolInvocations` rule.
-//
-// "Invoked" means either:
-// - a top-level `tool-call` event with that tool name, OR
-// - an `agent-spawned` event whose payload `tools` array contains that name
-// (the sub-agent had access — even if it has not yet called it), OR
-// - the rule names `spawn_sub_agent:` and a sub-agent with that role
-// was spawned.
-//
-// The asymmetry (sub-agent existence counts as discovery) lets dispatch checks
-// assert that a specialized background agent was reached even before it emits
-// its own tool calls.
-// ---------------------------------------------------------------------------
-
-import { isRecord } from '@n8n/utils/is-record';
-
-import type { EventOutcome } from '../types';
-import type {
- DiscoveryCheckResult,
- DiscoveryTestCase,
- DiscoveryTrialFacts,
- ExpectedToolInvocations,
- ForbiddenToolCall,
-} from './types';
-
-const SPAWN_PREFIX = 'spawn_sub_agent:';
-
-function collectInvokedTools(outcome: EventOutcome): string[] {
- const tools = new Set();
- for (const tc of outcome.toolCalls) {
- if (tc.toolName && !wasDeclined(tc)) tools.add(tc.toolName);
- }
- for (const agent of outcome.agentActivities) {
- for (const t of agent.tools) tools.add(t);
- for (const tc of agent.toolCalls) {
- if (tc.toolName && !wasDeclined(tc)) tools.add(tc.toolName);
- }
- }
- return [...tools];
-}
-
-function collectSpawnedAgents(outcome: EventOutcome): string[] {
- return outcome.agentActivities
- .filter((a) => a.role.length > 0)
- .map((a) => `${SPAWN_PREFIX}${a.role}`);
-}
-
-function matches(name: string, invokedTools: string[], spawnedAgents: string[]): boolean {
- if (name.startsWith(SPAWN_PREFIX)) {
- return spawnedAgents.includes(name);
- }
- return invokedTools.includes(name);
-}
-
-function validateRule(rule: ExpectedToolInvocations): void {
- const hasAnyOf = Array.isArray(rule.anyOf) && rule.anyOf.length > 0;
- const hasNoneOf = Array.isArray(rule.noneOf) && rule.noneOf.length > 0;
- const hasAnyOfToolCalls = Array.isArray(rule.anyOfToolCalls) && rule.anyOfToolCalls.length > 0;
- const hasAllOfToolCalls = Array.isArray(rule.allOfToolCalls) && rule.allOfToolCalls.length > 0;
- const hasNoneOfToolCalls = Array.isArray(rule.noneOfToolCalls) && rule.noneOfToolCalls.length > 0;
- if (!hasAnyOf && !hasNoneOf && !hasAnyOfToolCalls && !hasAllOfToolCalls && !hasNoneOfToolCalls) {
- throw new Error(
- 'expectedToolInvocations must specify a non-empty `anyOf`, `noneOf`, `anyOfToolCalls`, `allOfToolCalls`, or `noneOfToolCalls` list',
- );
- }
-}
-
-function wasDeclined(toolCall: EventOutcome['toolCalls'][number]): boolean {
- return isRecord(toolCall.result) && toolCall.result.declined === true;
-}
-
-function toolCallMatchesExpectation(
- toolCall: EventOutcome['toolCalls'][number],
- expectation: ForbiddenToolCall,
-): boolean {
- if (wasDeclined(toolCall) !== (expectation.declined ?? false)) return false;
- if (toolCall.toolName !== expectation.toolName) return false;
- if (expectation.args && !matchesArgPattern(expectation.args, toolCall.args)) return false;
-
- const argsContainAny = expectation.argsContainAny ?? [];
- if (argsContainAny.length === 0) return true;
-
- const argsText = JSON.stringify(toolCall.args).toLowerCase();
- return argsContainAny.some((term) => argsText.includes(term.toLowerCase()));
-}
-
-function matchesArgPattern(pattern: unknown, actual: unknown): boolean {
- if (Array.isArray(pattern)) {
- return (
- Array.isArray(actual) &&
- pattern.every((item) => actual.some((candidate) => matchesArgPattern(item, candidate)))
- );
- }
- if (isRecord(pattern)) {
- return (
- isRecord(actual) &&
- Object.entries(pattern).every(([key, value]) => matchesArgPattern(value, actual[key]))
- );
- }
- return pattern === actual;
-}
-
-function describeActualToolCalls(outcome: EventOutcome): string {
- return (
- outcome.toolCalls
- .map((tc) => (wasDeclined(tc) ? `${tc.toolName} (declined)` : tc.toolName))
- .join(', ') || '∅'
- );
-}
-
-function formatToolCallExpectation(expectation: ForbiddenToolCall): string {
- const clauses: string[] = [];
- if (expectation.declined) clauses.push('a declined result');
- if (expectation.args) clauses.push(`args matching ${JSON.stringify(expectation.args)}`);
- if (expectation.argsContainAny && expectation.argsContainAny.length > 0) {
- clauses.push(`args containing one of [${expectation.argsContainAny.join(', ')}]`);
- }
- return clauses.length > 0
- ? `${expectation.toolName} with ${clauses.join(' and ')}`
- : expectation.toolName;
-}
-
-export function runExpectedToolsInvokedCheck(
- scenario: DiscoveryTestCase,
- outcome: EventOutcome,
-): DiscoveryCheckResult {
- validateRule(scenario.expectedToolInvocations);
-
- const invokedTools = collectInvokedTools(outcome);
- const spawnedAgents = collectSpawnedAgents(outcome);
-
- const { anyOf, noneOf, anyOfToolCalls, allOfToolCalls, noneOfToolCalls } =
- scenario.expectedToolInvocations;
-
- if (anyOf && anyOf.length > 0) {
- const matched = anyOf.find((name) => matches(name, invokedTools, spawnedAgents));
- if (!matched) {
- return {
- pass: false,
- comment: `Expected at least one of [${anyOf.join(', ')}] to be invoked. Invoked: [${invokedTools.join(', ') || '∅'}]; spawned: [${spawnedAgents.join(', ') || '∅'}].`,
- invokedTools,
- spawnedAgents,
- };
- }
- }
-
- if (noneOf && noneOf.length > 0) {
- const violated = noneOf.find((name) => matches(name, invokedTools, spawnedAgents));
- if (violated) {
- return {
- pass: false,
- comment: `Expected none of [${noneOf.join(', ')}] to be invoked, but [${violated}] was reached.`,
- invokedTools,
- spawnedAgents,
- };
- }
- }
-
- if (anyOfToolCalls && anyOfToolCalls.length > 0) {
- const matched = anyOfToolCalls.find((expectation) =>
- outcome.toolCalls.some((toolCall) => toolCallMatchesExpectation(toolCall, expectation)),
- );
- if (!matched) {
- const actualToolCalls = describeActualToolCalls(outcome);
- return {
- pass: false,
- comment: `Expected at least one actual tool call matching [${anyOfToolCalls.map(formatToolCallExpectation).join(', ')}]. Actual tool calls: [${actualToolCalls}].`,
- invokedTools,
- spawnedAgents,
- };
- }
- }
-
- if (allOfToolCalls && allOfToolCalls.length > 0) {
- for (const expectation of allOfToolCalls) {
- const matched = outcome.toolCalls.find((toolCall) =>
- toolCallMatchesExpectation(toolCall, expectation),
- );
- if (!matched) {
- const actualToolCalls = describeActualToolCalls(outcome);
- return {
- pass: false,
- comment: `Expected actual tool call matching [${formatToolCallExpectation(expectation)}]. Actual tool calls: [${actualToolCalls}].`,
- invokedTools,
- spawnedAgents,
- };
- }
- }
- }
-
- if (noneOfToolCalls && noneOfToolCalls.length > 0) {
- for (const expectation of noneOfToolCalls) {
- const violated = outcome.toolCalls.find((toolCall) =>
- toolCallMatchesExpectation(toolCall, expectation),
- );
- if (violated) {
- return {
- pass: false,
- comment: `Expected no actual tool call matching [${formatToolCallExpectation(expectation)}], but saw ${violated.toolName} with args ${JSON.stringify(violated.args)}.`,
- invokedTools,
- spawnedAgents,
- };
- }
- }
- }
-
- return {
- pass: true,
- comment: 'Discovery expectation satisfied.',
- invokedTools,
- spawnedAgents,
- };
-}
-
-/**
- * Only a run that finished on its own terms can settle an expectation, so anything else
- * fails whichever way the expectation points — a truncated run that satisfied a positive
- * expectation still means the agent never got to finish what it was doing.
- */
-export function evaluateDiscoveryTrial(
- scenario: DiscoveryTestCase,
- outcome: EventOutcome,
- trial: DiscoveryTrialFacts,
-): DiscoveryCheckResult {
- const check = runExpectedToolsInvokedCheck(scenario, outcome);
- const invalid = invalidTrialReason(trial);
- if (!invalid) return check;
-
- return { ...check, pass: false, comment: check.pass ? invalid : `${invalid} ${check.comment}` };
-}
-
-function invalidTrialReason(trial: DiscoveryTrialFacts): string | undefined {
- switch (trial.streamStatus) {
- case 'timed-out':
- return `Run exceeded its ${String(trial.timeoutMs)}ms budget and was abandoned.`;
- case 'step-exhausted':
- return 'Run stopped at its step cap instead of finishing.';
- case 'errored':
- case 'suspended':
- return `Run did not complete (${trial.runError ? `${trial.streamStatus}: ${trial.runError}` : trial.streamStatus}).`;
- case 'completed':
- return trial.unmatchedConfirmations.length > 0
- ? `Scenario declared confirmation answers for [${trial.unmatchedConfirmations.join(', ')}] that no suspension asked for, so those decisions never ran.`
- : undefined;
- }
-}
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/runner.ts b/packages/@n8n/instance-ai/evaluations/discovery/runner.ts
deleted file mode 100644
index a714b2e4669..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/runner.ts
+++ /dev/null
@@ -1,359 +0,0 @@
-// ---------------------------------------------------------------------------
-// In-process discovery runner.
-//
-// Drives the orchestrator with a scenario's userMessage + instanceState,
-// captures InstanceAi events into the CapturedEvent[] shape that
-// extractOutcomeFromEvents consumes, then runs the discovery check. No
-// Docker, no n8n server — the orchestrator runs in-process against
-// stubbed services.
-//
-// What's tested: the orchestrator's first dispatch decision. Tools are NOT
-// stubbed — when the orchestrator loads a runtime skill or reaches for a
-// Computer Use browser tool, the tool-call event fires before any downstream
-// failure, so the discovery check still sees the dispatch intent. The wall-clock
-// timeout bounds the trial, not the run: at the budget the trial stops and fails,
-// and the abandoned stream is left to unwind on its own. Scenarios or --max-steps
-// can additionally opt into an iteration cap.
-// ---------------------------------------------------------------------------
-
-import type { InstanceAiEvent, TaskList } from '@n8n/api-types';
-import { nanoid } from 'nanoid';
-
-import {
- buildConfirmationPolicy,
- resolveConfirmation,
- unmatchedConfirmations,
- type ApprovalResponder,
-} from './confirmation-policy';
-import { credentialAutoSetupResponder } from './credential-approval';
-import { evaluateDiscoveryTrial } from './expected-tools-invoked';
-import { resolveStreamStatus } from './stream-status';
-import { createStubLocalMcpServer } from './stub-local-mcp';
-import {
- createMcpConnectResponder,
- createStubMcpRegistry,
- createStubMcpToolRegistry,
- StubMcpClientManager,
- stubMcpServerConfigs,
- type StubMcpRegistry,
-} from './stub-mcp-registry';
-import type { DiscoveryCheckResult, DiscoveryStreamStatus, DiscoveryTestCase } from './types';
-import { createInstanceAgent } from '../../src/agent/instance-agent';
-import type { InstanceAiEventBus } from '../../src/event-bus';
-import type { Logger } from '../../src/logger';
-import {
- executeResumableStream,
- normalizeStreamSource,
-} from '../../src/runtime/resumable-stream-executor';
-import { loadInstanceAiRuntimeSkillSource } from '../../src/skills/runtime-skills';
-import type {
- BuilderTurnStream,
- InstanceAiContext,
- InstanceAiBuilderDelegate,
- ComputerUseState,
- ModelConfig,
- OrchestrationContext,
- TaskStorage,
-} from '../../src/types';
-import { isAgentFeatureEnabled } from '../../src/utils/agent-feature-enabled';
-import { asResumable, type SuspensionInfo } from '../../src/utils/stream-helpers';
-import { createInMemoryEventBus, wrapEventBusWithObserver } from '../harness/in-memory-event-bus';
-import { createStubServices, defaultNodesJsonPath } from '../harness/stub-services';
-import { createStubWorkspace, stubWorkspaceRoot } from '../harness/stub-workspace';
-import { extractOutcomeFromEvents } from '../outcome/event-parser';
-import type { CapturedEvent, EventOutcome } from '../types';
-
-// ---------------------------------------------------------------------------
-// Public API
-// ---------------------------------------------------------------------------
-
-export interface DiscoveryRunOptions {
- scenario: DiscoveryTestCase;
- modelId: ModelConfig;
- /** Defaults to `defaultNodesJsonPath()`. */
- nodesJsonPath?: string;
- /** Hard cap on agent steps. Unset leaves the SDK's own 30-iteration ceiling in place. */
- maxSteps?: number;
- /** Per-trial timeout in ms. */
- timeoutMs?: number;
-}
-
-export interface DiscoveryRunResult {
- scenario: DiscoveryTestCase;
- check: DiscoveryCheckResult;
- events: CapturedEvent[];
- outcome: EventOutcome;
- durationMs: number;
- /** Final agent status — useful for diagnosing why noop / unexpected loops happened. */
- streamStatus: DiscoveryStreamStatus;
- /** Populated when the run errored before reaching the check. */
- runError?: string;
-}
-
-export async function runDiscoveryScenario(
- options: DiscoveryRunOptions,
-): Promise {
- const started = Date.now();
- // Unset by default: the orchestrator legitimately explores past any small fixed cap
- // (data-table-workflow needs >8 iterations). Unset still lands on the SDK's own
- // 30-iteration ceiling, which reports `step-exhausted`.
- const maxSteps = options.scenario?.maxSteps ?? options.maxSteps;
- const timeoutMs = options.scenario?.timeoutMs ?? options.timeoutMs ?? 60_000;
- const nodesJsonPath = options.nodesJsonPath ?? defaultNodesJsonPath();
-
- const events: CapturedEvent[] = [];
-
- let streamStatus: DiscoveryRunResult['streamStatus'] = 'completed';
- let runError: string | undefined;
-
- const confirmationPolicy = buildConfirmationPolicy(options.scenario);
- const suspensions = new Map();
-
- const abortController = new AbortController();
- let mcpManager: StubMcpClientManager | undefined;
- let timeoutHandle: NodeJS.Timeout | undefined;
- const budgetExpired = new Promise<'timed-out'>((resolve) => {
- timeoutHandle = setTimeout(() => {
- abortController.abort();
- resolve('timed-out');
- }, timeoutMs);
- });
-
- try {
- const services = await createStubServices({ nodesJsonPath });
- const mcpState = options.scenario.instanceState?.mcp;
- const mcpRegistry = mcpState ? createStubMcpRegistry(mcpState) : undefined;
- const context: InstanceAiContext = {
- ...applyInstanceState(services.context, options.scenario, mcpRegistry),
- ...(isAgentFeatureEnabled() ? { builderDelegate: createStubBuilderDelegate() } : {}),
- workspace: createStubWorkspace(),
- workspaceRoot: stubWorkspaceRoot,
- };
-
- mcpManager = new StubMcpClientManager(createStubMcpToolRegistry(mcpState ?? {}));
- const threadId = 'discovery-thread-' + nanoid(6);
- const runId = 'discovery-run-' + nanoid(6);
-
- const approvalResponders: ApprovalResponder[] = [
- credentialAutoSetupResponder,
- ...(mcpRegistry ? [createMcpConnectResponder(mcpRegistry)] : []),
- ];
-
- const eventBus = wrapEventBusWithObserver(createInMemoryEventBus(), (event) => {
- events.push(toCapturedEvent(event));
- });
-
- // `OrchestrationContext` is required for the orchestrator to receive tools like
- // `create-tasks` and runtime skills. Discovery scenarios measure first-step
- // tool-call decisions, not background execution.
- const orchestrationContext = createStubOrchestrationContext({
- context,
- modelId: options.modelId,
- eventBus,
- threadId,
- runId,
- abortSignal: abortController.signal,
- });
-
- const { agent } = await createInstanceAgent({
- modelId: options.modelId,
- context,
- orchestrationContext,
- mcpServers: stubMcpServerConfigs(mcpState ?? {}),
- mcpManager,
- // No memory: discovery measures stateless first-step tool dispatch.
- memoryConfig: {},
- thinkingEnabled: false,
- });
-
- const streamSource = normalizeStreamSource(
- await agent.stream(options.scenario.userMessage, {
- maxIterations: maxSteps,
- abortSignal: abortController.signal,
- providerOptions: {
- anthropic: { cacheControl: { type: 'ephemeral' as const } },
- },
- }),
- );
-
- const run = executeResumableStream({
- agent: asResumable(agent),
- stream: streamSource,
- context: {
- threadId,
- runId,
- agentId: 'n8n-instance-agent',
- eventBus,
- signal: abortController.signal,
- logger: silentLogger(),
- },
- control: {
- mode: 'auto',
- onSuspension: (suspension) => suspensions.set(suspension.requestId, suspension),
- waitForConfirmation: async (requestId: string): Promise> =>
- await Promise.resolve(
- resolveConfirmation(suspensions.get(requestId), confirmationPolicy, approvalResponders),
- ),
- },
- });
- void run.catch(() => {});
- const result = await Promise.race([run, budgetExpired]);
-
- streamStatus = resolveStreamStatus(result, abortController.signal.aborted);
- } catch (error) {
- runError = error instanceof Error ? error.message : String(error);
- streamStatus = abortController.signal.aborted ? 'timed-out' : 'errored';
- } finally {
- clearTimeout(timeoutHandle);
- await mcpManager?.disconnect();
- }
-
- const observedEvents = [...events];
- const outcome = extractOutcomeFromEvents(observedEvents);
- const check = evaluateDiscoveryTrial(options.scenario, outcome, {
- streamStatus,
- timeoutMs,
- ...(runError ? { runError } : {}),
- unmatchedConfirmations: unmatchedConfirmations(confirmationPolicy, suspensions.values()),
- });
-
- return {
- scenario: options.scenario,
- check,
- // An abandoned stream can still publish, so hand back what the verdict saw.
- events: [...events],
- outcome,
- durationMs: Date.now() - started,
- streamStatus,
- ...(runError ? { runError } : {}),
- };
-}
-
-// ---------------------------------------------------------------------------
-// Internal helpers
-// ---------------------------------------------------------------------------
-
-function applyInstanceState(
- base: InstanceAiContext,
- scenario: DiscoveryTestCase,
- mcpRegistry: StubMcpRegistry | undefined,
-): InstanceAiContext {
- const state = scenario.instanceState;
- if (!state) return base;
-
- const computerUse: ComputerUseState | undefined = state.computerUse;
- // Both channels can serve tools, so the stub server gets the union.
- const liveToolCategories = computerUse
- ? Object.values(computerUse).flatMap((channel) =>
- channel.status === 'connected' ? channel.toolCategories : [],
- )
- : [];
-
- const localMcpServer =
- liveToolCategories.length > 0
- ? createStubLocalMcpServer({
- capabilities: liveToolCategories.filter(
- (c): c is 'browser' | 'filesystem' | 'shell' =>
- c === 'browser' || c === 'filesystem' || c === 'shell',
- ),
- })
- : base.localMcpServer;
-
- return {
- ...base,
- ...(computerUse ? { computerUseState: computerUse } : {}),
- ...(localMcpServer ? { localMcpServer } : {}),
- ...(mcpRegistry ? { mcpService: mcpRegistry.service } : {}),
- ...(state.folderExploration !== undefined
- ? { folderExplorationEnabled: state.folderExploration }
- : {}),
- };
-}
-
-function silentLogger(): Logger {
- return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
-}
-
-function completedBuilderTurn(): BuilderTurnStream {
- return {
- fullStream: (async function* () {
- await Promise.resolve();
- yield {
- type: 'tool-call',
- toolCallId: 'discovery-write',
- toolName: 'write_config',
- input: {},
- };
- yield { type: 'tool-result', toolCallId: 'discovery-write', output: { ok: true } };
- })(),
- text: Promise.resolve('Agent configured for discovery evaluation.'),
- };
-}
-
-function createStubBuilderDelegate(): InstanceAiBuilderDelegate {
- return {
- createAgent: async (name) =>
- await Promise.resolve({
- agentId: 'discovery-agent',
- projectId: 'discovery-project',
- name,
- }),
- streamBuild: async () => await Promise.resolve(completedBuilderTurn()),
- resumeBuild: async () => await Promise.resolve(completedBuilderTurn()),
- findOpenSuspensions: async () => await Promise.resolve([]),
- cancelOpenSuspension: async () => await Promise.resolve(),
- resolveAgentName: async () => await Promise.resolve(undefined),
- };
-}
-
-interface StubOrchestrationContextOptions {
- context: InstanceAiContext;
- modelId: ModelConfig;
- eventBus: InstanceAiEventBus;
- threadId: string;
- runId: string;
- abortSignal: AbortSignal;
-}
-
-function createStubOrchestrationContext(
- opts: StubOrchestrationContextOptions,
-): OrchestrationContext {
- const taskStorage: TaskStorage = {
- // eslint-disable-next-line @typescript-eslint/require-await
- get: async (): Promise => null,
-
- save: async (): Promise => {},
- };
-
- return {
- threadId: opts.threadId,
- runId: opts.runId,
- userId: opts.context.userId,
- orchestratorAgentId: 'n8n-instance-agent',
- modelId: opts.modelId,
- eventBus: opts.eventBus,
- logger: silentLogger(),
- runtimeSkills: loadInstanceAiRuntimeSkillSource(),
- abortSignal: opts.abortSignal,
- taskStorage,
- // Surface the localMcpServer so Computer Use browser tools are available to the
- // orchestrator.
- ...(opts.context.localMcpServer ? { localMcpServer: opts.context.localMcpServer } : {}),
- // Registers the `workspace_*` file tools for build-workflow
- ...(opts.context.workspace ? { workspace: opts.context.workspace } : {}),
- ...(opts.context.workspaceRoot ? { workspaceRoot: opts.context.workspaceRoot } : {}),
- // Used for the orchestrator's untrusted-content doctrine and other domain references.
- // Provide the same context the orchestrator sees.
- domainContext: opts.context,
- };
-}
-
-function toCapturedEvent(event: InstanceAiEvent): CapturedEvent {
- return {
- timestamp: Date.now(),
- type: event.type,
- // `extractOutcomeFromEvents` reads `data.payload.toolName` etc. — our
- // InstanceAiEvent already has that shape, so we pass it through directly.
- data: event,
- };
-}
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/stream-status.ts b/packages/@n8n/instance-ai/evaluations/discovery/stream-status.ts
deleted file mode 100644
index 04a975d509f..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/stream-status.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { FinishReason } from '@n8n/agents';
-
-import type { DiscoveryStreamStatus } from './types';
-import type { ExecuteResumableStreamResult } from '../../src/runtime/resumable-stream-executor';
-
-export function resolveStreamStatus(
- result: ExecuteResumableStreamResult | 'timed-out',
- aborted: boolean,
-): DiscoveryStreamStatus {
- if (result === 'timed-out' || aborted) return 'timed-out';
- if (result.status === 'errored') return 'errored';
- if (result.status === 'suspended') return 'suspended';
- if (result.status === 'cancelled') return 'timed-out';
- if (result.finishReason === undefined) return 'errored';
- return statusForFinishReason(result.finishReason);
-}
-
-function statusForFinishReason(finishReason: FinishReason): DiscoveryStreamStatus {
- switch (finishReason) {
- case 'stop':
- return 'completed';
- case 'max-iterations':
- return 'step-exhausted';
- case 'paused':
- return 'suspended';
- case 'tool-calls':
- case 'length':
- case 'content-filter':
- case 'error':
- case 'guardrail':
- case 'other':
- return 'errored';
- default: {
- const unhandled: never = finishReason;
- throw new Error(`Unhandled finish reason: ${String(unhandled)}`);
- }
- }
-}
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/stub-local-mcp.ts b/packages/@n8n/instance-ai/evaluations/discovery/stub-local-mcp.ts
deleted file mode 100644
index 820e31b72aa..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/stub-local-mcp.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-// ---------------------------------------------------------------------------
-// Stub LocalMcpServer for discovery evals.
-//
-// A case declares its connected channels through `instanceState.computerUse`, and
-// the runner plugs in this stub so the agent holds the matching tools without
-// spinning up the real computer-use daemon or the Chrome extension. The stub
-// advertises tools by name and category. It never receives actual `callTool` invocations during
-// discovery scenarios — the orchestrator's first dispatch decision is what we
-// measure, not downstream tool execution.
-// ---------------------------------------------------------------------------
-
-import type { McpTool, McpToolCallRequest, McpToolCallResult } from '@n8n/api-types';
-
-import type { LocalMcpServer } from '../../src/types';
-
-const STUB_BROWSER_TOOL_NAMES = [
- 'browser_connect',
- 'browser_tab_open',
- 'browser_navigate',
- 'browser_snapshot',
- 'browser_content',
- 'browser_click',
- 'browser_type',
- 'browser_screenshot',
- 'browser_capture_secret',
- 'browser_create_credential',
-];
-
-const STUB_FILESYSTEM_TOOL_NAMES = ['fs_read_file', 'fs_search_files', 'fs_list_dir'];
-
-const STUB_SHELL_TOOL_NAMES = ['shell_run'];
-
-function makeStubTool(name: string, category: string): McpTool {
- return {
- name,
- description: `Stub ${category} tool — no-op for discovery evals.`,
- inputSchema: { type: 'object', properties: {} },
- annotations: { category },
- };
-}
-
-export interface CreateStubLocalMcpServerOptions {
- /** Capabilities to advertise. Each capability publishes a small set of stub tool names. */
- capabilities: ReadonlyArray<'browser' | 'filesystem' | 'shell'>;
-}
-
-export function createStubLocalMcpServer(options: CreateStubLocalMcpServerOptions): LocalMcpServer {
- const tools: McpTool[] = [];
- if (options.capabilities.includes('browser')) {
- for (const name of STUB_BROWSER_TOOL_NAMES) tools.push(makeStubTool(name, 'browser'));
- }
- if (options.capabilities.includes('filesystem')) {
- for (const name of STUB_FILESYSTEM_TOOL_NAMES) tools.push(makeStubTool(name, 'filesystem'));
- }
- if (options.capabilities.includes('shell')) {
- for (const name of STUB_SHELL_TOOL_NAMES) tools.push(makeStubTool(name, 'shell'));
- }
-
- return {
- getAvailableTools: () => tools,
- getToolsByCategory: (category: string) =>
- tools.filter((t) => {
- const annotations = t.annotations;
- return (
- annotations !== undefined &&
- typeof annotations === 'object' &&
- (annotations as Record).category === category
- );
- }),
- // eslint-disable-next-line @typescript-eslint/require-await
- callTool: async (req: McpToolCallRequest): Promise => {
- // Return a normal tool-error result rather than throwing. native agent logs an
- // error stack trace for thrown tool errors; an `isError: true` result is
- // treated as expected and surfaced as a `tool-error` event without spam.
- // The discovery check still records the underlying `tool-call` event, so
- // dispatch is captured either way.
- return {
- content: [
- {
- type: 'text' as const,
- text: `Stub: ${req.name} would have executed in production. Discovery evals measure dispatch, not execution.`,
- },
- ],
- isError: true,
- };
- },
- };
-}
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/stub-mcp-registry.ts b/packages/@n8n/instance-ai/evaluations/discovery/stub-mcp-registry.ts
deleted file mode 100644
index 5752cd39f87..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/stub-mcp-registry.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-// ---------------------------------------------------------------------------
-// Stub MCP registry + connections for discovery evals. Fakes the registry
-// service; `stub-local-mcp.ts` fakes the computer-use gateway.
-//
-// ---------------------------------------------------------------------------
-
-import { sanitizeToolName, wrapToolForApproval, type BuiltTool } from '@n8n/agents';
-import { DEFAULT_INSTANCE_AI_PERMISSIONS, type InstanceAiPermissions } from '@n8n/api-types';
-import { isRecord } from '@n8n/utils/is-record';
-
-import type { ApprovalResponder } from './confirmation-policy';
-import type { Logger } from '../../src/logger';
-import { McpClientManager } from '../../src/mcp/mcp-client-manager';
-import { createToolRegistry } from '../../src/tool-registry';
-import type {
- InstanceAiMcpService,
- InstanceAiToolRegistry,
- McpRegistryConnectServerSummary,
- McpRegistryServerSummary,
- McpServerConfig,
-} from '../../src/types';
-
-/** What production derives for cli's shared e2e fixtures (`registry/mock-servers.ts`).
- * Search below drops credential options and real relevance scoring. */
-const CATALOGUE: McpRegistryConnectServerSummary[] = [
- {
- slug: 'notion',
- title: 'Notion',
- description: 'Connect to the Notion MCP Server',
- usesCredentials: [
- { credentialType: 'notionMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
- ],
- tools: ['notion-search', 'notion-fetch', 'notion-create-pages'],
- },
- {
- slug: 'linear',
- title: 'Linear',
- description: 'Connect to the Linear MCP Server',
- usesCredentials: [
- { credentialType: 'linearMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
- ],
- tools: ['list_issues', 'get_issue', 'save_issue'],
- },
- {
- slug: 'slack',
- title: 'Slack',
- description: 'Connect to the Slack MCP Server',
- usesCredentials: [
- { credentialType: 'slackMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
- ],
- tools: [],
- },
-];
-
-export interface StubMcpConnection {
- slug: string;
- /** Defaults to the catalogue's tools; empty models a broken connection. */
- tools?: string[];
-}
-
-export interface DiscoveryMcpState {
- registry?: string[];
- connected?: StubMcpConnection[];
-}
-
-export interface StubMcpRegistry {
- service: InstanceAiMcpService;
- markConnected: (slugs: string[]) => void;
-}
-
-function catalogueServer(slug: string): McpRegistryConnectServerSummary {
- const server = CATALOGUE.find((entry) => entry.slug === slug);
- if (!server) {
- const known = CATALOGUE.map((entry) => entry.slug).join(', ');
- throw new Error(`Unknown MCP registry slug "${slug}" — expected one of ${known}.`);
- }
- return server;
-}
-
-export function createStubMcpRegistry(state: DiscoveryMcpState): StubMcpRegistry {
- const servers = (state.registry ?? []).map(catalogueServer);
- const connected = new Set((state.connected ?? []).map((entry) => entry.slug));
-
- const matches = (server: McpRegistryServerSummary, query: string): boolean =>
- `${server.slug} ${server.title} ${server.description}`
- .toLowerCase()
- .includes(query.trim().toLowerCase());
-
- return {
- markConnected: (slugs) => slugs.forEach((slug) => connected.add(slug)),
- service: {
- // Connected servers are filtered out, as in the adapter.
- search: async (queries) =>
- await Promise.resolve(
- servers.filter(
- (server) =>
- !connected.has(server.slug) && queries.some((query) => matches(server, query)),
- ).map(({ slug, title, description, tools }) => ({ slug, title, description, tools })),
- ),
- getServers: async (slugs) =>
- await Promise.resolve(servers.filter((server) => slugs.includes(server.slug))),
- listConnections: async () => await Promise.resolve([...connected].map((slug) => ({ slug }))),
- },
- };
-}
-
-/** Approving a connect card claims the offered slugs on resume *and* makes them
- * live here — `resumeConnect` counts a slug only when both hold. */
-export function createMcpConnectResponder(registry: StubMcpRegistry): ApprovalResponder {
- return (payload) => {
- const request = payload.mcpConnectRequest;
- if (!isRecord(request) || !Array.isArray(request.servers)) return undefined;
-
- const slugs = request.servers
- .map((server) => (isRecord(server) ? server.serverSlug : undefined))
- .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0);
- if (slugs.length === 0) return undefined;
-
- registry.markConnected(slugs);
- return { connectedSlugs: slugs };
- };
-}
-
-/** `listConnectedMcpServices` pairs a tool with its slug through this name. */
-function serverName(slug: string): string {
- return `mcp_${slug}`;
-}
-
-export function stubMcpServerConfigs(state: DiscoveryMcpState): McpServerConfig[] {
- return (state.connected ?? []).map(({ slug }) => ({
- name: serverName(slug),
- url: `https://${slug}.mcp.invalid/mcp`,
- metadata: { serverSlug: slug },
- }));
-}
-
-/** Mirrors `mcp-tool-resolver`'s output, down to the server-prefixed name. */
-function stubMcpTool(rawName: string, slug: string): BuiltTool {
- return {
- name: sanitizeToolName(`${serverName(slug)}_${rawName}`),
- description: `${slug} MCP tool.`,
- inputSchema: { type: 'object', properties: {} },
- mcpTool: true,
- mcpServerName: serverName(slug),
- mcpToolName: rawName,
- // An empty or apologetic result reads to the agent as an expired connection
- // and sends it to `connect` — a stub artefact that looks like a regression.
- handler: async () =>
- await Promise.resolve({
- ok: true,
- items: [{ id: `${slug}-eval-stub-1`, title: 'Eval harness result' }],
- }),
- };
-}
-
-export function createStubMcpToolRegistry(state: DiscoveryMcpState): InstanceAiToolRegistry {
- const registry = createToolRegistry();
- for (const { slug, tools } of state.connected ?? []) {
- for (const rawName of tools ?? catalogueServer(slug).tools) {
- const tool = stubMcpTool(rawName, slug);
- registry.set(tool.name, tool);
- }
- }
- return registry;
-}
-
-/** Production wraps MCP tools in the same gate whenever the caller asks for
- * approval; the real path goes through `McpConnection.listTools`, which this stub
- * bypasses. */
-function withApprovalGate(tools: InstanceAiToolRegistry): InstanceAiToolRegistry {
- const gated = createToolRegistry();
- for (const [name, tool] of tools) {
- gated.set(name, wrapToolForApproval(tool, { requireApproval: true }));
- }
- return gated;
-}
-
-/** `CreateInstanceAgentOptions.mcpManager` is the concrete class, so this
- * subclasses rather than structurally fakes it. */
-export class StubMcpClientManager extends McpClientManager {
- constructor(private readonly stubTools: InstanceAiToolRegistry) {
- super();
- }
-
- override async getRegularTools(
- _configs: McpServerConfig[],
- _logger?: Logger,
- {
- mcpRead,
- mcpWrite,
- }: Pick = DEFAULT_INSTANCE_AI_PERMISSIONS,
- ) {
- const requireApproval = mcpRead !== 'always_allow' || mcpWrite !== 'always_allow';
- const tools = requireApproval ? withApprovalGate(this.stubTools) : this.stubTools;
- return await Promise.resolve({ tools, connectionFailures: [] });
- }
-
- override async disconnect() {
- await Promise.resolve();
- }
-}
diff --git a/packages/@n8n/instance-ai/evaluations/discovery/types.ts b/packages/@n8n/instance-ai/evaluations/discovery/types.ts
deleted file mode 100644
index 8f97f82f80b..00000000000
--- a/packages/@n8n/instance-ai/evaluations/discovery/types.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-// ---------------------------------------------------------------------------
-// Tool-discovery scenario types — guards browser/computer-use discoverability.
-//
-// Discovery scenarios run the orchestrator against a user message that should
-// (or should not) cause a specific tool / sub-agent to be reached for, and
-// assert the captured events match the expectation. Workflow-build scenarios
-// in evaluations/data/workflows/ assert the *output workflow*; discovery
-// scenarios assert the *agent's tool/dispatch behavior*.
-// ---------------------------------------------------------------------------
-
-import type { DiscoveryMcpState } from './stub-mcp-registry';
-import type { ComputerUseState } from '../../src/types';
-
-/**
- * Pass condition for tool invocations.
- *
- * - `anyOf` — pass if at least one of the listed tool names was invoked
- * (top-level orchestrator call, or via a spawned sub-agent's tool list).
- * - `noneOf` — pass only if NONE of the listed tool names was invoked.
- * Used for negative scenarios that guard against over-eager invocation.
- * - `anyOfToolCalls` — pass if at least one listed actual tool call happened.
- * Unlike `anyOf`, this can check serialized tool args.
- * - `allOfToolCalls` — pass only if EVERY listed actual tool call happened.
- * Unlike `anyOf`, this checks completed/errored tool calls only; a spawned
- * sub-agent having the tool available does not count as a match.
- * - `noneOfToolCalls` — pass only if NONE of the listed tool calls happened.
- * Unlike `noneOf`, this checks actual tool calls only; a spawned sub-agent
- * having the tool available does not count as a violation.
- *
- * Both forms accept a sub-agent role prefix `spawn_sub_agent:` to match
- * an `agent-spawned` event whose role equals ``.
- */
-export interface ForbiddenToolCall {
- toolName: string;
- /** Deep-partial match: listed keys must match, extra keys ignored, pattern
- * arrays matched as subsets. Prefer this for schema'd fields — a substring
- * can't tell a value from the same word in a free-text sibling. */
- args?: Record;
- /** Case-insensitive substrings over the serialized args, for free-text
- * fields. Combines with `args`; both must hold on the same call. */
- argsContainAny?: string[];
- /** Match a call the user refused (an approval-gated tool resuming with
- * `{ declined: true }`) instead of one that ran. Defaults to false, so an
- * expectation never matches a refusal by accident. */
- declined?: boolean;
-}
-
-export interface ExpectedToolInvocations {
- anyOf?: string[];
- noneOf?: string[];
- anyOfToolCalls?: ForbiddenToolCall[];
- allOfToolCalls?: ForbiddenToolCall[];
- noneOfToolCalls?: ForbiddenToolCall[];
-}
-
-/**
- * Optional instance configuration overrides for a discovery scenario.
- * Mirrors a subset of `SystemPromptOptions` so a scenario can pin the gateway
- * status (e.g. `disabledGlobally` to test the "explain how to enable" branch).
- */
-export interface DiscoveryInstanceState {
- computerUse?: ComputerUseState;
- mcp?: DiscoveryMcpState;
- /** Turn the folder-exploration capability on for the run (default off). */
- folderExploration?: boolean;
-}
-
-export type ConfirmationDecision = 'approve' | 'deny';
-
-export interface ConfirmationAnswer {
- decision: ConfirmationDecision;
- resumeWith?: Record;
-}
-
-// Keyed by the suspending tool's name
-export type DiscoveryConfirmations = Record;
-
-export interface DiscoveryTestCase {
- /** Unique scenario identifier — also used as the scenario filename (without .json). */
- id: string;
- /** The user message sent to the orchestrator. */
- userMessage: string;
- /** Optional instance state overrides applied when constructing the agent. */
- instanceState?: DiscoveryInstanceState;
- /** Answers for the tools listed; every other suspension is approved. */
- confirmations?: DiscoveryConfirmations;
- /** Pass condition. At least one expectation key is required. */
- expectedToolInvocations: ExpectedToolInvocations;
- /** Free-form note explaining what regression this scenario protects against. */
- rationale?: string;
- /** Override the runner step cap when orchestrator-inline work needs more iterations. */
- maxSteps?: number;
- /** Override the runner timeout when a scenario needs more time. */
- timeoutMs?: number;
-}
-
-export type DiscoveryStreamStatus =
- | 'completed'
- | 'errored'
- | 'timed-out'
- | 'suspended'
- | 'step-exhausted';
-
-export interface DiscoveryTrialFacts {
- streamStatus: DiscoveryStreamStatus;
- timeoutMs: number;
- runError?: string;
- unmatchedConfirmations: string[];
-}
-
-export interface DiscoveryCheckResult {
- pass: boolean;
- /** Human-readable reason — included in failure reports. */
- comment: string;
- /** Tool names actually invoked during the run (top-level + via sub-agents). */
- invokedTools: string[];
- /** `spawn_sub_agent:` markers for every spawned sub-agent. */
- spawnedAgents: string[];
-}
diff --git a/packages/@n8n/instance-ai/package.json b/packages/@n8n/instance-ai/package.json
index b846e314fa1..ee47dc5e6cc 100644
--- a/packages/@n8n/instance-ai/package.json
+++ b/packages/@n8n/instance-ai/package.json
@@ -23,7 +23,6 @@
"eval:pairwise:compare": "tsx evaluations/cli/compare-pairwise.ts",
"eval:subagent": "tsx evaluations/subagent/cli.ts",
"eval:computer-use": "tsx evaluations/computer-use/cli.ts",
- "eval:discovery": "tsx evaluations/discovery/cli.ts",
"prompts:print": "tsx scripts/print-prompts.ts"
},
"main": "dist/index.js",
diff --git a/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md b/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md
index 48ba8200e8b..d45eaae5a46 100644
--- a/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md
+++ b/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md
@@ -45,12 +45,6 @@ If a task appears stuck, tell the user and stop; do not try to detect completion
yourself. Do not re-dispatch a build whose task ID is already visible in
``.
-When `` context is present, use it only to reference active task
-IDs for cancellation or corrections.
-
-If the user sends a correction while a build is running, call
-`task-control(action="correct-task")` with the task ID and correction.
-
## Synthesize follow-up
When `` is present, all planned tasks
diff --git a/packages/@n8n/instance-ai/src/__tests__/index.test.ts b/packages/@n8n/instance-ai/src/__tests__/index.test.ts
index 9d5412503a2..00356bff782 100644
--- a/packages/@n8n/instance-ai/src/__tests__/index.test.ts
+++ b/packages/@n8n/instance-ai/src/__tests__/index.test.ts
@@ -66,7 +66,6 @@ vi.mock('../storage', () => ({
ThreadTaskStorage: class ThreadTaskStorage {},
PlannedTaskStorage: class PlannedTaskStorage {},
getThread: () => ({ id: 'thread-1' }),
- TerminalOutcomeStorage: class TerminalOutcomeStorage {},
patchThread: () => ({ id: 'thread-1' }),
WorkflowLoopStorage: class WorkflowLoopStorage {},
}));
@@ -108,40 +107,10 @@ vi.mock('../workspace/sandbox-setup', () => ({
setupSandboxWorkspace: () => undefined,
}));
vi.mock('../workspace/snapshot-manager', () => ({ SnapshotManager: class SnapshotManager {} }));
-vi.mock('../runtime/background-task-manager', () => ({
- BackgroundTaskManager: class BackgroundTaskManager {},
- enrichMessageWithRunningTasks: () => ({ content: 'message' }),
-}));
vi.mock('../runtime/run-state-registry', () => ({ RunStateRegistry: class RunStateRegistry {} }));
vi.mock('../runtime/terminal-response-guard', () => ({
InstanceAiTerminalResponseGuard: class InstanceAiTerminalResponseGuard {},
}));
-vi.mock('../runtime/resumable-stream-executor', () => ({
- executeResumableStream: () => ({ status: 'done' }),
-}));
-vi.mock('../runtime/stream-runner', () => ({
- resumeAgentRun: () => ({ status: 'resumed' }),
- streamAgentRun: () => ({ status: 'streamed' }),
-}));
-vi.mock('../runtime/liveness-policy', () => {
- const INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG = {
- confirmationTimeoutMs: 1,
- backgroundTaskIdleTimeoutMs: 1,
- backgroundTaskMaxLifetimeMs: 1,
- activeRunIdleTimeoutMs: 0,
- activeRunMaxLifetimeMs: 1,
- };
- class InstanceAiLivenessPolicy {
- hasEnabledTimeouts() {
- return true;
- }
- }
- return {
- INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
- createInstanceAiLivenessPolicyConfig: () => INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
- InstanceAiLivenessPolicy,
- };
-});
vi.mock('../workflow-loop', () => ({
workflowBuildOutcomeSchema: { safeParse: () => ({ success: true }) },
attemptRecordSchema: { safeParse: () => ({ success: false }) },
@@ -188,7 +157,6 @@ describe('@n8n/instance-ai public entrypoint', () => {
expect(entrypoint.getParseableAttachmentMimeTypes).toEqual(expect.any(Function));
expect(entrypoint.mapAgentChunkToEvent).toEqual(expect.any(Function));
expect(entrypoint.wrapUntrustedData).toEqual(expect.any(Function));
- expect(entrypoint.BackgroundTaskManager).toEqual(expect.any(Function));
expect(entrypoint.IdRemapper).toEqual(expect.any(Function));
expect(entrypoint.McpClientManager).toEqual(expect.any(Function));
expect(entrypoint.UnsupportedAttachmentError).toEqual(expect.any(Function));
@@ -242,9 +210,6 @@ describe('@n8n/instance-ai public entrypoint', () => {
expect(construct(entrypoint.ThreadTaskStorage)).toBeInstanceOf(entrypoint.ThreadTaskStorage);
expect(construct(entrypoint.PlannedTaskStorage)).toBeInstanceOf(entrypoint.PlannedTaskStorage);
expect(call(entrypoint.getThread)).toEqual({ id: 'thread-1' });
- expect(construct(entrypoint.TerminalOutcomeStorage)).toBeInstanceOf(
- entrypoint.TerminalOutcomeStorage,
- );
expect(call(entrypoint.patchThread)).toEqual({ id: 'thread-1' });
expect(construct(entrypoint.WorkflowLoopStorage)).toBeInstanceOf(
entrypoint.WorkflowLoopStorage,
@@ -277,25 +242,10 @@ describe('@n8n/instance-ai public entrypoint', () => {
expect(call(entrypoint.createWorkspace)).toEqual({ type: 'workspace' });
expect(construct(entrypoint.SnapshotManager)).toBeInstanceOf(entrypoint.SnapshotManager);
- expect(construct(entrypoint.BackgroundTaskManager)).toBeInstanceOf(
- entrypoint.BackgroundTaskManager,
- );
- expect(call(entrypoint.enrichMessageWithRunningTasks)).toEqual({ content: 'message' });
- expect(call(entrypoint.enrichMessageWithBackgroundTasks)).toEqual({ content: 'message' });
expect(construct(entrypoint.RunStateRegistry)).toBeInstanceOf(entrypoint.RunStateRegistry);
expect(construct(entrypoint.InstanceAiTerminalResponseGuard)).toBeInstanceOf(
entrypoint.InstanceAiTerminalResponseGuard,
);
- expect(call(entrypoint.executeResumableStream)).toEqual({ status: 'done' });
- expect(call(entrypoint.resumeAgentRun)).toEqual({ status: 'resumed' });
- expect(call(entrypoint.streamAgentRun)).toEqual({ status: 'streamed' });
-
- expect(call(entrypoint.createInstanceAiLivenessPolicyConfig)).toEqual(
- entrypoint.INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
- );
- expect(construct(entrypoint.InstanceAiLivenessPolicy)).toBeInstanceOf(
- entrypoint.InstanceAiLivenessPolicy,
- );
expect(construct(entrypoint.WorkflowTaskCoordinator)).toBeInstanceOf(
entrypoint.WorkflowTaskCoordinator,
);
diff --git a/packages/@n8n/instance-ai/src/debug/__tests__/run-debug-buffer.test.ts b/packages/@n8n/instance-ai/src/debug/__tests__/run-debug-buffer.test.ts
deleted file mode 100644
index b0b3a78ff1c..00000000000
--- a/packages/@n8n/instance-ai/src/debug/__tests__/run-debug-buffer.test.ts
+++ /dev/null
@@ -1,282 +0,0 @@
-import type { GenerateTextStepEndEvent, GenerateTextStepStartEvent } from 'ai';
-import { describe, expect, it } from 'vitest';
-import { mock } from 'vitest-mock-extended';
-import { z } from 'zod';
-
-import type { Logger } from '../../logger';
-import {
- RunDebugBuffer,
- buildRunDebugLabel,
- createRunDebugStepHooks,
- sanitizeStepFinish,
- sanitizeStepStart,
-} from '../run-debug-buffer';
-import { sanitizeDebugSnapshotValue } from '../sanitize-debug-snapshot';
-
-function makeStepEndEvent(text: string): GenerateTextStepEndEvent {
- return {
- stepNumber: 0,
- text,
- toolCalls: [],
- finishReason: 'tool-calls',
- usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
- response: {
- modelId: 'test-model',
- timestamp: new Date('2026-01-01T00:00:00.000Z'),
- messages: [{ role: 'assistant', content: text }],
- },
- } as unknown as GenerateTextStepEndEvent;
-}
-
-function makeStepStartEvent(): GenerateTextStepStartEvent {
- return {
- stepNumber: 0,
- system: 'You are helpful',
- messages: [{ role: 'user', content: 'hello' }],
- tools: { search: { description: 'search', inputSchema: { type: 'object' } } },
- toolChoice: 'auto',
- activeTools: ['search'],
- abortSignal: new AbortController().signal,
- } as unknown as GenerateTextStepStartEvent;
-}
-
-describe('RunDebugBuffer', () => {
- it('pairs step start and finish by run-scoped step index', () => {
- const buffer = new RunDebugBuffer();
- buffer.ensure('run-1', 'thread-1');
-
- buffer.recordStepStart('run-1', 0, makeStepStartEvent());
- buffer.recordStepFinish('run-1', 0, makeStepEndEvent('done'));
-
- const record = buffer.get('run-1');
- expect(record?.steps).toHaveLength(1);
- expect(record?.steps[0]?.stepNumber).toBe(0);
- expect(record?.steps[0]?.input?.messages).toBeDefined();
- expect(record?.steps[0]?.output?.text).toBe('done');
- });
-
- it('records multiple orchestrator iterations even when SDK stepNumber stays 0', () => {
- const buffer = new RunDebugBuffer();
- buffer.ensure('run-1', 'thread-1');
- const hooks = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
-
- for (const label of ['first', 'second', 'third']) {
- hooks.onStepStart(makeStepStartEvent());
- hooks.onStepEnd(makeStepEndEvent(label));
- }
-
- const record = buffer.get('run-1');
- expect(record?.steps).toHaveLength(3);
- expect(record?.steps.map((step) => step.stepNumber)).toEqual([0, 1, 2]);
- expect(record?.steps.map((step) => step.output?.text)).toEqual(['first', 'second', 'third']);
- });
-
- it('continues step numbering after resume hooks are recreated', () => {
- const buffer = new RunDebugBuffer();
- buffer.ensure('run-1', 'thread-1');
-
- const firstPass = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
- firstPass.onStepStart(makeStepStartEvent());
- firstPass.onStepEnd(makeStepEndEvent('before suspend'));
-
- const resumePass = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
- resumePass.onStepStart(makeStepStartEvent());
- resumePass.onStepEnd(makeStepEndEvent('after resume'));
-
- const record = buffer.get('run-1');
- expect(record?.steps).toHaveLength(2);
- expect(record?.steps[1]?.stepNumber).toBe(1);
- expect(record?.steps[1]?.output?.text).toBe('after resume');
- });
-
- it('keeps full tool definitions and strips abortSignal from step start', () => {
- const sanitized = sanitizeStepStart(
- {
- stepNumber: 1,
- system: 'system prompt',
- messages: [{ role: 'user', content: 'hello' }],
- tools: {
- search: { description: 'search', inputSchema: { type: 'object', properties: { q: {} } } },
- },
- toolChoice: 'auto',
- activeTools: ['search'],
- abortSignal: new AbortController().signal,
- } as unknown as GenerateTextStepStartEvent,
- 4,
- );
-
- expect(sanitized.stepNumber).toBe(4);
- expect(sanitized.sdkStepNumber).toBe(1);
- expect(sanitized.activeTools).toEqual(['search']);
- expect(sanitized.tools).toEqual({
- search: { description: 'search', inputSchema: { type: 'object', properties: { q: {} } } },
- });
- expect(sanitized).not.toHaveProperty('abortSignal');
- });
-
- it('drops step-start data that repeats other captured fields', () => {
- const stepTools = [{ type: 'function', name: 'search', inputSchema: { type: 'object' } }];
- const sanitized = sanitizeStepStart(
- {
- stepNumber: 0,
- instructions: 'system prompt',
- messages: [{ role: 'user', content: 'hello' }],
- tools: { search: { description: 'search', inputSchema: z.object({ q: z.string() }) } },
- stepTools,
- promptMessages: [{ role: 'system', content: 'system prompt' }],
- steps: [{ stepNumber: 0, text: 'earlier step' }],
- modelId: 'claude',
- } as unknown as GenerateTextStepStartEvent,
- 0,
- );
-
- expect(sanitized.stepTools).toEqual(stepTools);
- expect(sanitized.modelId).toBe('claude');
- for (const key of ['tools', 'promptMessages', 'steps']) {
- expect(sanitized).not.toHaveProperty(key);
- }
- });
-
- it('drops empty step-start containers and keeps populated ones', () => {
- const sanitized = sanitizeStepStart(
- {
- stepNumber: 0,
- runtimeContext: {},
- toolsContext: { search: { region: 'eu' } },
- activeTools: [],
- } as unknown as GenerateTextStepStartEvent,
- 0,
- );
-
- expect(sanitized).not.toHaveProperty('runtimeContext');
- expect(sanitized).not.toHaveProperty('activeTools');
- expect(sanitized.toolsContext).toEqual({ search: { region: 'eu' } });
- });
-
- it('keeps tool descriptions without Zod internals when stepTools is missing', () => {
- const sanitized = sanitizeStepStart(
- {
- stepNumber: 0,
- tools: { search: { description: 'search', inputSchema: z.object({ q: z.string() }) } },
- } as unknown as GenerateTextStepStartEvent,
- 0,
- );
-
- expect(sanitized.tools).toEqual({ search: { description: 'search' } });
- });
-
- it('does not truncate long captured strings', () => {
- const longSystem = 'x'.repeat(5_000);
- const sanitized = sanitizeDebugSnapshotValue(longSystem);
- expect(sanitized).toHaveLength(5_000);
- });
-
- it('captures full tool call inputs and outputs on step finish', () => {
- const sanitized = sanitizeStepFinish(
- {
- stepNumber: 0,
- text: '',
- toolCalls: [
- {
- toolCallId: 'tc-1',
- toolName: 'build-workflow',
- input: { code: 'full workflow code payload' },
- },
- ],
- toolResults: [
- {
- toolCallId: 'tc-1',
- toolName: 'build-workflow',
- output: { success: true, workflowId: 'wf-1' },
- },
- ],
- finishReason: 'tool-calls',
- usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
- response: {
- modelId: 'test-model',
- timestamp: new Date('2026-01-01T00:00:00.000Z'),
- messages: [{ role: 'assistant', content: '' }],
- body: { secret: 'raw-provider-body' },
- },
- } as unknown as GenerateTextStepEndEvent,
- 0,
- );
-
- expect(sanitized.toolCalls).toEqual([
- {
- toolCallId: 'tc-1',
- toolName: 'build-workflow',
- input: { code: 'full workflow code payload' },
- },
- ]);
- expect(sanitized.toolResults).toEqual([
- {
- toolCallId: 'tc-1',
- toolName: 'build-workflow',
- output: { success: true, workflowId: 'wf-1' },
- },
- ]);
- expect(sanitized.response).not.toHaveProperty('body');
- });
-
- it('evicts oldest run when cap is exceeded', () => {
- const logger = mock();
- const buffer = new RunDebugBuffer(logger);
-
- for (let index = 0; index < 51; index++) {
- const runId = `run-${index}`;
- buffer.ensure(runId, 'thread-1');
- buffer.recordStepStart(runId, 0, {
- stepNumber: 0,
- messages: [],
- } as unknown as GenerateTextStepStartEvent);
- }
-
- expect(buffer.get('run-0')).toBeUndefined();
- expect(buffer.get('run-50')).toBeDefined();
- expect(logger.warn).toHaveBeenCalled();
- });
-
- it('createRunDebugStepHooks writes into the buffer', () => {
- const buffer = new RunDebugBuffer();
- buffer.ensure('run-1', 'thread-1');
- const hooks = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
-
- hooks.onStepStart({
- stepNumber: 2,
- messages: [{ role: 'user', content: 'ping' }],
- } as unknown as GenerateTextStepStartEvent);
-
- expect(buffer.get('run-1')?.steps[0]?.stepNumber).toBe(0);
- expect(buffer.get('run-1')?.steps[0]?.input?.sdkStepNumber).toBe(2);
- });
-
- it('exposes onStepEnd and keeps onStepFinish as a compatibility alias', () => {
- const buffer = new RunDebugBuffer();
- const hooks = createRunDebugStepHooks(buffer, { runId: 'run-1', threadId: 'thread-1' });
-
- expect(typeof hooks.onStepEnd).toBe('function');
- expect(hooks.onStepFinish).toBe(hooks.onStepEnd);
- });
-
- it('stores a run label on first ensure', () => {
- const buffer = new RunDebugBuffer();
- buffer.ensure('run-1', 'thread-1', 'build a weather workflow');
- expect(buffer.get('run-1')?.label).toBe('build a weather workflow');
- buffer.ensure('run-1', 'thread-1', 'ignored');
- expect(buffer.get('run-1')?.label).toBe('build a weather workflow');
- });
-});
-
-describe('buildRunDebugLabel', () => {
- it('uses the user message when no resume reason is provided', () => {
- expect(buildRunDebugLabel({ message: 'build a simple workflow' })).toBe(
- 'build a simple workflow',
- );
- });
-
- it('maps resume reasons to action labels', () => {
- expect(buildRunDebugLabel({ resumeReason: 'approval' })).toBe('Resume · approval');
- expect(buildRunDebugLabel({ resumeReason: 'replan' })).toBe('Follow-up · replan');
- });
-});
diff --git a/packages/@n8n/instance-ai/src/debug/__tests__/sanitize-debug-snapshot.test.ts b/packages/@n8n/instance-ai/src/debug/__tests__/sanitize-debug-snapshot.test.ts
deleted file mode 100644
index c645957aaa0..00000000000
--- a/packages/@n8n/instance-ai/src/debug/__tests__/sanitize-debug-snapshot.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import {
- sanitizeDebugSnapshotRecord,
- sanitizeDebugSnapshotValue,
-} from '../sanitize-debug-snapshot';
-
-describe('sanitizeDebugSnapshotValue', () => {
- it('redacts secrets and omits runtime-only payloads', () => {
- expect(
- sanitizeDebugSnapshotRecord({
- apiKey: 'plain-value',
- message: 'token sk-abcdefghijklmnop',
- abortSignal: new AbortController().signal,
- response: { body: 'raw provider response', status: 500 },
- }),
- ).toEqual({
- apiKey: '[redacted]',
- message: '[REDACTED]',
- response: { status: 500 },
- });
- });
-
- it('scrubs error messages', () => {
- expect(sanitizeDebugSnapshotValue(new Error('failed with sk-abcdefghijklmnop'))).toEqual({
- name: 'Error',
- message: 'failed with [REDACTED]',
- });
- });
-
- it('handles circular values', () => {
- const value: Record = {};
- value.self = value;
-
- expect(sanitizeDebugSnapshotValue(value)).toEqual({ self: '[Circular]' });
- });
-
- it('serializes a shared reference in each place it appears', () => {
- const shared = { anthropic: { eagerInputStreaming: false } };
-
- expect(
- sanitizeDebugSnapshotValue({
- first: { providerOptions: shared },
- second: [{ providerOptions: shared }],
- }),
- ).toEqual({
- first: { providerOptions: shared },
- second: [{ providerOptions: shared }],
- });
- });
-});
diff --git a/packages/@n8n/instance-ai/src/debug/run-debug-buffer.ts b/packages/@n8n/instance-ai/src/debug/run-debug-buffer.ts
deleted file mode 100644
index 2fdf46a7437..00000000000
--- a/packages/@n8n/instance-ai/src/debug/run-debug-buffer.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-import { isRecord } from '@n8n/utils/is-record';
-import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
-import type { GenerateTextStepEndEvent, GenerateTextStepStartEvent } from 'ai';
-
-import type { Logger } from '../logger';
-import { sanitizeDebugSnapshotRecord, sanitizeDebugSnapshotValue } from './sanitize-debug-snapshot';
-
-const MAX_RUNS = 50;
-const MAX_STEPS_PER_RUN = 200;
-const MAX_WORKFLOW_SNAPSHOTS_PER_RUN = 100;
-
-export interface WorkflowCodeSnapshotInput {
- code: string;
- source: 'full-code' | 'patch';
- patches?: unknown;
- workflowId?: string;
- toolCallId?: string;
- success: boolean;
- errors?: string[];
- capturedAt: number;
-}
-
-export type SanitizedStepStart = {
- stepNumber: number;
- sdkStepNumber?: number;
-} & Record;
-
-export type SanitizedStepFinish = {
- stepNumber: number;
- sdkStepNumber?: number;
-} & Record;
-
-export type WorkflowCodeSnapshot = WorkflowCodeSnapshotInput;
-
-export interface RunDebugStep {
- stepNumber: number;
- input?: SanitizedStepStart;
- output?: SanitizedStepFinish;
-}
-
-export interface RunDebugRecord {
- threadId: string;
- runId: string;
- startedAt: number;
- label?: string;
- /** Next run-scoped step index; survives step-cap eviction. */
- nextStepIndex: number;
- steps: RunDebugStep[];
- workflowCode: WorkflowCodeSnapshot[];
-}
-
-export interface RunDebugStepHookOptions {
- runId: string;
- threadId: string;
-}
-
-/**
- * Step-start keys that repeat data captured elsewhere:
- * - `tools` holds live Zod schemas; `stepTools` has the JSON Schema the model received.
- * - `promptMessages` repeats `instructions` and `messages`.
- * - `steps` repeats earlier steps, which the buffer records on their own.
- */
-const DUPLICATE_STEP_START_KEYS = new Set(['tools', 'promptMessages', 'steps']);
-
-function isEmptyContainer(value: unknown): boolean {
- if (Array.isArray(value)) return value.length === 0;
- return isRecord(value) && Object.keys(value).length === 0;
-}
-
-function captureStepStartPayload(event: GenerateTextStepStartEvent): Record {
- const hasStepTools = 'stepTools' in event && Array.isArray(event.stepTools);
- const payload: Record = {};
- for (const [key, value] of Object.entries(event)) {
- if (key === 'tools' && !hasStepTools) {
- payload.tools = summarizeToolSet(value);
- continue;
- }
- if (DUPLICATE_STEP_START_KEYS.has(key) || isEmptyContainer(value)) continue;
- payload[key] = value;
- }
- return sanitizeDebugSnapshotRecord(payload);
-}
-
-/**
- * Keeps tool descriptions and plain JSON schemas when the SDK does not report
- * `stepTools`. Zod schemas are dropped: serialized, they are only method stubs.
- */
-function summarizeToolSet(tools: unknown): unknown {
- if (!isRecord(tools)) return undefined;
- const summary: Record = {};
- for (const [name, tool] of Object.entries(tools)) {
- if (!isRecord(tool)) continue;
- summary[name] = {
- ...(typeof tool.description === 'string' ? { description: tool.description } : {}),
- ...(tool.inputSchema !== undefined && !isZodSchema(tool.inputSchema)
- ? { inputSchema: tool.inputSchema }
- : {}),
- };
- }
- return summary;
-}
-
-/** Matches Zod 3 (`_def`) and Zod 4 (`_zod`) schemas without depending on one Zod copy. */
-function isZodSchema(value: unknown): boolean {
- return isRecord(value) && ('_def' in value || '_zod' in value);
-}
-
-function captureStepFinishPayload(event: GenerateTextStepEndEvent): Record {
- return sanitizeDebugSnapshotRecord(event);
-}
-
-export function sanitizeStepStart(
- event: GenerateTextStepStartEvent,
- stepNumber: number,
-): SanitizedStepStart {
- return {
- ...captureStepStartPayload(event),
- stepNumber,
- sdkStepNumber: event.stepNumber,
- };
-}
-
-export function sanitizeStepFinish(
- event: GenerateTextStepEndEvent,
- stepNumber: number,
-): SanitizedStepFinish {
- return {
- ...captureStepFinishPayload(event),
- stepNumber,
- sdkStepNumber: event.stepNumber,
- };
-}
-
-export function createRunDebugStepHooks(
- buffer: RunDebugBuffer,
- options: RunDebugStepHookOptions,
-): {
- onStepStart: (event: GenerateTextStepStartEvent) => void;
- onStepEnd: (event: GenerateTextStepEndEvent) => void;
- /** @deprecated Use `onStepEnd` instead. */
- onStepFinish: (event: GenerateTextStepEndEvent) => void;
-} {
- // The agent runtime calls streamText/generateText once per loop iteration. The AI SDK
- // resets stepNumber to 0 on each call, so we allocate a run-scoped sequence instead.
- let stepIndex = buffer.getNextStepIndex(options.runId);
-
- const onStepEnd = (event: GenerateTextStepEndEvent) => {
- buffer.recordStepFinish(options.runId, stepIndex, event);
- stepIndex++;
- };
-
- return {
- onStepStart: (event) => {
- buffer.recordStepStart(options.runId, stepIndex, event);
- },
- onStepEnd,
- onStepFinish: onStepEnd,
- };
-}
-
-export function buildRunDebugLabel(options: {
- message?: string;
- resumeReason?: string;
-}): string {
- const resumeLabels: Record = {
- approval: 'Resume · approval',
- background_task_completed: 'Follow-up · background task completed',
- workflow_verification: 'Follow-up · workflow verification',
- workflow_setup: 'Follow-up · workflow setup',
- planned_checkpoint: 'Follow-up · planned checkpoint',
- replan: 'Follow-up · replan',
- synthesize: 'Follow-up · synthesize',
- };
-
- if (options.resumeReason && resumeLabels[options.resumeReason]) {
- return resumeLabels[options.resumeReason];
- }
-
- const trimmed = options.message?.trim();
- if (trimmed) {
- return trimmed.length > 80 ? `${trimmed.slice(0, 80)}…` : trimmed;
- }
-
- return 'Orchestrator run';
-}
-
-export class RunDebugBuffer {
- private readonly records = new Map();
-
- constructor(private readonly logger?: Logger) {}
-
- ensure(runId: string, threadId: string, label?: string): void {
- if (this.records.has(runId)) return;
-
- this.evictOldestRunIfNeeded();
- this.records.set(runId, {
- threadId,
- runId,
- startedAt: Date.now(),
- label: label ? scrubSecretsInText(label.trim()) : undefined,
- nextStepIndex: 0,
- steps: [],
- workflowCode: [],
- });
- }
-
- getNextStepIndex(runId: string): number {
- return this.records.get(runId)?.nextStepIndex ?? 0;
- }
-
- recordStepStart(runId: string, stepIndex: number, event: GenerateTextStepStartEvent): void {
- const record = this.records.get(runId);
- if (!record) return;
-
- const existing = record.steps.find((step) => step.stepNumber === stepIndex);
- if (existing) {
- existing.input = sanitizeStepStart(event, stepIndex);
- return;
- }
-
- this.evictOldestStepIfNeeded(record);
- record.steps.push({
- stepNumber: stepIndex,
- input: sanitizeStepStart(event, stepIndex),
- });
- record.steps.sort((a, b) => a.stepNumber - b.stepNumber);
- }
-
- recordStepFinish(runId: string, stepIndex: number, event: GenerateTextStepEndEvent): void {
- const record = this.records.get(runId);
- if (!record) return;
-
- const existing = record.steps.find((step) => step.stepNumber === stepIndex);
- if (existing) {
- existing.output = sanitizeStepFinish(event, stepIndex);
- record.nextStepIndex = stepIndex + 1;
- return;
- }
-
- this.evictOldestStepIfNeeded(record);
- record.steps.push({
- stepNumber: stepIndex,
- output: sanitizeStepFinish(event, stepIndex),
- });
- record.steps.sort((a, b) => a.stepNumber - b.stepNumber);
- record.nextStepIndex = stepIndex + 1;
- }
-
- recordWorkflowCode(runId: string, snapshot: WorkflowCodeSnapshotInput): void {
- const record = this.records.get(runId);
- if (!record) return;
-
- if (record.workflowCode.length >= MAX_WORKFLOW_SNAPSHOTS_PER_RUN) {
- record.workflowCode.shift();
- this.logger?.warn('Evicted oldest workflow code snapshot from run debug buffer', {
- runId,
- maxSnapshots: MAX_WORKFLOW_SNAPSHOTS_PER_RUN,
- });
- }
-
- record.workflowCode.push({
- ...snapshot,
- code: scrubSecretsInText(snapshot.code),
- patches: sanitizeDebugSnapshotValue(snapshot.patches),
- errors: snapshot.errors?.map((error) => scrubSecretsInText(error)),
- });
- }
-
- get(runId: string): RunDebugRecord | undefined {
- const record = this.records.get(runId);
- if (!record) return undefined;
- return structuredClone(record);
- }
-
- listByThread(threadId: string): RunDebugRecord[] {
- return [...this.records.values()]
- .filter((record) => record.threadId === threadId)
- .sort((a, b) => a.startedAt - b.startedAt)
- .map((record) => structuredClone(record));
- }
-
- private evictOldestRunIfNeeded(): void {
- if (this.records.size < MAX_RUNS) return;
-
- const oldest = [...this.records.values()].sort((a, b) => a.startedAt - b.startedAt)[0];
- if (!oldest) return;
-
- this.records.delete(oldest.runId);
- this.logger?.warn('Evicted oldest run from debug buffer', {
- runId: oldest.runId,
- threadId: oldest.threadId,
- maxRuns: MAX_RUNS,
- });
- }
-
- private evictOldestStepIfNeeded(record: RunDebugRecord): void {
- if (record.steps.length < MAX_STEPS_PER_RUN) return;
-
- const removed = record.steps.shift();
- this.logger?.warn('Evicted oldest step from run debug buffer', {
- runId: record.runId,
- stepNumber: removed?.stepNumber,
- maxSteps: MAX_STEPS_PER_RUN,
- });
- }
-}
diff --git a/packages/@n8n/instance-ai/src/debug/sanitize-debug-snapshot.ts b/packages/@n8n/instance-ai/src/debug/sanitize-debug-snapshot.ts
deleted file mode 100644
index 85b87b9a2d0..00000000000
--- a/packages/@n8n/instance-ai/src/debug/sanitize-debug-snapshot.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { isRecord } from '@n8n/utils/is-record';
-import { isSensitiveKey } from '@n8n/utils/redaction/sensitive-key';
-import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
-
-const OMIT_KEYS = new Set(['abortSignal']);
-
-function shouldOmitKey(key: string, parentKey?: string): boolean {
- if (OMIT_KEYS.has(key)) {
- return true;
- }
-
- // Raw provider HTTP payloads are not useful in the debug buffer.
- if (key === 'body' && parentKey === 'response') {
- return true;
- }
-
- return false;
-}
-
-function redactSensitiveKey(key: string, value: unknown, seen: WeakSet