mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(core): Build agent drafts before asking for setup in the agent builder (no-changelog) (#34615)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -281,6 +281,8 @@ export type {
|
||||
SubAgentTaskDifficulty,
|
||||
} from './runtime/tools/delegate-sub-agent-tool';
|
||||
export { WRITE_TODOS_TOOL_NAME, createWriteTodosTool } from './runtime/tools/write-todos-tool';
|
||||
export { createPlannerTodosTool } from './runtime/tools/planner-todos-tool';
|
||||
export type { CreatePlannerTodosToolOptions } from './runtime/tools/planner-todos-tool';
|
||||
export type { CreateWriteTodosToolOptions } from './runtime/tools/write-todos-tool';
|
||||
export { createEmbeddingModel } from './runtime/model/model-factory';
|
||||
export { generateTitleFromMessage } from './runtime/memory/title-generation';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Agent } from '../../sdk/agent';
|
||||
import { isZodSchema } from '../../utils/zod';
|
||||
import { createPlannerTodosTool } from '../tools/planner-todos-tool';
|
||||
import { isSdkOwnedBuiltInTool } from '../tools/sdk-owned-tool';
|
||||
|
||||
const samplePlannerTodos = [
|
||||
{ id: 'a', content: 'Resolve model', status: 'blocked' as const },
|
||||
{ id: 'b', content: 'Write instructions', status: 'in_progress' as const },
|
||||
];
|
||||
|
||||
describe('createPlannerTodosTool', () => {
|
||||
it('accepts bare items including blocked status and echoes them with a count', async () => {
|
||||
const tool = createPlannerTodosTool();
|
||||
|
||||
await expect(
|
||||
tool.handler?.(
|
||||
{ todos: samplePlannerTodos },
|
||||
{ runId: 'r1', persistence: { threadId: 't1', resourceId: 'u1' } },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: 'ok',
|
||||
todoCount: 2,
|
||||
todos: samplePlannerTodos,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects items carrying delegation fields', () => {
|
||||
const tool = createPlannerTodosTool();
|
||||
expect(isZodSchema(tool.inputSchema)).toBe(true);
|
||||
if (!isZodSchema(tool.inputSchema)) {
|
||||
throw new Error('Expected Zod input schema');
|
||||
}
|
||||
|
||||
expect(
|
||||
tool.inputSchema.safeParse({
|
||||
todos: [{ id: 'a', content: 'x', status: 'pending', difficulty: 'low' }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('registers on an agent as an official SDK built-in', () => {
|
||||
expect(() =>
|
||||
new Agent('parent')
|
||||
.model('openai', 'gpt-4o-mini')
|
||||
.instructions('t')
|
||||
.tool(createPlannerTodosTool()),
|
||||
).not.toThrow();
|
||||
expect(isSdkOwnedBuiltInTool(createPlannerTodosTool())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withSdkOwnedBuiltInMetadata } from './sdk-owned-tool';
|
||||
import {
|
||||
buildTodosInputSchema,
|
||||
buildTodosOutputSchema,
|
||||
todosEchoHandler,
|
||||
todoStatusSchema,
|
||||
} from './todos-core';
|
||||
import { WRITE_TODOS_TOOL_NAME } from './write-todos-tool';
|
||||
import { Tool } from '../../sdk/tool';
|
||||
import type { BuiltTool } from '../../types/sdk/tool';
|
||||
|
||||
const plannerTodoItemSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).describe('Stable identifier for this task within the current plan.'),
|
||||
content: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'Concrete, self-contained task description. For blocked tasks, state exactly what user input is missing.',
|
||||
),
|
||||
status: todoStatusSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PLANNER_TODOS_DESCRIPTION =
|
||||
'Create or update a structured task list for the current task. Use it to decompose the work, ' +
|
||||
'track progress, and record which tasks are blocked on user input. Use it for every multi-step ' +
|
||||
'request. This tool only updates the task list; it does not perform work or ask the user.';
|
||||
|
||||
const PLANNER_TODOS_SYSTEM_INSTRUCTION = [
|
||||
'write_todos maintains your plan for the current task. It never asks the user or performs work itself.',
|
||||
'WHEN TO USE write_todos:',
|
||||
'- Every multi-step request, before other tool calls.',
|
||||
'- You need to track progress or record tasks blocked on user input.',
|
||||
'WHEN NOT TO USE write_todos:',
|
||||
'- Purely conversational replies with no task work.',
|
||||
'HOW TO USE write_todos:',
|
||||
'- Write concrete, self-contained tasks; mark the first active task in_progress immediately.',
|
||||
'- Mark a task blocked when it cannot proceed without user input, and state exactly what input is missing in the task content.',
|
||||
'- Marking a task blocked IS the action for now. Do not ask the user about it while any non-blocked task remains; continue with unblocked tasks.',
|
||||
'- When only blocked tasks remain, end your turn with a summary of what is missing; unblock and finish them in later turns as the user provides input.',
|
||||
'- Update task status as soon as work completes; do not batch completions at the end.',
|
||||
'- Do not call write_todos multiple times in parallel; send one full list update at a time.',
|
||||
].join('\n');
|
||||
|
||||
export interface CreatePlannerTodosToolOptions {
|
||||
/** Override the model-visible tool description. */
|
||||
description?: string;
|
||||
/** Override the model-visible system instruction. */
|
||||
systemInstruction?: string;
|
||||
}
|
||||
|
||||
/** Planner-only variant of `write_todos` — no delegation fields or guidance. */
|
||||
export function createPlannerTodosTool(options: CreatePlannerTodosToolOptions = {}): BuiltTool {
|
||||
const tool = new Tool(WRITE_TODOS_TOOL_NAME)
|
||||
.description(options.description ?? PLANNER_TODOS_DESCRIPTION)
|
||||
.systemInstruction(options.systemInstruction ?? PLANNER_TODOS_SYSTEM_INSTRUCTION)
|
||||
.input(buildTodosInputSchema(plannerTodoItemSchema))
|
||||
.output(buildTodosOutputSchema(plannerTodoItemSchema))
|
||||
.handler(todosEchoHandler)
|
||||
.build();
|
||||
|
||||
return withSdkOwnedBuiltInMetadata(tool);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const todoStatusSchema = z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'blocked',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
/** Wraps a todo item schema in the shared `{ todos: [...] }` input shape with duplicate-id validation. */
|
||||
export function buildTodosInputSchema<T extends z.ZodType<{ id: string }>>(todoItemSchema: T) {
|
||||
return z
|
||||
.object({
|
||||
todos: z
|
||||
.array(todoItemSchema)
|
||||
.describe('Full task list for the current run. Replaces any previous list.'),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const seen = new Set<string>();
|
||||
for (const [index, todo] of value.todos.entries()) {
|
||||
const id = todo.id;
|
||||
if (seen.has(id)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Duplicate todo id "${id}". Each task must have a unique id.`,
|
||||
path: ['todos', index, 'id'],
|
||||
});
|
||||
}
|
||||
seen.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTodosOutputSchema<T extends z.ZodTypeAny>(todoItemSchema: T) {
|
||||
return z.object({
|
||||
status: z.literal('ok'),
|
||||
todoCount: z.number(),
|
||||
todos: z.array(todoItemSchema),
|
||||
});
|
||||
}
|
||||
|
||||
/** Echo handler shared by all todo tools — state lives in the transcript, not the tool. */
|
||||
export async function todosEchoHandler<T>(input: { todos: T[] }) {
|
||||
const todos = [...input.todos];
|
||||
return await Promise.resolve({
|
||||
status: 'ok' as const,
|
||||
todoCount: todos.length,
|
||||
todos,
|
||||
});
|
||||
}
|
||||
@@ -5,13 +5,17 @@ import {
|
||||
SUB_AGENT_TASK_DIFFICULTIES,
|
||||
} from './delegate-sub-agent-tool';
|
||||
import { withSdkOwnedBuiltInMetadata } from './sdk-owned-tool';
|
||||
import {
|
||||
buildTodosInputSchema,
|
||||
buildTodosOutputSchema,
|
||||
todosEchoHandler,
|
||||
todoStatusSchema,
|
||||
} from './todos-core';
|
||||
import { Tool } from '../../sdk/tool';
|
||||
import type { BuiltTool } from '../../types/sdk/tool';
|
||||
|
||||
export const WRITE_TODOS_TOOL_NAME = 'write_todos';
|
||||
|
||||
const todoStatusSchema = z.enum(['pending', 'in_progress', 'completed', 'blocked', 'cancelled']);
|
||||
|
||||
const todoDifficultySchema = z.enum(SUB_AGENT_TASK_DIFFICULTIES);
|
||||
|
||||
function buildTodoItemSchema(delegateToolName: string) {
|
||||
@@ -41,38 +45,6 @@ function buildTodoItemSchema(delegateToolName: string) {
|
||||
});
|
||||
}
|
||||
|
||||
type TodoItemSchema = ReturnType<typeof buildTodoItemSchema>;
|
||||
|
||||
function buildWriteTodosInputSchema(todoItemSchema: TodoItemSchema) {
|
||||
return z
|
||||
.object({
|
||||
todos: z
|
||||
.array(todoItemSchema)
|
||||
.describe('Full task list for the current run. Replaces any previous list.'),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const seen = new Set<string>();
|
||||
for (const [index, todo] of value.todos.entries()) {
|
||||
if (seen.has(todo.id)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Duplicate todo id "${todo.id}". Each task must have a unique id.`,
|
||||
path: ['todos', index, 'id'],
|
||||
});
|
||||
}
|
||||
seen.add(todo.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildWriteTodosOutputSchema(todoItemSchema: TodoItemSchema) {
|
||||
return z.object({
|
||||
status: z.literal('ok'),
|
||||
todoCount: z.number(),
|
||||
todos: z.array(todoItemSchema),
|
||||
});
|
||||
}
|
||||
|
||||
function buildWriteTodosDescription(delegateToolName: string): string {
|
||||
return `Create or update a structured task list for complex agent work. Use it to decompose a larger request into concrete workstreams, track progress, and identify which tasks should be handled separately with ${delegateToolName}. Do not use it for trivial work, single-step tasks, or purely conversational answers. This tool only updates the task list; it does not run sub-agents or answer the user.`;
|
||||
}
|
||||
@@ -112,8 +84,8 @@ export interface CreateWriteTodosToolOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the planner-only `write_todos` tool — lets a parent agent maintain a
|
||||
* structured task list for complex work without auto-dispatching sub-agents.
|
||||
* Build the delegation-aware `write_todos` tool — lets a parent agent maintain
|
||||
* a structured task list and mark tasks for sub-agent delegation.
|
||||
*/
|
||||
export function createWriteTodosTool(options: CreateWriteTodosToolOptions = {}): BuiltTool {
|
||||
const delegateToolName = options.delegateToolName ?? DELEGATE_SUB_AGENT_TOOL_NAME;
|
||||
@@ -122,17 +94,9 @@ export function createWriteTodosTool(options: CreateWriteTodosToolOptions = {}):
|
||||
const tool = new Tool(WRITE_TODOS_TOOL_NAME)
|
||||
.description(buildWriteTodosDescription(delegateToolName))
|
||||
.systemInstruction(buildWriteTodosSystemInstruction(delegateToolName))
|
||||
.input(buildWriteTodosInputSchema(todoItemSchema))
|
||||
.output(buildWriteTodosOutputSchema(todoItemSchema))
|
||||
.handler(async (input) => {
|
||||
const todos = [...input.todos];
|
||||
|
||||
return await Promise.resolve({
|
||||
status: 'ok' as const,
|
||||
todoCount: todos.length,
|
||||
todos,
|
||||
});
|
||||
})
|
||||
.input(buildTodosInputSchema(todoItemSchema))
|
||||
.output(buildTodosOutputSchema(todoItemSchema))
|
||||
.handler(todosEchoHandler)
|
||||
.build();
|
||||
|
||||
return withSdkOwnedBuiltInMetadata(tool);
|
||||
|
||||
@@ -45,7 +45,7 @@ const INTENT_HINT = isAgentFeatureEnabled()
|
||||
: '';
|
||||
|
||||
const AGENT_BUILD_ROUTE = isAgentFeatureEnabled()
|
||||
? '\n- **Agent build or edit** (agent-anchored per the intent gate: chat or session interaction, cross-session memory, proactive or long-running operation, learning from feedback) → call `build-agent` right away and let the builder gather requirements — do not run your own requirement-gathering round first. The builder cannot see this conversation: its only knowledge is what you pass in `message`, so include ALL requirements, constraints, and answers already gathered in the first `message` (plus `name` for a new agent or `agentId` for an existing one, and `workflowContext` for workflows built this session). Each distinct agent the user asks for is its own build target — pass `name` or `agentId` again to create or switch agents (prefer the `agentId` returned by earlier build-agent results when switching back; a name used earlier in this conversation also switches back to that agent rather than duplicating it); calls without either continue the most recent target. When the user message includes an agent-preview reference / `<agent-preview-context>`, call `get-session` to read the transcript first. For review/analysis/assessment requests (e.g. "review the tone", "how did it do", "assess its behavior"), answer directly from the transcript — do NOT call `build-agent` or otherwise modify the agent. Only call `build-agent` when the user explicitly asks to update, improve, or fix the agent; then pass the given `agentId` and put the concrete behavioral findings (failures, bad tool use, wrong answers) into `message` — do not ask the user to re-describe what already appears in the transcript. While a build is in progress, forward each user follow-up to `build-agent` near-verbatim and relay its `builderReply` back. When the builder needs user input it asks directly through interactive cards in this chat — do not re-ask those questions yourself; the tool call resumes with the user’s answer and returns the builder’s reply. When the user asks to publish, unpublish, activate, or make an agent live/usable, forward that intent to `build-agent` (the builder calls `publish_agent` / `unpublish_agent`) — never tell the user to open the agent editor and click Publish. Pass requests to add tools or capabilities to an agent to `build-agent` near-verbatim so the delegated builder can choose MCP, node-backed, provider, or custom tools. Do not build a workflow merely because the capability is reusable by the agent: multiple independent tools do not become one workflow. Use `workflow-builder` before `build-agent` only when one agent tool call must execute an ordered multi-node procedure, or when the user explicitly asks for a workflow that is reusable or callable outside the agent; then attach it via `workflowContext`. For questions about existing agents ("what agents do I have?") call `agents(action="list")` directly — no intent gate; and to edit an agent NOT built in this conversation, find its id via `agents(action="list")` and pass it as `agentId`.'
|
||||
? '\n- **Agent build or edit** (agent-anchored per the intent gate: chat or session interaction, cross-session memory, proactive or long-running operation, learning from feedback) → call `build-agent` right away and let the builder gather requirements — do not run your own requirement-gathering round first. The builder cannot see this conversation: its only knowledge is what you pass in `message`, so include ALL requirements, constraints, and answers already gathered in the first `message` (plus `name` + `createNew: true` for a new agent or `agentId` for an existing one, and `workflowContext` for workflows built this session). Each distinct agent the user asks for is its own build target — pass `name` or `agentId` again to create or switch agents (prefer the `agentId` returned by earlier build-agent results when switching back; a name used earlier in this conversation also switches back to that agent rather than duplicating it — unless `createNew: true` is passed); calls without either continue the most recent target. When the user message includes an agent-preview reference / `<agent-preview-context>`, call `get-session` to read the transcript first. For review/analysis/assessment requests (e.g. "review the tone", "how did it do", "assess its behavior"), answer directly from the transcript — do NOT call `build-agent` or otherwise modify the agent. Only call `build-agent` when the user explicitly asks to update, improve, or fix the agent; then pass the given `agentId` and put the concrete behavioral findings (failures, bad tool use, wrong answers) into `message` — do not ask the user to re-describe what already appears in the transcript. While a build is in progress, forward each user follow-up to `build-agent` near-verbatim and relay its `builderReply` back. When the builder needs user input it asks directly through interactive cards in this chat — do not re-ask those questions yourself; the tool call resumes with the user’s answer and returns the builder’s reply. When the user asks to publish, unpublish, activate, or make an agent live/usable, forward that intent to `build-agent` (the builder calls `publish_agent` / `unpublish_agent`) — never tell the user to open the agent editor and click Publish. Pass requests to add tools or capabilities to an agent to `build-agent` near-verbatim so the delegated builder can choose MCP, node-backed, provider, or custom tools. Do not build a workflow merely because the capability is reusable by the agent: multiple independent tools do not become one workflow. Use `workflow-builder` before `build-agent` only when one agent tool call must execute an ordered multi-node procedure, or when the user explicitly asks for a workflow that is reusable or callable outside the agent; then attach it via `workflowContext`. For questions about existing agents ("what agents do I have?") call `agents(action="list")` directly — no intent gate; and to edit an agent NOT built in this conversation, find its id via `agents(action="list")` and pass it as `agentId`. Pass `agentId` only when the user\'s intent is to edit that specific agent. A request to build/create a NEW agent never passes `agentId`: pass `name` with `createNew: true`, even if an agent with the same or a similar name already exists — duplicate names are allowed, and an existing agent must never be repurposed to satisfy a create request. Agents the request merely references — as sub-agents, delegation targets, or examples — are not the build target: forward those mentions inside `message` only, never as `agentId`.'
|
||||
: '';
|
||||
|
||||
const WORKFLOW_ROUTE_GATE_REF = isAgentFeatureEnabled()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { InstanceAiThreadStatusResponse } from '@n8n/api-types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { InstanceAiTraceContext, ModelConfig } from '../types';
|
||||
import type { InstanceAiTraceContext, ModelConfig, OrchestrationContext } from '../types';
|
||||
import type {
|
||||
InstanceAiLivenessPolicy,
|
||||
InstanceAiLivenessSurface,
|
||||
@@ -24,6 +24,10 @@ export interface ActiveRunState {
|
||||
export interface SuspendedRunState<TUser = unknown> extends ActiveRunState {
|
||||
agentRunId: string;
|
||||
agent: unknown;
|
||||
/** The orchestration context the agent's tools closed over. Stored so a
|
||||
* resume can rebind `tracing` to the new resume trace — spans emitted
|
||||
* through the suspended turn's shut-down runtime export nothing. */
|
||||
orchestrationContext?: OrchestrationContext;
|
||||
threadId: string;
|
||||
user: TUser;
|
||||
toolCallId: string;
|
||||
|
||||
@@ -552,6 +552,45 @@ describe('build-agent tool', () => {
|
||||
});
|
||||
|
||||
describe('deferred agentId-path binding', () => {
|
||||
it('rejects foreign agentId when the passed name contradicts the resolved agent name', async () => {
|
||||
const { context, delegate } = makeContext();
|
||||
vi.mocked(delegate.resolveAgentName).mockResolvedValue('Support Triage Agent');
|
||||
|
||||
const result = await runTool(context, {
|
||||
message: 'Build Ops Companion',
|
||||
agentId: 'agent-existing',
|
||||
name: 'Ops Companion',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
'Agent agent-existing is named "Support Triage Agent", but name "Ops Companion" was passed. ' +
|
||||
'To create a new agent named "Ops Companion", pass `name` only (no `agentId`). ' +
|
||||
'To edit "Support Triage Agent", pass `agentId` only and put any rename instruction in `message`.',
|
||||
});
|
||||
expect(delegate.streamBuild).not.toHaveBeenCalled();
|
||||
expect(saveAgentBuilderTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows foreign agentId when the passed name matches the resolved agent name', async () => {
|
||||
const { context, delegate } = makeContext();
|
||||
vi.mocked(delegate.resolveAgentName).mockResolvedValue('Ops Companion');
|
||||
vi.mocked(delegate.streamBuild).mockResolvedValue(fakeStream([], 'Editing it.'));
|
||||
|
||||
await runTool(context, {
|
||||
message: 'Add a tool',
|
||||
agentId: 'agent-existing',
|
||||
name: ' ops companion ',
|
||||
});
|
||||
|
||||
expect(delegate.streamBuild).toHaveBeenCalledWith(
|
||||
'agent-existing',
|
||||
'Add a tool',
|
||||
expect.objectContaining({ threadId: 'ia-builder:thread-1:agent-existing' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist the target when the agentId path fails before the stream settles', async () => {
|
||||
const { context, delegate } = makeContext();
|
||||
vi.mocked(delegate.streamBuild).mockRejectedValue(new Error('agent:update forbidden'));
|
||||
@@ -989,6 +1028,53 @@ describe('build-agent tool', () => {
|
||||
modelConfig: context.modelId,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a fresh agent instead of switching back when createNew is set and the name matches a session agent', async () => {
|
||||
const { context, delegate } = makeContext();
|
||||
const boundTarget: AgentBuilderTarget = {
|
||||
agentId: 'agent-1',
|
||||
projectId: 'proj-1',
|
||||
name: 'Platform Cycle Tracker',
|
||||
};
|
||||
context.domainContext!.agentBuilderTarget = boundTarget;
|
||||
vi.mocked(findSessionAgentByName).mockResolvedValue(boundTarget);
|
||||
vi.mocked(delegate.createAgent).mockResolvedValue({
|
||||
agentId: 'agent-3',
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
vi.mocked(delegate.streamBuild).mockResolvedValue(fakeStream([], 'Created it.'));
|
||||
|
||||
await runTool(context, {
|
||||
message: 'Build another tracker',
|
||||
name: 'Platform Cycle Tracker',
|
||||
createNew: true,
|
||||
});
|
||||
|
||||
expect(findSessionAgentByName).not.toHaveBeenCalled();
|
||||
expect(delegate.createAgent).toHaveBeenCalledWith('Platform Cycle Tracker');
|
||||
expect(delegate.streamBuild).toHaveBeenCalledWith(
|
||||
'agent-3',
|
||||
'Build another tracker',
|
||||
expect.objectContaining({ threadId: 'ia-builder:thread-1:agent-3' }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ agentId: 'agent-1', createNew: true, message: 'Build it' },
|
||||
{ createNew: true, message: 'Build it' },
|
||||
])('rejects createNew when combined with agentId or missing name', async (input) => {
|
||||
const { context, delegate } = makeContext();
|
||||
|
||||
const result = await runTool(context, input);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error:
|
||||
'createNew requires `name` and cannot be combined with `agentId` — pass `name` only to create a new agent.',
|
||||
});
|
||||
expect(delegate.createAgent).not.toHaveBeenCalled();
|
||||
expect(delegate.streamBuild).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive suspension cascade', () => {
|
||||
|
||||
@@ -158,7 +158,17 @@ const buildAgentInputSchema = z.object({
|
||||
.describe(
|
||||
'Agent name. A name matching an agent already built in this conversation switches back ' +
|
||||
'to that agent; a new name creates a new agent and makes it the active target. Omit on ' +
|
||||
'follow-up calls for the current agent.',
|
||||
'follow-up calls for the current agent. Combine with `createNew: true` to force creating ' +
|
||||
'a fresh agent when the name matches one built earlier this conversation.',
|
||||
),
|
||||
createNew: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set true when the user asks to create a brand-new agent. Bypasses the same-name ' +
|
||||
'switch-back: with createNew, `name` always creates a fresh agent even if that name ' +
|
||||
'matches one built earlier in this conversation. Requires `name`; never combine with ' +
|
||||
'`agentId`. Omit for edits, follow-ups, and switch-backs.',
|
||||
),
|
||||
agentId: z
|
||||
.string()
|
||||
@@ -166,7 +176,12 @@ const buildAgentInputSchema = z.object({
|
||||
.describe(
|
||||
'Existing agent id to edit — use the `agentId` returned by earlier build-agent ' +
|
||||
'results. Pass to start editing that agent or to switch the active build target; ' +
|
||||
'omit on follow-up calls.',
|
||||
'omit on follow-up calls. Only pass this when the user explicitly wants to change ' +
|
||||
'that specific existing agent. NEVER pass it for a request to build/create a NEW ' +
|
||||
'agent — even if an agent with the same or a similar name already exists in the ' +
|
||||
'project (duplicate names are allowed). Agents the request merely references — as ' +
|
||||
'sub-agents, delegation targets, or examples — are not the build target: mention ' +
|
||||
'them in `message` instead.',
|
||||
),
|
||||
workflowContext: z
|
||||
.array(z.object({ id: z.string(), name: z.string(), description: z.string().optional() }))
|
||||
@@ -616,9 +631,34 @@ type TargetResolution =
|
||||
| { ok: false; error: string };
|
||||
|
||||
const NO_TARGET_INPUT_ERROR = 'Pass name to create a new agent or agentId to edit an existing one.';
|
||||
const CREATE_NEW_INPUT_ERROR =
|
||||
'createNew requires `name` and cannot be combined with `agentId` — pass `name` only to create a new agent.';
|
||||
const AGENT_ID_NEEDS_PROJECT_ERROR =
|
||||
'Cannot bind to agentId without an active project context. Start this conversation from within a project.';
|
||||
|
||||
function buildAgentTargetNameMismatchError(
|
||||
agentId: string,
|
||||
realName: string,
|
||||
passedName: string,
|
||||
): string {
|
||||
return (
|
||||
`Agent ${agentId} is named "${realName}", but name "${passedName}" was passed. ` +
|
||||
`To create a new agent named "${passedName}", pass \`name\` only (no \`agentId\`). ` +
|
||||
`To edit "${realName}", pass \`agentId\` only and put any rename instruction in \`message\`.`
|
||||
);
|
||||
}
|
||||
|
||||
function rejectAgentTargetNameMismatch(
|
||||
agentId: string,
|
||||
realName: string | undefined,
|
||||
passedName: string | undefined,
|
||||
): TargetResolution | undefined {
|
||||
if (!passedName || !realName || agentNamesMatch(passedName, realName)) {
|
||||
return undefined;
|
||||
}
|
||||
return { ok: false, error: buildAgentTargetNameMismatchError(agentId, realName, passedName) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which agent this call should build/edit. A bound target stays
|
||||
* active by default; passing `name` or `agentId` can create a new target or
|
||||
@@ -636,8 +676,14 @@ async function resolveTargetForCall(
|
||||
input: z.infer<typeof buildAgentInputSchema>,
|
||||
boundTarget: AgentBuilderTarget | undefined,
|
||||
): Promise<TargetResolution> {
|
||||
if (input.createNew && (input.agentId || !input.name)) {
|
||||
return { ok: false, error: CREATE_NEW_INPUT_ERROR };
|
||||
}
|
||||
|
||||
if (input.agentId) {
|
||||
if (boundTarget && input.agentId === boundTarget.agentId) {
|
||||
const mismatch = rejectAgentTargetNameMismatch(input.agentId, boundTarget.name, input.name);
|
||||
if (mismatch) return mismatch;
|
||||
return { ok: true, target: boundTarget, bindAfterTurn: false };
|
||||
}
|
||||
if (!domainContext.projectId) {
|
||||
@@ -651,6 +697,8 @@ async function resolveTargetForCall(
|
||||
} catch {
|
||||
name = undefined;
|
||||
}
|
||||
const mismatch = rejectAgentTargetNameMismatch(input.agentId, name, input.name);
|
||||
if (mismatch) return mismatch;
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
@@ -663,19 +711,21 @@ async function resolveTargetForCall(
|
||||
}
|
||||
|
||||
if (input.name) {
|
||||
// Guards against the orchestrator redundantly repeating `name` on a
|
||||
// follow-up call for the agent already being built.
|
||||
if (boundTarget && agentNamesMatch(input.name, boundTarget.name)) {
|
||||
return { ok: true, target: boundTarget, bindAfterTurn: false };
|
||||
}
|
||||
// A name matching an agent already built/targeted this conversation is a
|
||||
// switch-back, not a creation — the duplicate-agent failure mode this
|
||||
// registry exists to prevent. Deferred persist like the agentId path: the
|
||||
// agent may have been deleted since, and a failed turn must not clobber
|
||||
// the current binding.
|
||||
const sessionAgent = await findSessionAgentByName(domainContext, input.name);
|
||||
if (sessionAgent) {
|
||||
return { ok: true, target: sessionAgent, bindAfterTurn: true };
|
||||
if (!input.createNew) {
|
||||
// Guards against the orchestrator redundantly repeating `name` on a
|
||||
// follow-up call for the agent already being built.
|
||||
if (boundTarget && agentNamesMatch(input.name, boundTarget.name)) {
|
||||
return { ok: true, target: boundTarget, bindAfterTurn: false };
|
||||
}
|
||||
// A name matching an agent already built/targeted this conversation is a
|
||||
// switch-back, not a creation — the duplicate-agent failure mode this
|
||||
// registry exists to prevent. Deferred persist like the agentId path: the
|
||||
// agent may have been deleted since, and a failed turn must not clobber
|
||||
// the current binding.
|
||||
const sessionAgent = await findSessionAgentByName(domainContext, input.name);
|
||||
if (sessionAgent) {
|
||||
return { ok: true, target: sessionAgent, bindAfterTurn: true };
|
||||
}
|
||||
}
|
||||
const created = await delegate.createAgent(input.name);
|
||||
const target: AgentBuilderTarget = {
|
||||
@@ -697,9 +747,12 @@ export function createBuildAgentTool(context: OrchestrationContext) {
|
||||
.description(
|
||||
'Delegate agent building to the agents-module builder, running as a sub-agent. ' +
|
||||
'Pass `name` to start a new agent or `agentId` to edit an existing one; calls ' +
|
||||
'without either keep editing the current agent. To build ANOTHER agent in the same ' +
|
||||
'without either keep editing the current agent. Create vs. edit follows user ' +
|
||||
'intent, not name collisions: a request to build a NEW agent always passes `name` ' +
|
||||
'plus `createNew: true` — never `agentId` — even when a same-named agent already exists in the ' +
|
||||
'project. To build ANOTHER agent in the same ' +
|
||||
'conversation, pass its `name` or `agentId` — a name matching an agent already built ' +
|
||||
'in this conversation switches back to it; an unmatched name creates a new agent and ' +
|
||||
'in this conversation switches back to it (unless `createNew: true` is passed); an unmatched name creates a new agent and ' +
|
||||
'switches the active target. The builder can also publish or unpublish the target ' +
|
||||
'agent when the user asks to publish, activate, make it live/usable, or unpublish — ' +
|
||||
'forward that intent in `message`; never tell the user to open the agent editor and ' +
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAgentFeatureEnabled } from '../utils/agent-feature-enabled';
|
||||
|
||||
export const DOMAIN_TOOL_IDS = {
|
||||
WORKFLOWS: 'workflows',
|
||||
EVALS: 'evals',
|
||||
@@ -62,6 +64,10 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set<string>([
|
||||
DOMAIN_TOOL_IDS.AGENTS,
|
||||
'web-search',
|
||||
'fetch-url',
|
||||
// build-agent is the primary route for agent-anchored intents; deferring it
|
||||
// costs 2 LLM rounds (search_tools + load_tool) and a prompt-cache rewrite
|
||||
// on every agent build.
|
||||
...(isAgentFeatureEnabled() ? [ORCHESTRATION_TOOL_IDS.BUILD_AGENT] : []),
|
||||
]);
|
||||
|
||||
export const CHECKPOINT_FOLLOW_UP_TOOL_NAMES = new Set<string>([
|
||||
|
||||
@@ -14,19 +14,20 @@ import type { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import type { AiUsageService } from '@/services/ai-usage.service';
|
||||
import type { WorkflowBuilderService } from '@/services/ai-workflow-builder.service';
|
||||
import type { AiService } from '@/services/ai.service';
|
||||
import type { FreeAiCreditsService } from '@/services/free-ai-credits.service';
|
||||
|
||||
import { AiController, type FlushableResponse } from '../ai.controller';
|
||||
|
||||
describe('AiController', () => {
|
||||
const aiService = mock<AiService>();
|
||||
const workflowBuilderService = mock<WorkflowBuilderService>();
|
||||
const freeAiCreditsService = mock<FreeAiCreditsService>();
|
||||
const aiUsageService = mock<AiUsageService>();
|
||||
const aiGatewayService = mock<AiGatewayService>();
|
||||
const controller = new AiController(
|
||||
aiService,
|
||||
workflowBuilderService,
|
||||
mock(),
|
||||
mock(),
|
||||
freeAiCreditsService,
|
||||
aiUsageService,
|
||||
aiGatewayService,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
AiGatewayConfigDto,
|
||||
AiGatewayUsageResponse,
|
||||
CreateCredentialDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { AiGatewayConfigDto, AiGatewayUsageResponse } from '@n8n/api-types';
|
||||
import {
|
||||
AiChatRequestDto,
|
||||
AiApplySuggestionRequestDto,
|
||||
@@ -19,12 +15,10 @@ import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Get, Licensed, Post, Query, RestController, GlobalScope } from '@n8n/decorators';
|
||||
import { type AiAssistantSDK, APIResponseError } from '@n8n_io/ai-assistant-sdk';
|
||||
import { Response } from 'express';
|
||||
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { WritableStream } from 'node:stream/web';
|
||||
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME, STREAM_SEPARATOR } from '@/constants';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { STREAM_SEPARATOR } from '@/constants';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ContentTooLargeError } from '@/errors/response-errors/content-too-large.error';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
@@ -34,7 +28,7 @@ import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import { AiUsageService } from '@/services/ai-usage.service';
|
||||
import { WorkflowBuilderService } from '@/services/ai-workflow-builder.service';
|
||||
import { AiService } from '@/services/ai.service';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { FreeAiCreditsService } from '@/services/free-ai-credits.service';
|
||||
|
||||
export type FlushableResponse = Response & { flush: () => void };
|
||||
|
||||
@@ -43,8 +37,7 @@ export class AiController {
|
||||
constructor(
|
||||
private readonly aiService: AiService,
|
||||
private readonly workflowBuilderService: WorkflowBuilderService,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly userService: UserService,
|
||||
private readonly freeAiCreditsService: FreeAiCreditsService,
|
||||
private readonly aiUsageService: AiUsageService,
|
||||
private readonly aiGatewayService: AiGatewayService,
|
||||
) {}
|
||||
@@ -225,28 +218,7 @@ export class AiController {
|
||||
@Post('/free-credits')
|
||||
async aiCredits(req: AuthenticatedRequest, _: Response, @Body payload: AiFreeCreditsRequestDto) {
|
||||
try {
|
||||
const aiCredits = await this.aiService.createFreeAiCredits(req.user);
|
||||
|
||||
const credentialProperties: CreateCredentialDto = {
|
||||
name: FREE_AI_CREDITS_CREDENTIAL_NAME,
|
||||
type: OPEN_AI_API_CREDENTIAL_TYPE,
|
||||
data: {
|
||||
apiKey: aiCredits.apiKey,
|
||||
url: aiCredits.url,
|
||||
},
|
||||
projectId: payload?.projectId,
|
||||
};
|
||||
|
||||
const newCredential = await this.credentialsService.createManagedCredential(
|
||||
credentialProperties,
|
||||
req.user,
|
||||
);
|
||||
|
||||
await this.userService.updateSettings(req.user.id, {
|
||||
userClaimedAiCredits: true,
|
||||
});
|
||||
|
||||
return newCredential;
|
||||
return await this.freeAiCreditsService.claim(req.user, payload?.projectId);
|
||||
} catch (e) {
|
||||
assert(e instanceof Error);
|
||||
throw new InternalServerError(e.message, e);
|
||||
|
||||
+17
@@ -101,6 +101,23 @@ describe('AgentIntegrationPersistenceService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes a same-type draft entry (empty credentialId) when connecting a real credential', async () => {
|
||||
const { service, agentRepository, chatIntegrationService, runtimeCacheService } = makeService();
|
||||
const agent = makeAgent({ integrations: [{ type: 'slack', credentialId: '' }] });
|
||||
|
||||
await service.saveCredentialIntegration(agent, { type: 'slack', credentialId: 'c1' });
|
||||
|
||||
expect(agent.integrations).toEqual([{ type: 'slack', credentialId: 'c1' }]);
|
||||
expect(agent.versionId).not.toBe(agent.activeVersionId);
|
||||
expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId);
|
||||
expect(agentRepository.save).toHaveBeenCalledWith(agent);
|
||||
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
{ type: 'slack', credentialId: 'c1' },
|
||||
'connect',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends new credential integrations while preserving existing siblings', async () => {
|
||||
const { service, agentRepository, chatIntegrationService, runtimeCacheService } = makeService();
|
||||
const agent = makeAgent({
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('AgentIntegrationsController integration credentials', () => {
|
||||
'project-1',
|
||||
{ id: 'user-1' },
|
||||
undefined,
|
||||
{ syncIntegrations: false },
|
||||
{ syncIntegrations: false, ignoreDraftIntegrations: true },
|
||||
);
|
||||
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
@@ -427,7 +427,7 @@ describe('AgentIntegrationsController integration credentials', () => {
|
||||
'project-1',
|
||||
{ id: 'user-1' },
|
||||
undefined,
|
||||
{ syncIntegrations: false },
|
||||
{ syncIntegrations: false, ignoreDraftIntegrations: true },
|
||||
);
|
||||
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
@@ -571,6 +571,25 @@ describe('AgentIntegrationsController integration credentials', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a draft integration (empty credentialId) as disconnected', async () => {
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue({
|
||||
id: 'agent-1',
|
||||
projectId: 'project-1',
|
||||
integrations: [{ type: 'slack', credentialId: '' }],
|
||||
} as never);
|
||||
|
||||
const { controller } = makeController({ agentRepository });
|
||||
|
||||
await expect(
|
||||
controller.integrationStatus(
|
||||
{ params: { projectId: 'project-1' } } as never,
|
||||
undefined as never,
|
||||
'agent-1',
|
||||
),
|
||||
).resolves.toEqual({ status: 'disconnected', integrations: [] });
|
||||
});
|
||||
|
||||
it('disconnects the channel before removing the persisted integration', async () => {
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
const agent = {
|
||||
|
||||
@@ -486,6 +486,30 @@ describe('AgentPublishService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores other draft integrations when connecting a channel (ignoreDraftIntegrations)', async () => {
|
||||
const { service, agentRepository, agentValidationService } = makeService();
|
||||
const integrations = [
|
||||
{ type: 'slack', credentialId: 'slack-1' },
|
||||
{ type: 'telegram', credentialId: '' },
|
||||
];
|
||||
const agent = makeAgent({ integrations: integrations as never });
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
|
||||
|
||||
await service.publishAgent(agentId, projectId, user, undefined, {
|
||||
ignoreDraftIntegrations: true,
|
||||
});
|
||||
|
||||
expect(agentValidationService.validateAgentEntityConfiguration).toHaveBeenCalledWith(
|
||||
agent,
|
||||
projectId,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'publish',
|
||||
[{ type: 'slack', credentialId: 'slack-1' }],
|
||||
);
|
||||
expect(agent.integrations).toBe(integrations);
|
||||
});
|
||||
|
||||
it('maps publish history rows and marks the active version', async () => {
|
||||
const { service, agentRepository, agentHistoryRepository } = makeService();
|
||||
const active = makeHistory({ versionId: 'active-version', author: 'Ada Lovelace' });
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { CredentialTypes } from '@/credential-types';
|
||||
import type { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
|
||||
import type { FreeAiCreditsService } from '@/services/free-ai-credits.service';
|
||||
import type { Telemetry } from '@/telemetry';
|
||||
|
||||
import type { AgentConfigService } from '../agent-config.service';
|
||||
import type { AgentCustomToolsService } from '../agent-custom-tools.service';
|
||||
@@ -27,6 +29,7 @@ import type { AgentIntegrationPersistenceService } from '../agent-integration-pe
|
||||
import type { AgentPublishService } from '../agent-publish.service';
|
||||
import type { AgentSkillsService } from '../agent-skills.service';
|
||||
import type { AgentTaskService } from '../agent-task.service';
|
||||
import type { AgentValidationService } from '../agent-validation.service';
|
||||
import type { AgentsToolsService } from '../agents-tools.service';
|
||||
import type { AgentsService } from '../agents.service';
|
||||
import type { AttachableWorkflowsService } from '../attachable-workflows.service';
|
||||
@@ -76,6 +79,11 @@ function makeService() {
|
||||
const mcpRegistryService = mock<McpRegistryService>();
|
||||
const agentTaskService = mock<AgentTaskService>();
|
||||
const agentPublishService = mock<AgentPublishService>();
|
||||
const agentValidationService = mock<AgentValidationService>();
|
||||
agentValidationService.validateAgentConfiguration.mockResolvedValue({
|
||||
status: 'valid',
|
||||
issues: [],
|
||||
});
|
||||
const aiService = mock<AiService>();
|
||||
aiService.isProxyEnabled.mockReturnValue(false);
|
||||
const dynamicNodeParametersService = mock<DynamicNodeParametersService>();
|
||||
@@ -111,6 +119,9 @@ function makeService() {
|
||||
nodeTypes,
|
||||
mock<SsrfProtectionConfig>({ enabled: true }),
|
||||
mock<SsrfProtectionService>(),
|
||||
mock<FreeAiCreditsService>(),
|
||||
mock<Telemetry>(),
|
||||
agentValidationService,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -120,6 +131,7 @@ function makeService() {
|
||||
attachableWorkflowsService,
|
||||
agentTaskService,
|
||||
agentPublishService,
|
||||
agentValidationService,
|
||||
nodeTypes,
|
||||
outboundHttp,
|
||||
};
|
||||
@@ -245,6 +257,15 @@ describe('AgentsBuilderToolsService', () => {
|
||||
expect(toolNames).toContain(BUILDER_TOOLS.RESOLVE_INTEGRATION);
|
||||
});
|
||||
|
||||
it('registers the finish_setup interactive tool in the builder toolset', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const toolNames = service
|
||||
.getTools(agentId, projectId, credentialProvider, user)
|
||||
.json.map((tool) => tool.name);
|
||||
expect(toolNames).toContain(BUILDER_TOOLS.FINISH_SETUP);
|
||||
});
|
||||
|
||||
it('registers publish and unpublish tools in the builder toolset', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
@@ -276,6 +297,49 @@ describe('AgentsBuilderToolsService', () => {
|
||||
config: { ...baseConfig, integrations: [] },
|
||||
configHash: getAgentConfigHash({ ...baseConfig, integrations: [] }),
|
||||
});
|
||||
expect(result).not.toHaveProperty('configMutated');
|
||||
});
|
||||
|
||||
it('write_config success result carries configMutated and agentId', async () => {
|
||||
const { service, agentsService } = makeService();
|
||||
const currentConfig = { ...baseConfig, integrations: [] };
|
||||
const updatedConfig = { ...currentConfig, instructions: 'Help with support tickets.' };
|
||||
const normalizedConfig = {
|
||||
...updatedConfig,
|
||||
config: { webSearch: { enabled: true }, promptCaching: { enabled: true } },
|
||||
};
|
||||
agentsService.findById.mockResolvedValue(makeAgent(baseConfig));
|
||||
agentsService.updateConfig.mockResolvedValue({
|
||||
config: normalizedConfig,
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
versionId: 'v2',
|
||||
});
|
||||
|
||||
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
|
||||
{
|
||||
baseConfigHash: getAgentConfigHash(currentConfig),
|
||||
json: JSON.stringify(updatedConfig),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
it('write_config failure result is not stamped with configMutated', async () => {
|
||||
const { service, agentsService } = makeService();
|
||||
agentsService.findById.mockResolvedValue(makeAgent(baseConfig));
|
||||
|
||||
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
|
||||
{
|
||||
baseConfigHash: 'stale-hash',
|
||||
json: JSON.stringify(baseConfig),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ ok: false }));
|
||||
expect(result).not.toHaveProperty('configMutated');
|
||||
});
|
||||
|
||||
it('list_integration_types returns builder guidance for integration versus node-tool choice', async () => {
|
||||
@@ -383,7 +447,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
it('patch_config rejects stale baseConfigHash without updating or echoing the config', async () => {
|
||||
@@ -452,7 +516,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
integrations: [currentIntegrations[0], currentIntegrations[2]],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
it('patch_config strips legacy schedule integrations from the current snapshot', async () => {
|
||||
@@ -522,7 +586,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
it('write_config strips legacy schedule integrations before saving', async () => {
|
||||
@@ -680,7 +744,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
it('patch_config allows $fromAI on runtime fields when dynamic selectors are fixed', async () => {
|
||||
@@ -723,7 +787,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
it('write_config allows unrelated edits when an existing dynamic selector already uses $fromAI', async () => {
|
||||
@@ -760,7 +824,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
it('patch_config allows unrelated edits when an existing dynamic selector already uses $fromAI', async () => {
|
||||
@@ -799,7 +863,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
});
|
||||
|
||||
// Native web-search provider-tool derivation (add defaults, fill missing
|
||||
@@ -873,7 +937,61 @@ describe('AgentsBuilderToolsService', () => {
|
||||
expect(agentsService.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('write_config rejects draft LLM config without updating', async () => {
|
||||
it('write_config accepts a draft config without model and credential', async () => {
|
||||
const { service, agentsService } = makeService();
|
||||
const { credential: _credential, ...draftBase } = baseConfig;
|
||||
const currentDraftConfig = { ...draftBase, model: '', integrations: [] };
|
||||
const draftConfig = { ...currentDraftConfig, credential: undefined };
|
||||
agentsService.findById.mockResolvedValue(
|
||||
makeAgent({ ...draftBase, model: '' } as AgentJsonConfig),
|
||||
);
|
||||
agentsService.updateConfig.mockResolvedValue({
|
||||
config: { ...currentDraftConfig, model: '' },
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
versionId: 'v2',
|
||||
});
|
||||
|
||||
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
|
||||
{
|
||||
baseConfigHash: getAgentConfigHash(currentDraftConfig),
|
||||
json: JSON.stringify(draftConfig),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
projectId,
|
||||
expect.objectContaining({ model: '', instructions: 'Help the user.' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('write_config still rejects empty instructions on a draft', async () => {
|
||||
const { service, agentsService } = makeService();
|
||||
const { credential: _credential, ...draftBase } = baseConfig;
|
||||
const currentDraftConfig = { ...draftBase, model: '', integrations: [] };
|
||||
const draftConfig = { ...currentDraftConfig, instructions: '' };
|
||||
agentsService.findById.mockResolvedValue(
|
||||
makeAgent({ ...draftBase, model: '' } as AgentJsonConfig),
|
||||
);
|
||||
|
||||
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
|
||||
{
|
||||
baseConfigHash: getAgentConfigHash(currentDraftConfig),
|
||||
json: JSON.stringify(draftConfig),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [expect.objectContaining({ path: '/instructions' })],
|
||||
});
|
||||
expect(agentsService.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('write_config rejects a draft payload when the agent already has a model', async () => {
|
||||
const { service, agentsService } = makeService();
|
||||
const currentConfig = { ...baseConfig, integrations: [] };
|
||||
const draftConfig = { ...currentConfig, model: '', credential: undefined };
|
||||
@@ -887,7 +1005,6 @@ describe('AgentsBuilderToolsService', () => {
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(agentsService.updateConfig).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: expect.arrayContaining([
|
||||
@@ -895,6 +1012,58 @@ describe('AgentsBuilderToolsService', () => {
|
||||
expect.objectContaining({ path: 'credential' }),
|
||||
]),
|
||||
});
|
||||
expect(agentsService.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('patch_config succeeds on a draft config without model and credential', async () => {
|
||||
const { service, agentsService } = makeService();
|
||||
const { credential: _credential, ...noCredential } = baseConfig;
|
||||
const draftBase = { ...noCredential, model: '' };
|
||||
const currentConfig = { ...draftBase, integrations: [] };
|
||||
agentsService.findById.mockResolvedValue(makeAgent(draftBase as AgentJsonConfig));
|
||||
agentsService.updateConfig.mockResolvedValue({
|
||||
config: { ...currentConfig, instructions: 'Triage Slack messages.' },
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
versionId: 'v2',
|
||||
});
|
||||
|
||||
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
|
||||
{
|
||||
baseConfigHash: getAgentConfigHash(currentConfig),
|
||||
operations: JSON.stringify([
|
||||
{ op: 'replace', path: '/instructions', value: 'Triage Slack messages.' },
|
||||
]),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, configMutated: true, agentId });
|
||||
expect(agentsService.updateConfig).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
projectId,
|
||||
expect.objectContaining({ model: '', instructions: 'Triage Slack messages.' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('patch_config rejects clearing /model on a configured agent', async () => {
|
||||
const { service, agentsService } = makeService();
|
||||
const currentConfig = { ...baseConfig, integrations: [] };
|
||||
agentsService.findById.mockResolvedValue(makeAgent(baseConfig));
|
||||
|
||||
const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!(
|
||||
{
|
||||
baseConfigHash: getAgentConfigHash(currentConfig),
|
||||
operations: JSON.stringify([{ op: 'replace', path: '/model', value: '' }]),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
stage: 'schema',
|
||||
errors: expect.arrayContaining([expect.objectContaining({ path: 'model' })]),
|
||||
});
|
||||
expect(agentsService.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('write_config rejects stale baseConfigHash without updating or echoing the config', async () => {
|
||||
@@ -1350,7 +1519,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
'Pass every task you currently know how to write in one `tasks` array',
|
||||
);
|
||||
expect(tool.description).toContain('config.tasks');
|
||||
expect(tool.description).toContain('{ ok: true, tasks:');
|
||||
expect(tool.description).toContain('{ ok: true, configMutated: true, agentId, tasks:');
|
||||
expect(tool.description).toContain('{ ok: false, errors }');
|
||||
});
|
||||
|
||||
@@ -1409,6 +1578,8 @@ describe('AgentsBuilderToolsService', () => {
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
configMutated: true,
|
||||
agentId,
|
||||
tasks: [
|
||||
{ id: 'task-1', name: taskOneInput.name, enabled: true },
|
||||
{ id: 'task-2', name: taskTwoInput.name, enabled: true },
|
||||
@@ -1495,6 +1666,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
configMutated: true,
|
||||
agentId,
|
||||
activeVersionId: 'v-active',
|
||||
versionId: 'v-active',
|
||||
@@ -1521,6 +1693,7 @@ describe('AgentsBuilderToolsService', () => {
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
configMutated: true,
|
||||
agentId,
|
||||
activeVersionId: 'v-history',
|
||||
versionId: 'v-draft',
|
||||
@@ -1568,7 +1741,12 @@ describe('AgentsBuilderToolsService', () => {
|
||||
projectId,
|
||||
});
|
||||
expect(agentPublishService.unpublishAgent).toHaveBeenCalledWith(agentId, projectId);
|
||||
expect(result).toEqual({ ok: true, agentId, activeVersionId: null });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
configMutated: true,
|
||||
agentId,
|
||||
activeVersionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('denies unpublish when the user lacks agent:unpublish', async () => {
|
||||
|
||||
@@ -70,7 +70,12 @@ export class AgentIntegrationPersistenceService {
|
||||
throw new UserError('Credential integration requires a credential ID.');
|
||||
}
|
||||
|
||||
const existing = agent.integrations ?? [];
|
||||
// Drop a same-type draft entry (empty credentialId, written by the builder
|
||||
// before setup completes) so connecting a real credential replaces it
|
||||
// instead of leaving both the draft and the connected entry behind.
|
||||
const existing = (agent.integrations ?? []).filter(
|
||||
(i) => !(i.type === type && i.credentialId === ''),
|
||||
);
|
||||
const alreadyExists = existing.some((i) => i.type === type && i.credentialId === credentialId);
|
||||
|
||||
agent.integrations = alreadyExists
|
||||
|
||||
@@ -82,7 +82,7 @@ export class AgentIntegrationsController {
|
||||
agent.projectId,
|
||||
req.user,
|
||||
undefined,
|
||||
{ syncIntegrations: false },
|
||||
{ syncIntegrations: false, ignoreDraftIntegrations: true },
|
||||
);
|
||||
await this.chatIntegrationService.connect(agentId, integration, agent.projectId);
|
||||
await this.chatIntegrationService.broadcastIntegrationChange(agentId, integration, 'connect');
|
||||
@@ -215,11 +215,17 @@ export class AgentIntegrationsController {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
|
||||
const chatIntegrations = (agent.integrations ?? []).map((i) => ({
|
||||
type: i.type,
|
||||
credentialId: i.credentialId,
|
||||
...('settings' in i ? { settings: i.settings } : {}),
|
||||
}));
|
||||
// Draft entries (`credentialId: ''`) written during the initial build so
|
||||
// the panel can show a needs-setup chip aren't a real connection — report
|
||||
// them as disconnected so channel-setup UIs don't render an already-
|
||||
// connected state and hide their own setup form.
|
||||
const chatIntegrations = (agent.integrations ?? [])
|
||||
.filter((i) => i.credentialId !== '')
|
||||
.map((i) => ({
|
||||
type: i.type,
|
||||
credentialId: i.credentialId,
|
||||
...('settings' in i ? { settings: i.settings } : {}),
|
||||
}));
|
||||
return {
|
||||
status: chatIntegrations.length > 0 ? 'connected' : 'disconnected',
|
||||
integrations: chatIntegrations,
|
||||
|
||||
@@ -32,6 +32,13 @@ import { SubAgentCleanupService } from './sub-agents/sub-agent-cleanup.service';
|
||||
|
||||
export interface PublishAgentOptions {
|
||||
syncIntegrations?: boolean;
|
||||
/**
|
||||
* Validate as if not-yet-connected draft integrations (`credentialId: ''`)
|
||||
* didn't exist. Connect-time publishes (connecting one of several
|
||||
* drafted channels) pass this so another channel's still-unresolved
|
||||
* draft doesn't block publishing the one currently being connected.
|
||||
*/
|
||||
ignoreDraftIntegrations?: boolean;
|
||||
}
|
||||
|
||||
export type ValidAgentConfigValidationResponse = AgentConfigValidationResponse & {
|
||||
@@ -110,7 +117,14 @@ export class AgentPublishService {
|
||||
(await this.agentTaskRepository.findByAgentId(agentId)).map((task) => [task.id, task]),
|
||||
);
|
||||
|
||||
const validation = await this.assertPublishable(agent, projectId, user, tasks, targetHistory);
|
||||
const validation = await this.assertPublishable(
|
||||
agent,
|
||||
projectId,
|
||||
user,
|
||||
tasks,
|
||||
targetHistory,
|
||||
options.ignoreDraftIntegrations,
|
||||
);
|
||||
|
||||
await this.agentRepository.manager.transaction(async (trx) => {
|
||||
if (targetHistory) {
|
||||
@@ -178,6 +192,7 @@ export class AgentPublishService {
|
||||
user: User,
|
||||
tasks: ReadonlyMap<string, AgentTask>,
|
||||
targetHistory?: AgentHistory,
|
||||
ignoreDraftIntegrations?: boolean,
|
||||
): Promise<ValidAgentConfigValidationResponse> {
|
||||
const credentialProvider = new AgentsCredentialProvider(
|
||||
this.credentialsService,
|
||||
@@ -185,20 +200,34 @@ export class AgentPublishService {
|
||||
user,
|
||||
);
|
||||
|
||||
const baseIntegrations = agent.integrations ?? [];
|
||||
const integrations = ignoreDraftIntegrations
|
||||
? baseIntegrations.filter((integration) => integration.credentialId !== '')
|
||||
: baseIntegrations;
|
||||
|
||||
const validation = targetHistory
|
||||
? await this.agentValidationService.validateAgentHistoryConfiguration(
|
||||
agent.id,
|
||||
projectId,
|
||||
targetHistory,
|
||||
agent.integrations ?? [],
|
||||
integrations,
|
||||
credentialProvider,
|
||||
)
|
||||
: await this.agentValidationService.validateAgentEntityConfiguration(
|
||||
agent,
|
||||
projectId,
|
||||
tasks,
|
||||
credentialProvider,
|
||||
);
|
||||
: ignoreDraftIntegrations
|
||||
? await this.agentValidationService.validateAgentEntityConfiguration(
|
||||
agent,
|
||||
projectId,
|
||||
tasks,
|
||||
credentialProvider,
|
||||
'publish',
|
||||
integrations,
|
||||
)
|
||||
: await this.agentValidationService.validateAgentEntityConfiguration(
|
||||
agent,
|
||||
projectId,
|
||||
tasks,
|
||||
credentialProvider,
|
||||
);
|
||||
|
||||
requireValidValidation(validation);
|
||||
return validation;
|
||||
|
||||
@@ -161,6 +161,13 @@ export class AgentValidationService {
|
||||
tasks: ReadonlyMap<string, TaskBody>,
|
||||
credentialProvider: CredentialProvider,
|
||||
scope: AgentValidationScope = 'publish',
|
||||
/**
|
||||
* Validate against these integrations instead of `agent.integrations`.
|
||||
* Used by connect-time publishes to exclude not-yet-connected draft
|
||||
* entries (`credentialId: ''`) that would otherwise block publishing
|
||||
* the channel currently being connected.
|
||||
*/
|
||||
integrationsOverride?: AgentIntegrationConfig[],
|
||||
): Promise<AgentConfigValidationResponse> {
|
||||
return await this.runValidation(
|
||||
{
|
||||
@@ -169,7 +176,7 @@ export class AgentValidationService {
|
||||
config: agent.schema as unknown as AgentJsonConfig | null,
|
||||
skills: agent.skills ?? {},
|
||||
customTools: agent.tools ?? {},
|
||||
integrations: agent.integrations ?? [],
|
||||
integrations: integrationsOverride ?? agent.integrations ?? [],
|
||||
tasks,
|
||||
credentialProvider,
|
||||
},
|
||||
|
||||
+14
-27
@@ -119,27 +119,16 @@ describe('builder model recommendations', () => {
|
||||
expect(prompt).toContain('## Example flows');
|
||||
expect(prompt).toContain('## Response Style');
|
||||
expect(prompt).not.toContain('## Builder runtime skills');
|
||||
expect(prompt).toContain('agent-builder-integrations');
|
||||
expect(prompt).toContain('agent-builder-external-services');
|
||||
expect(prompt).toContain('agent-builder-memory');
|
||||
expect(prompt).toContain('agent-builder-node-tools');
|
||||
expect(prompt).toContain('agent-builder-custom-tools');
|
||||
expect(prompt).not.toContain('agent-builder-config-mutation');
|
||||
expect(prompt).not.toContain('agent-builder-llm-selection');
|
||||
|
||||
const nodeToolsSkill = getBuilderRuntimeSkills().find(
|
||||
(s) => s.id === 'agent-builder-node-tools',
|
||||
);
|
||||
expect(nodeToolsSkill?.instructions).toContain('agent-builder-resource-locators');
|
||||
});
|
||||
|
||||
it('does not tell the builder to write target agent descriptions', () => {
|
||||
const prompt = buildPrompt(null);
|
||||
|
||||
expect(prompt).not.toContain('Fresh agents must include a brief `description`');
|
||||
expect(prompt).toContain('Requires `name`, `model`, `credential`, and `instructions`');
|
||||
expect(prompt).not.toContain(
|
||||
'"description": "Answers support questions and helps triage customer issues."',
|
||||
const externalServicesSkill = getBuilderRuntimeSkills().find(
|
||||
(s) => s.id === 'agent-builder-external-services',
|
||||
);
|
||||
expect(externalServicesSkill?.instructions).toContain('agent-builder-resource-locators');
|
||||
});
|
||||
|
||||
it('routes subagent delegation to the sub-agent builder skill', () => {
|
||||
@@ -197,7 +186,7 @@ describe('builder model recommendations', () => {
|
||||
|
||||
it('keeps always-on interaction and workflow guidance in the main prompt, deferring expressions to a skill', () => {
|
||||
const prompt = buildPrompt('### Recommended LLM Models\n\n- OpenAI: `openai/gpt-5` GPT-5');
|
||||
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-node-tools');
|
||||
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-external-services');
|
||||
|
||||
expect(prompt).toContain('### Recommended LLM Models');
|
||||
expect(prompt).toContain('Never call two interactive tools in parallel');
|
||||
@@ -224,10 +213,8 @@ describe('builder model recommendations', () => {
|
||||
|
||||
expect(skills.map((skill) => skill.id)).toEqual([
|
||||
'agent-builder-custom-tools',
|
||||
'agent-builder-integrations',
|
||||
'agent-builder-mcp',
|
||||
'agent-builder-external-services',
|
||||
'agent-builder-memory',
|
||||
'agent-builder-node-tools',
|
||||
'agent-builder-resource-locators',
|
||||
'agent-builder-sub-agents',
|
||||
'agent-builder-target-skills',
|
||||
@@ -235,12 +222,12 @@ describe('builder model recommendations', () => {
|
||||
]);
|
||||
expect(skillsById.has('agent-builder-research')).toBe(false);
|
||||
|
||||
const integrations = skillsById.get('agent-builder-integrations');
|
||||
expect(integrations?.description).toContain(
|
||||
const externalServices = skillsById.get('agent-builder-external-services');
|
||||
expect(externalServices?.description).toContain(
|
||||
'chat integration/trigger versus an MCP, node, or workflow tool',
|
||||
);
|
||||
expect(integrations?.instructions).toContain('Integration vs Callable Tool Decision');
|
||||
expect(integrations?.instructions).toContain('Linear callable tools');
|
||||
expect(externalServices?.instructions).toContain('Integration vs Callable Tool Decision');
|
||||
expect(externalServices?.instructions).toContain('Linear callable tools');
|
||||
|
||||
const resourceLocators = skillsById.get('agent-builder-resource-locators');
|
||||
expect(resourceLocators?.description).toContain('stable dynamic selector fields');
|
||||
@@ -249,11 +236,11 @@ describe('builder model recommendations', () => {
|
||||
});
|
||||
|
||||
it('does not tell the builder to prefer Slack OAuth credentials for chat integrations', () => {
|
||||
const integrationsSkill = getBuilderRuntimeSkills().find(
|
||||
(skill) => skill.id === 'agent-builder-integrations',
|
||||
const externalServicesSkill = getBuilderRuntimeSkills().find(
|
||||
(skill) => skill.id === 'agent-builder-external-services',
|
||||
);
|
||||
|
||||
expect(integrationsSkill?.instructions).not.toContain('slackOAuth2Api');
|
||||
expect(integrationsSkill?.instructions).not.toContain('prefer the OAuth variant');
|
||||
expect(externalServicesSkill?.instructions).not.toContain('slackOAuth2Api');
|
||||
expect(externalServicesSkill?.instructions).not.toContain('prefer the OAuth variant');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { SUB_AGENT_MAX_CHILDREN_MAX, SUB_AGENT_MAX_CHILDREN_MIN } from '@n8n/api-types';
|
||||
|
||||
import {
|
||||
FEW_SHOT_FLOWS_SECTION,
|
||||
INTERACTIVE_TOOLS_SECTION,
|
||||
READ_CONFIG_FRESHNESS_SECTION,
|
||||
WORKFLOW_SECTION,
|
||||
buildBuilderPrompt,
|
||||
} from '../agents-builder-prompts';
|
||||
import { getConfigMutationPrompt } from '../prompts/config-mutation.prompt';
|
||||
import { getLlmSelectionPrompt } from '../prompts/llm-selection.prompt';
|
||||
import { TOOLS_PROMPT } from '../prompts/tools.prompt';
|
||||
import { getBuilderRuntimeSkills } from '../skills';
|
||||
|
||||
function normalizeWhitespace(value: string | undefined): string {
|
||||
return value?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
}
|
||||
|
||||
describe('builder prompt stability', () => {
|
||||
it('omits stale agent state while retaining config freshness guidance', () => {
|
||||
const prompt = buildBuilderPrompt({
|
||||
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
|
||||
modelRecommendationsSection: null,
|
||||
});
|
||||
|
||||
expect(prompt).not.toContain('## Current Agent Config');
|
||||
expect(prompt).not.toContain('\n## Custom Tools\n');
|
||||
expect(prompt).not.toContain('## Builder runtime skills');
|
||||
expect(prompt).not.toContain('## Important');
|
||||
expect(prompt).toContain(
|
||||
'Always call `read_config` first whenever a request touches the config',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create_skills / create_tasks batching guidance', () => {
|
||||
it('names only the plural batch tools, not the old singular ones', () => {
|
||||
const prompt = buildBuilderPrompt({
|
||||
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
|
||||
modelRecommendationsSection: null,
|
||||
});
|
||||
|
||||
expect(prompt).toContain('`create_skills`');
|
||||
expect(prompt).toContain('`create_tasks`');
|
||||
expect(prompt).not.toContain('`create_skill`');
|
||||
expect(prompt).not.toContain('`create_task`');
|
||||
|
||||
for (const skill of getBuilderRuntimeSkills()) {
|
||||
expect(skill.allowedTools ?? []).not.toContain('create_skill');
|
||||
expect(skill.allowedTools ?? []).not.toContain('create_task');
|
||||
expect(skill.instructions).not.toContain('`create_skill`');
|
||||
expect(skill.instructions).not.toContain('`create_task`');
|
||||
}
|
||||
});
|
||||
|
||||
it('mandates one call per fully-specified batch, not one call per item', () => {
|
||||
const skills = getBuilderRuntimeSkills();
|
||||
const targetSkills = skills.find((skill) => skill.id === 'agent-builder-target-skills');
|
||||
const targetTasks = skills.find((skill) => skill.id === 'agent-builder-target-tasks');
|
||||
|
||||
expect(targetSkills?.instructions).toContain(
|
||||
'do not spread multiple fully-specified skills\n across separate calls',
|
||||
);
|
||||
expect(targetTasks?.instructions).toContain(
|
||||
'do not spread multiple fully-specified tasks\n across separate calls',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the skill-attachment and task-publish rules for the batched tools', () => {
|
||||
const skills = getBuilderRuntimeSkills();
|
||||
const targetSkills = skills.find((skill) => skill.id === 'agent-builder-target-skills');
|
||||
const targetTasks = skills.find((skill) => skill.id === 'agent-builder-target-tasks');
|
||||
|
||||
expect(targetSkills?.instructions).toContain(
|
||||
'Use `patch_config` or `write_config` to add a `{ "type": "skill", "id": "<returned id>" }`',
|
||||
);
|
||||
expect(targetTasks?.instructions).toContain(
|
||||
'`create_tasks` adds a `{ type: "task", id, enabled }` ref per task to',
|
||||
);
|
||||
expect(targetTasks?.instructions).toContain(
|
||||
'only start running once the agent is (re)published',
|
||||
);
|
||||
});
|
||||
|
||||
it('permits create_skills and create_tasks together in one turn, never with an interactive tool or config mutation', () => {
|
||||
expect(WORKFLOW_SECTION).toContain(
|
||||
'call `create_skills`\n and `create_tasks` in the same assistant response',
|
||||
);
|
||||
expect(WORKFLOW_SECTION).toContain(
|
||||
'Do not combine either\n with an interactive tool or `write_config`/`patch_config` in that response.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact tool output guidance', () => {
|
||||
it('requires a fresh read_config before retrying a stale write/patch', () => {
|
||||
expect(READ_CONFIG_FRESHNESS_SECTION).toContain(
|
||||
'`patch_config` returns `stage: "stale"`, call `read_config` and retry once\nusing the `config` and `configHash` it returns.',
|
||||
);
|
||||
|
||||
const mutationPrompt = getConfigMutationPrompt();
|
||||
expect(mutationPrompt).toContain('Follow Config Freshness');
|
||||
expect(mutationPrompt).not.toContain('retry once using the');
|
||||
});
|
||||
|
||||
it('never claims a stale or mutation-success response carries the config or configHash', () => {
|
||||
expect(READ_CONFIG_FRESHNESS_SECTION).toContain(
|
||||
'`read_config` is the only tool that returns the full `config`. A successful\n`write_config`/`patch_config` returns only `{ ok: true }` as confirmation\n— never the config, its hash, timestamps, or version — so it cannot serve as\na `baseConfigHash` for a later write.',
|
||||
);
|
||||
expect(READ_CONFIG_FRESHNESS_SECTION).not.toContain('configHash`, `updatedAt`');
|
||||
expect(READ_CONFIG_FRESHNESS_SECTION).not.toContain('retry once\nfrom the returned');
|
||||
|
||||
const mutationPrompt = getConfigMutationPrompt();
|
||||
expect(mutationPrompt).not.toContain('never echo the config back');
|
||||
expect(mutationPrompt).not.toContain('retry once from the returned');
|
||||
});
|
||||
|
||||
it('requires an immediately preceding read_config before every later mutation', () => {
|
||||
expect(READ_CONFIG_FRESHNESS_SECTION).toContain(
|
||||
'Call `read_config`\nagain immediately before every later mutation and before any later\ninspection of the config.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt-caching rule dedup', () => {
|
||||
it('states the detailed mandatory prompt-caching rule once, in Agent Config Rules', () => {
|
||||
const prompt = buildBuilderPrompt({
|
||||
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
|
||||
modelRecommendationsSection: null,
|
||||
});
|
||||
|
||||
const detailedRule = 'this is mandatory and must\n never be disabled';
|
||||
expect(prompt.split(detailedRule)).toHaveLength(2);
|
||||
|
||||
const rulesIndex = prompt.indexOf('#### Agent Config Rules');
|
||||
const ruleIndex = prompt.indexOf(detailedRule);
|
||||
expect(rulesIndex).toBeGreaterThan(-1);
|
||||
expect(ruleIndex).toBeGreaterThan(rulesIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agents builder integrations prompt', () => {
|
||||
it('does not tell the builder to prefer Slack OAuth credentials for chat integrations', () => {
|
||||
const integrationsSkill = getBuilderRuntimeSkills().find(
|
||||
(skill) => skill.id === 'agent-builder-integrations',
|
||||
);
|
||||
|
||||
expect(integrationsSkill?.instructions).not.toContain('slackOAuth2Api');
|
||||
expect(integrationsSkill?.instructions).not.toContain('prefer the OAuth variant');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat-channel credential guidance', () => {
|
||||
it('mandates configure_channel and forbids ask_credential for chat-channel credentials', () => {
|
||||
expect(INTERACTIVE_TOOLS_SECTION).toContain(
|
||||
'`ask_credential` for node-tool, MCP-server, and fallback web-search credentials',
|
||||
);
|
||||
expect(normalizeWhitespace(INTERACTIVE_TOOLS_SECTION)).toContain(
|
||||
"anything that isn't a node-tool credential, MCP-server credential, fallback web-search credential, or channel choice",
|
||||
);
|
||||
expect(INTERACTIVE_TOOLS_SECTION).toContain(
|
||||
'NEVER use it for a chat-channel\n credential — use `configure_channel` instead.',
|
||||
);
|
||||
expect(INTERACTIVE_TOOLS_SECTION).toContain(
|
||||
'`configure_channel`: ALWAYS use this to connect a chat platform',
|
||||
);
|
||||
|
||||
const integrationsSkill = getBuilderRuntimeSkills().find(
|
||||
(skill) => skill.id === 'agent-builder-integrations',
|
||||
);
|
||||
expect(integrationsSkill?.instructions).toContain(
|
||||
'ALWAYS use `configure_channel` for chat-channel\n credentials — never `ask_credential`',
|
||||
);
|
||||
|
||||
const llmSelectionPrompt = getLlmSelectionPrompt(null);
|
||||
expect(llmSelectionPrompt).toContain(
|
||||
'Use `ask_credential` for node tools, MCP servers, and fallback web-search credentials.',
|
||||
);
|
||||
expect(llmSelectionPrompt).not.toContain(
|
||||
'Use `ask_credential` for node tools and integrations.',
|
||||
);
|
||||
});
|
||||
|
||||
it('references ask_questions in the batching guidance', () => {
|
||||
expect(WORKFLOW_SECTION).toContain('Clarify missing decisions through the Interactive tools');
|
||||
expect(INTERACTIVE_TOOLS_SECTION).toContain(
|
||||
'Batch every\n question you currently need into a single call',
|
||||
);
|
||||
});
|
||||
|
||||
it('tells the builder how to remove an existing chat integration', () => {
|
||||
const integrationsSkill = getBuilderRuntimeSkills().find(
|
||||
(skill) => skill.id === 'agent-builder-integrations',
|
||||
);
|
||||
|
||||
expect(integrationsSkill?.recommendedTools).toContain('ask_questions');
|
||||
expect(integrationsSkill?.allowedTools).toContain('ask_questions');
|
||||
expect(integrationsSkill?.instructions).toContain('To remove an existing chat integration');
|
||||
expect(integrationsSkill?.instructions).toContain('config.integrations');
|
||||
expect(integrationsSkill?.instructions).toContain(
|
||||
'If multiple existing integrations match the requested platform, ask which one',
|
||||
);
|
||||
expect(integrationsSkill?.instructions).toContain(
|
||||
'Do not call `configure_channel` to remove a channel.',
|
||||
);
|
||||
expect(getConfigMutationPrompt()).toContain('#### Remove An Existing Chat Integration');
|
||||
expect(getConfigMutationPrompt()).toContain(
|
||||
'Omitting `integrations` preserves existing channels.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('external integration routing guidance', () => {
|
||||
it('routes chat and trigger surfaces before resolving callable services', () => {
|
||||
const toolsPrompt = normalizeWhitespace(TOOLS_PROMPT);
|
||||
|
||||
expect(toolsPrompt).toContain(
|
||||
"first decide whether it is the target agent's chat or trigger surface",
|
||||
);
|
||||
expect(toolsPrompt).toContain(
|
||||
'Do not call `resolve_integration` for chat/trigger integrations.',
|
||||
);
|
||||
expect(toolsPrompt).toContain(
|
||||
'For each requested non-chat callable service, call `resolve_integration` separately',
|
||||
);
|
||||
|
||||
const slackFlow = normalizeWhitespace(
|
||||
FEW_SHOT_FLOWS_SECTION.split('### New agent: "Use Anthropic via OpenRouter"')[0],
|
||||
);
|
||||
expect(slackFlow).toContain('`list_integration_types()`');
|
||||
expect(slackFlow).toContain('`configure_channel({ integrationType: "slack" })`');
|
||||
expect(slackFlow).not.toContain('`resolve_integration`');
|
||||
});
|
||||
|
||||
it('selects an MCP candidate before credential and verification', () => {
|
||||
const mcpSkill = getBuilderRuntimeSkills().find((skill) => skill.id === 'agent-builder-mcp');
|
||||
const mcpInstructions = normalizeWhitespace(mcpSkill?.instructions);
|
||||
|
||||
expect(mcpInstructions).toContain(
|
||||
'`resolve_integration` returns `{ kind: "mcp", results: [...] }`',
|
||||
);
|
||||
expect(mcpInstructions).toContain('Never read server fields from the wrapper');
|
||||
expect(mcpInstructions).toContain('never choose by array order');
|
||||
expect(mcpInstructions).toContain('call `ask_questions` with the candidate');
|
||||
expect(mcpInstructions).toContain('`selectedResult.credentialType`');
|
||||
expect(mcpInstructions).toContain('returned `credentialId` as `credential`');
|
||||
expect(mcpInstructions).toContain(
|
||||
'If `ask_questions` returns `{ answered: false }`, stop MCP setup',
|
||||
);
|
||||
expect(mcpSkill?.recommendedTools).toContain('ask_questions');
|
||||
|
||||
const notionFlow = normalizeWhitespace(
|
||||
FEW_SHOT_FLOWS_SECTION.split('### Add MCP integration:')[1]?.split(
|
||||
'### Ambiguous request:',
|
||||
)[0],
|
||||
);
|
||||
expect(notionFlow).toContain('select one entry from `results[]`');
|
||||
expect(notionFlow).toContain('`selectedResult.credentialType`');
|
||||
expect(notionFlow).toContain('`credentialId` as `credential`');
|
||||
});
|
||||
|
||||
it('stops after channel setup and mutates config only for the non-chat branch', () => {
|
||||
const ambiguousFlow = normalizeWhitespace(
|
||||
FEW_SHOT_FLOWS_SECTION.split('### Ambiguous request:')[1]?.split(
|
||||
'### Publish after build:',
|
||||
)[0],
|
||||
);
|
||||
|
||||
expect(ambiguousFlow).toContain('After `configure_channel` returns, stop this flow');
|
||||
expect(ambiguousFlow).toContain('In this non-chat branch only, `read_config()`');
|
||||
|
||||
const chatBranch = ambiguousFlow.split('If it is a chat integration')[1]?.split('Otherwise')[0];
|
||||
expect(chatBranch).not.toContain('`read_config`');
|
||||
expect(chatBranch).not.toContain('`patch_config`');
|
||||
expect(chatBranch).not.toContain('`write_config`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP skill availability', () => {
|
||||
it('includes the MCP skill', () => {
|
||||
const skills = getBuilderRuntimeSkills();
|
||||
expect(skills.find((s) => s.id === 'agent-builder-mcp')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource locator skill availability', () => {
|
||||
it('includes builder guidance for node dynamic selectors', () => {
|
||||
const skills = getBuilderRuntimeSkills();
|
||||
const skill = skills.find((s) => s.id === 'agent-builder-resource-locators');
|
||||
|
||||
expect(skill).toBeDefined();
|
||||
expect(skill?.description).toContain('write_config/patch_config rejects $fromAI');
|
||||
expect(skill?.instructions).toContain('Linear `teamId`');
|
||||
expect(skill?.instructions).toContain('get_resource_locator_options');
|
||||
expect(skill?.instructions).toContain('parameterValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sub-agent skill availability', () => {
|
||||
it('contains the moved sub-agent delegation guidance', () => {
|
||||
const skill = getBuilderRuntimeSkills().find((s) => s.id === 'agent-builder-sub-agents');
|
||||
|
||||
expect(skill).toBeDefined();
|
||||
expect(skill?.instructions).toContain('`delegate_subagent`');
|
||||
expect(skill?.instructions).toContain('Call `list_sub_agents`');
|
||||
expect(skill?.instructions).toContain('`type: "multi"`');
|
||||
expect(skill?.instructions).toContain('subAgentId: "inline"');
|
||||
expect(skill?.instructions).toContain('`subAgents.maxChildren`');
|
||||
expect(skill?.instructions).toContain(
|
||||
'{ "agentId": "<returned-agent-id>", "useWhen": "Use for ..." }',
|
||||
);
|
||||
expect(skill?.instructions).toContain(
|
||||
'If it is unclear when a selected saved subagent should be used, ask the user',
|
||||
);
|
||||
expect(skill?.instructions).toContain('Do not write vague values');
|
||||
expect(skill?.instructions).toContain(
|
||||
`from ${SUB_AGENT_MAX_CHILDREN_MIN} to ${SUB_AGENT_MAX_CHILDREN_MAX}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+8
-2
@@ -116,6 +116,10 @@ const agentsSdkMocks = vi.hoisted(() => {
|
||||
return { model, options, kind: 'reflect' };
|
||||
}
|
||||
|
||||
function createPlannerTodosTool(): BuiltTool {
|
||||
return { name: 'write_todos', description: 'planner todos tool' } as BuiltTool;
|
||||
}
|
||||
|
||||
return {
|
||||
streamCalls,
|
||||
instructionsCalls,
|
||||
@@ -131,6 +135,7 @@ const agentsSdkMocks = vi.hoisted(() => {
|
||||
MockMemory,
|
||||
createObservationLogObserveFn,
|
||||
createObservationLogReflectFn,
|
||||
createPlannerTodosTool,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -139,6 +144,7 @@ vi.mock('@n8n/agents', () => ({
|
||||
Memory: agentsSdkMocks.MockMemory,
|
||||
createObservationLogObserveFn: agentsSdkMocks.createObservationLogObserveFn,
|
||||
createObservationLogReflectFn: agentsSdkMocks.createObservationLogReflectFn,
|
||||
createPlannerTodosTool: agentsSdkMocks.createPlannerTodosTool,
|
||||
}));
|
||||
|
||||
// Avoid a real `models.dev` catalog fetch — irrelevant to thread isolation and
|
||||
@@ -295,7 +301,7 @@ describe('AgentsBuilderService session isolation', () => {
|
||||
expect(n8nCheckpointStorage.delete).toHaveBeenCalledWith('run-1', 'agent-1');
|
||||
});
|
||||
|
||||
it('includes the integrations skill', async () => {
|
||||
it('includes the external services skill', async () => {
|
||||
const { service, user, credentialProvider } = setup();
|
||||
|
||||
await drain(
|
||||
@@ -303,7 +309,7 @@ describe('AgentsBuilderService session isolation', () => {
|
||||
);
|
||||
|
||||
const skills = agentsSdkMocks.skillsCalls[0] as Array<{ id: string }>;
|
||||
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(true);
|
||||
expect(skills.some((skill) => skill.id === 'agent-builder-external-services')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses session.modelConfig directly for the builder model', async () => {
|
||||
|
||||
@@ -22,12 +22,13 @@ vi.mock('../../json-config/mcp-client-factory', () => ({
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDeps() {
|
||||
function makeDeps(overrides: Partial<Parameters<typeof buildVerifyMcpServerTool>[0]> = {}) {
|
||||
return {
|
||||
credentialProvider: mock<CredentialProvider>(),
|
||||
oauthService: mock<OauthService>(),
|
||||
projectId: 'proj-1',
|
||||
proxyFetch: vi.fn() as unknown as CustomFetch,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,4 +257,136 @@ describe('buildVerifyMcpServerTool', () => {
|
||||
expect(result).toEqual({ ok: false, error: 'MCP server verification was cancelled' });
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('auto-applies the credential when verification succeeds and the callback reports applied', async () => {
|
||||
const applyCredentialToMcpServer = vi.fn().mockResolvedValue({ applied: true });
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockResolvedValue([{ name: 'echo', description: 'Echo the input' }]),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(
|
||||
makeDeps({
|
||||
agentId: 'agent-1',
|
||||
applyCredentialToMcpServer,
|
||||
}),
|
||||
);
|
||||
const result = await tool.handler!(
|
||||
{
|
||||
name: 'notion',
|
||||
url: 'https://example.test/mcp',
|
||||
credential: 'cred-42',
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(applyCredentialToMcpServer).toHaveBeenCalledWith('notion', 'cred-42');
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
tools: [{ name: 'echo', description: 'Echo the input' }],
|
||||
credentialApplied: true,
|
||||
configMutated: true,
|
||||
agentId: 'agent-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a plain success result when the callback reports not applied', async () => {
|
||||
const applyCredentialToMcpServer = vi.fn().mockResolvedValue({ applied: false });
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockResolvedValue([{ name: 'echo', description: 'Echo the input' }]),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(
|
||||
makeDeps({
|
||||
agentId: 'agent-1',
|
||||
applyCredentialToMcpServer,
|
||||
}),
|
||||
);
|
||||
const result = await tool.handler!(
|
||||
{
|
||||
name: 'notion',
|
||||
url: 'https://example.test/mcp',
|
||||
credential: 'cred-42',
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
tools: [{ name: 'echo', description: 'Echo the input' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns credentialApplied false when the callback throws', async () => {
|
||||
const applyCredentialToMcpServer = vi.fn().mockRejectedValue(new Error('config write failed'));
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockResolvedValue([{ name: 'echo', description: 'Echo the input' }]),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(
|
||||
makeDeps({
|
||||
agentId: 'agent-1',
|
||||
applyCredentialToMcpServer,
|
||||
}),
|
||||
);
|
||||
const result = await tool.handler!(
|
||||
{
|
||||
name: 'notion',
|
||||
url: 'https://example.test/mcp',
|
||||
credential: 'cred-42',
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
tools: [{ name: 'echo', description: 'Echo the input' }],
|
||||
credentialApplied: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call the credential callback when verification fails', async () => {
|
||||
const applyCredentialToMcpServer = vi.fn();
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockRejectedValue(new Error('connection timeout')),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(
|
||||
makeDeps({
|
||||
agentId: 'agent-1',
|
||||
applyCredentialToMcpServer,
|
||||
}),
|
||||
);
|
||||
await tool.handler!(
|
||||
{
|
||||
name: 'notion',
|
||||
url: 'https://example.test/mcp',
|
||||
credential: 'cred-42',
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(applyCredentialToMcpServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call the credential callback when no credential is provided', async () => {
|
||||
const applyCredentialToMcpServer = vi.fn();
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: vi.fn().mockResolvedValue([{ name: 'echo', description: 'Echo the input' }]),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(
|
||||
makeDeps({
|
||||
agentId: 'agent-1',
|
||||
applyCredentialToMcpServer,
|
||||
}),
|
||||
);
|
||||
await tool.handler!({ name: 'notion', url: 'https://example.test/mcp' }, {} as never);
|
||||
|
||||
expect(applyCredentialToMcpServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,11 +82,13 @@ tool-capable.
|
||||
Treat this list as authoritative for model recommendations. Use these models
|
||||
when the user does not know what model to pick. Prefer a recommended model for
|
||||
a provider the user has credentials for; then call resolve_llm with that
|
||||
provider and model, or ask via ask_questions if the user needs to choose a
|
||||
credential.
|
||||
provider and model. During an initial build, if the user still needs to
|
||||
choose a provider or credential, do not ask: include the model choice in the
|
||||
trailing finish_setup call instead.
|
||||
Do not mention models outside this list unless the user explicitly names one
|
||||
and resolve_llm validates it. Do not write a model or credential directly
|
||||
without a resolve_llm result.
|
||||
and resolve_llm validates it. Never write a non-empty model or credential
|
||||
without a resolve_llm result; drafts carry model "" while LLM setup is
|
||||
pending.
|
||||
|
||||
${rows.join('\n')}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getConfigMutationPrompt } from './prompts/config-mutation.prompt';
|
||||
import { INITIAL_BUILD_SECTION } from './prompts/initial-build.prompt';
|
||||
import { getLlmSelectionPrompt } from './prompts/llm-selection.prompt';
|
||||
import { MEMORY_PROMPT } from './prompts/memory.prompt';
|
||||
import { TOOLS_PROMPT } from './prompts/tools.prompt';
|
||||
@@ -26,8 +27,9 @@ agent in this Build chat, do not call tools. Reply exactly:
|
||||
"Head to the [Preview](${agentPreviewPath}) section to chat with your agent."
|
||||
Do not say anything else. Keep the Preview link as a relative app path.
|
||||
|
||||
Never write empty, placeholder, or guessed \`instructions\`. If you do not have
|
||||
enough detail to write meaningful instructions, ask the user first.`;
|
||||
Never write empty or placeholder \`instructions\`. When the user gave a
|
||||
concrete goal, write real instructions from it and fill gaps with sensible assumptions
|
||||
stated in your summary. Only ask first when the overall goal itself is missing.`;
|
||||
}
|
||||
|
||||
export const INTERACTIVE_TOOLS_SECTION = `\
|
||||
@@ -47,14 +49,32 @@ Exception: the opening reply to a greeting, a "what do you do", or a vague
|
||||
intent — there you reply conversationally and ask for the overall goal, per
|
||||
"When To Build vs When To Converse".
|
||||
|
||||
"Initial build" means the first build pass on a fresh agent; per the Initial
|
||||
Build section, never suspend during it except the single trailing
|
||||
\`finish_setup\` call. Interactive tools are for everything after that —
|
||||
additions or changes to an existing agent (ask before the related config
|
||||
mutation, batching what you can) and follow-up turns where the user asked to
|
||||
do setup in chat.
|
||||
|
||||
- \`finish_setup\`: use ONCE, only in the trailing step of an initial build
|
||||
when only blocked tasks remain — the model choice and every open decision
|
||||
as \`questions\`, one \`credentialRequests\` entry per credential slot, and
|
||||
one \`channels\` entry per drafted channel integration. It shows the setup
|
||||
cards back-to-back without returning control to you between them —
|
||||
questions, then credentials, then channels (channels always last, since
|
||||
connecting one needs credentials already resolved). Never call it
|
||||
together with another interactive tool.
|
||||
- \`ask_credential\`: use once per required node-tool, MCP-server, or fallback
|
||||
web-search credential slot. For node tools and fallback web search, call it
|
||||
before the related config mutation; for MCP servers, call it before
|
||||
verification. NEVER use it for a chat-channel
|
||||
web-search credential slot. During an initial build, never call it
|
||||
(see Initial Build). For an addition to an existing
|
||||
agent, call it before the related config mutation. For MCP servers, call it
|
||||
before verification. NEVER use it for a chat-channel
|
||||
credential — use \`configure_channel\` instead.
|
||||
- \`configure_channel\`: ALWAYS use this to connect a chat platform (Slack,
|
||||
Telegram, ...) as an agent channel, with a type from \`list_integration_types\`.
|
||||
The setup UI creates and persists the credential itself.
|
||||
The setup UI creates and persists the credential itself. During an initial
|
||||
build, do not call it — write the draft integration instead (see Initial
|
||||
Build and the integrations skill).
|
||||
- \`ask_questions\`: the default way to ask the user anything that isn't a
|
||||
node-tool credential, MCP-server credential, fallback web-search credential,
|
||||
or channel choice, including when the user must choose, confirm, configure, or
|
||||
@@ -63,8 +83,11 @@ intent — there you reply conversationally and ask for the overall goal, per
|
||||
question you currently need into a single call instead of asking one at a
|
||||
time. Each question is single-select, multi-select, or free-text; pass
|
||||
discrete \`options\` for a known small set of choices, or \`type: "text"\` for
|
||||
an open-ended question.
|
||||
an open-ended question. Never call it during an initial build
|
||||
(see Initial Build).
|
||||
- Never call two interactive tools in parallel. The run suspends on the first.
|
||||
- Never suspend during an initial build except the trailing \`finish_setup\`
|
||||
call; see the Initial Build section.
|
||||
- Never re-ask a question the user already answered in this thread.
|
||||
- After resume, continue with the next concrete tool action. Do not narrate the
|
||||
answer back to the user.`;
|
||||
@@ -103,21 +126,37 @@ export const RESPONSE_STYLE_SECTION = `\
|
||||
|
||||
Be concise. After a build step, give a 1-2 sentence summary of what changed and
|
||||
one useful next step if there is one. Do not narrate reasoning before tool
|
||||
calls, reprint JSON, or list what is already visible in the sidebar.`;
|
||||
calls, reprint JSON, or list what is already visible in the sidebar. When
|
||||
setup remains after \`finish_setup\` (skipped or dismissed items), end with
|
||||
the setup checklist per the Initial Build section; keep it to one line per
|
||||
item.`;
|
||||
|
||||
export const WORKFLOW_SECTION = `\
|
||||
## Workflow
|
||||
|
||||
1. Clarify missing decisions through the Interactive tools, batching questions.
|
||||
2. For fresh agents, resolve the main model and credential with \`resolve_llm\`.
|
||||
3. Draft real target-agent \`instructions\`; never write empty placeholders.
|
||||
1. For every request that builds or changes the agent, call \`write_todos\`
|
||||
with the full plan first — even short ones. Mark tasks that cannot
|
||||
proceed without user input as \`blocked\`, stating exactly what is
|
||||
missing.
|
||||
2. For fresh agents, call \`resolve_llm\` once, silently. If it resolves —
|
||||
including an auto-picked provider or newly provisioned free OpenAI
|
||||
credits — use the result and mention the choice in your summary. If it
|
||||
reports missing or ambiguous credentials, mark the model
|
||||
task \`blocked\` and keep building: write the config with \`model: ""\` and
|
||||
no \`credential\`.
|
||||
3. Draft real target-agent \`instructions\` and write the config early; never
|
||||
write empty placeholders, and never wait for setup answers before writing
|
||||
instructions, tools, skills, or tasks.
|
||||
4. Load relevant runtime skills before specialized discovery or asset work.
|
||||
5. Perform discovery and create any requested tools, skills, or tasks.
|
||||
6. Follow Config Freshness immediately before every config mutation.
|
||||
7. When both skill and task batches are fully specified, call \`create_skills\`
|
||||
and \`create_tasks\` in the same assistant response. Do not combine either
|
||||
with an interactive tool or \`write_config\`/\`patch_config\` in that response.
|
||||
8. When the user asks to publish, activate, or make the agent live/usable, call
|
||||
8. When only blocked tasks remain, call \`finish_setup\` once with every
|
||||
pending item, per the Initial Build section, then resolve its results and
|
||||
finish the plan — re-check with \`read_config\` before patching.
|
||||
9. When the user asks to publish, activate, or make the agent live/usable, call
|
||||
\`publish_agent\`. Never tell them to click Publish in the editor. Do not
|
||||
auto-publish without that intent. Use \`unpublish_agent\` when they ask to
|
||||
unpublish.`;
|
||||
@@ -126,29 +165,41 @@ export const FEW_SHOT_FLOWS_SECTION = `\
|
||||
## Example flows
|
||||
|
||||
### New agent: "Build me an agent teammates can @mention in Slack to triage messages"
|
||||
1. \`ask_questions({ ... })\` for the model choice, then
|
||||
\`resolve_llm({ provider, model })\` -> resolved provider, model, and credential.
|
||||
1. \`write_todos\` with the plan. \`resolve_llm({})\` once, silently; if it
|
||||
reports missing credentials, mark the model task \`blocked\`.
|
||||
2. \`read_config()\`.
|
||||
3. \`write_config(...)\` with the model, credential, and instructions.
|
||||
4. Load \`agent-builder-integrations\`, call \`list_integration_types()\`, and
|
||||
select the returned Slack type.
|
||||
5. \`configure_channel({ integrationType: "slack" })\`. The setup UI persists or
|
||||
skips the channel; do not follow it with a config read or mutation.
|
||||
3. \`write_config(...)\` with the instructions, and the resolved model and
|
||||
credential — or \`model: ""\` and no \`credential\` while the model task
|
||||
is blocked.
|
||||
4. Load \`agent-builder-external-services\`, call \`list_integration_types()\`,
|
||||
\`read_config()\`, then \`patch_config(...)\` adding the returned Slack type
|
||||
to \`/integrations/-\` with \`credentialId: ""\`.
|
||||
5. \`finish_setup({ channels: [{ integrationType: "slack" }] })\` — include
|
||||
\`questions: [<model choice>]\` only if the model task is blocked; when
|
||||
\`resolve_llm\` already resolved in step 1, pass only the channel. For a
|
||||
model answer, call \`resolve_llm\` with it, then \`read_config()\` and
|
||||
\`patch_config(...)\` replacing \`/model\` and \`/credential\`. The channel
|
||||
card in \`finish_setup\` already persisted or skipped the Slack
|
||||
connection — do not follow it with a config mutation. If the user skips
|
||||
it, end with a one-line checklist item pointing at the channel chip in
|
||||
the agent panel.
|
||||
|
||||
### New agent: "Use Anthropic via OpenRouter"
|
||||
1. \`resolve_llm({ provider: "openrouter" })\`.
|
||||
2. \`read_config()\`.
|
||||
3. \`write_config(...)\` with \`model: "openrouter/{resolvedModel}"\`,
|
||||
1. \`write_todos\` with the plan.
|
||||
2. \`resolve_llm({ provider: "openrouter" })\`.
|
||||
3. \`read_config()\`.
|
||||
4. \`write_config(...)\` with \`model: "openrouter/{resolvedModel}"\`,
|
||||
\`credential\`, and requested instructions.
|
||||
|
||||
### Change the existing model
|
||||
1. \`ask_questions({ ... })\` for the new model choice, then
|
||||
1. \`write_todos\` with the plan.
|
||||
2. \`ask_questions({ ... })\` for the new model choice, then
|
||||
\`resolve_llm({ provider, model })\`.
|
||||
2. \`read_config()\`.
|
||||
3. \`patch_config(...)\` replacing \`/model\` and \`/credential\`.
|
||||
3. \`read_config()\`.
|
||||
4. \`patch_config(...)\` replacing \`/model\` and \`/credential\`.
|
||||
|
||||
### Add an explicitly requested n8n node tool to an existing agent
|
||||
1. Load \`agent-builder-node-tools\`, then call \`search_nodes\` and
|
||||
1. Load \`agent-builder-external-services\`, then call \`search_nodes\` and
|
||||
\`get_node_types\`; the explicit n8n-node request does not need
|
||||
\`resolve_integration\`.
|
||||
2. \`ask_credential\` for every required slot.
|
||||
@@ -156,16 +207,27 @@ export const FEW_SHOT_FLOWS_SECTION = `\
|
||||
4. \`patch_config(...)\` adding the node tool to \`/tools/-\`.
|
||||
|
||||
### Add an explicitly requested n8n node tool when credential setup is skipped
|
||||
1. Load \`agent-builder-node-tools\`, then call \`search_nodes\` and
|
||||
1. Load \`agent-builder-external-services\`, then call \`search_nodes\` and
|
||||
\`get_node_types\`.
|
||||
2. \`ask_credential(...)\` -> \`{ skipped: true }\`.
|
||||
3. \`read_config()\`.
|
||||
4. \`patch_config(...)\` adding the tool and omitting only the skipped
|
||||
credential slot. Do not abort the tool addition.
|
||||
5. Summarize it as a successful addition, not a failure: the tool is in
|
||||
place and starts working once a credential is connected. Never say you
|
||||
could not complete it — end with a one-line checklist item for
|
||||
connecting the credential later.
|
||||
|
||||
### Add MCP integration: "Connect Notion MCP"
|
||||
This flow is user-initiated on an existing agent, so the credential ask is
|
||||
immediate. During an initial build, pick the best candidate as a stated
|
||||
assumption, write the draft \`/mcpServers/-\` entry with \`credential\` omitted,
|
||||
skip verification, and include the credential in the trailing \`finish_setup\`
|
||||
call; verify with the returned credential id — on success the tool writes the
|
||||
credential into the matching entry itself; no \`read_config\`/\`patch_config\`
|
||||
follow-up for the credential.
|
||||
1. \`resolve_integration({ queries: ["notion"] })\`.
|
||||
2. When it returns \`kind: "mcp"\`, load \`agent-builder-mcp\`.
|
||||
2. When it returns \`kind: "mcp"\`, load \`agent-builder-external-services\`.
|
||||
3. For MCP candidates, select one entry from \`results[]\`. If
|
||||
multiple candidates remain, use \`ask_questions\` with their titles and
|
||||
descriptions; never choose by array order. If the user dismisses the
|
||||
@@ -182,17 +244,18 @@ export const FEW_SHOT_FLOWS_SECTION = `\
|
||||
|
||||
### Ambiguous request: "Make it post somewhere"
|
||||
1. \`ask_questions(...)\` with the known destination choices.
|
||||
2. Load \`agent-builder-integrations\` to decide whether the destination is the
|
||||
agent's chat/trigger surface.
|
||||
2. Load \`agent-builder-external-services\` to decide whether the destination is
|
||||
the agent's chat/trigger surface.
|
||||
3. If it is a chat integration, call \`configure_channel\` with the returned
|
||||
\`integrationType\`. After \`configure_channel\` returns, stop this flow; the
|
||||
setup UI already persisted or skipped the channel, so do not read or mutate
|
||||
the config.
|
||||
4. Otherwise call \`resolve_integration({ queries: ["<selected service>"] })\`
|
||||
and follow the returned kind:
|
||||
- \`kind: "mcp"\`: load \`agent-builder-mcp\`, verify, and wire the MCP server.
|
||||
- \`kind: "node"\`: load \`agent-builder-node-tools\`, use the returned node
|
||||
results with \`get_node_types\`, and ask for every required credential.
|
||||
- \`kind: "mcp"\`: follow the skill's MCP Servers section — verify and wire
|
||||
the MCP server.
|
||||
- \`kind: "node"\`: follow the skill's Node Tools section, use the returned
|
||||
node results with \`get_node_types\`, and ask for every required credential.
|
||||
5. In this non-chat branch only, \`read_config()\`, then \`patch_config(...)\` or
|
||||
\`write_config(...)\` with the resolved capability.
|
||||
|
||||
@@ -218,6 +281,7 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
|
||||
MEMORY_PROMPT,
|
||||
TOOLS_PROMPT,
|
||||
INTERACTIVE_TOOLS_SECTION,
|
||||
INITIAL_BUILD_SECTION,
|
||||
READ_CONFIG_FRESHNESS_SECTION,
|
||||
WORKFLOW_SECTION,
|
||||
FEW_SHOT_FLOWS_SECTION,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
formatZodErrors,
|
||||
PROVIDER_CAPABILITIES,
|
||||
resolvePromptCaching,
|
||||
AgentJsonConfigSchema,
|
||||
RunnableAgentJsonConfigSchema,
|
||||
sanitizeAgentJsonConfig,
|
||||
tryParseConfigJson,
|
||||
@@ -36,6 +37,8 @@ import { OauthService } from '@/oauth/oauth.service';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
import { AiService } from '@/services/ai.service';
|
||||
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
|
||||
import { FreeAiCreditsService } from '@/services/free-ai-credits.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { createAiMcpFetch } from '@/utils/ai-proxy-fetch';
|
||||
|
||||
import { AgentConfigService } from '../agent-config.service';
|
||||
@@ -44,6 +47,7 @@ import { AgentIntegrationPersistenceService } from '../agent-integration-persist
|
||||
import { AgentPublishService } from '../agent-publish.service';
|
||||
import { AgentSkillsService } from '../agent-skills.service';
|
||||
import { AgentTaskService } from '../agent-task.service';
|
||||
import { AgentValidationService } from '../agent-validation.service';
|
||||
import { AgentsToolsService } from '../agents-tools.service';
|
||||
import { AgentsService } from '../agents.service';
|
||||
import { AttachableWorkflowsService } from '../attachable-workflows.service';
|
||||
@@ -55,6 +59,7 @@ import {
|
||||
buildAskEmbeddingCredentialTool,
|
||||
buildAskQuestionsTool,
|
||||
buildConfigureChannelTool,
|
||||
buildFinishSetupTool,
|
||||
buildResolveLlmTool,
|
||||
} from './interactive';
|
||||
import type { ModelLookup } from './interactive/resolve-llm.tool';
|
||||
@@ -131,6 +136,17 @@ function snapshotFromConfig(config: AgentJsonConfig | null): AgentConfigSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft writes (empty `model`, no `credential`) are only for agents that
|
||||
* don't have a model yet. Once the stored config has a model, require the
|
||||
* runnable schema so a builder write can't wipe it back into an unrunnable
|
||||
* draft.
|
||||
*/
|
||||
function parseBuilderWriteConfig(incoming: unknown, currentConfig: AgentJsonConfig | null) {
|
||||
const schema = currentConfig?.model ? RunnableAgentJsonConfigSchema : AgentJsonConfigSchema;
|
||||
return schema.safeParse(sanitizeAgentJsonConfig(incoming));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt caching is mandatory for OpenAI/Anthropic: this write-path
|
||||
* normalizer guarantees `config.promptCaching` is force-enabled for those
|
||||
@@ -190,8 +206,37 @@ export class AgentsBuilderToolsService {
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
private readonly ssrfConfig: SsrfProtectionConfig,
|
||||
private readonly ssrfProtectionService: SsrfProtectionService,
|
||||
private readonly freeAiCreditsService: FreeAiCreditsService,
|
||||
private readonly telemetry: Telemetry,
|
||||
private readonly agentValidationService: AgentValidationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Stamps `configMutated: true` + the target agentId onto successful results of
|
||||
* config-mutating tools, so the FE can refresh the agent artifact panel from a
|
||||
* single semantic field instead of a per-tool allowlist.
|
||||
*/
|
||||
private withConfigMutationMarker(tool: BuiltTool, agentId: string): BuiltTool {
|
||||
const handler = tool.handler;
|
||||
if (!handler) return tool;
|
||||
return {
|
||||
...tool,
|
||||
handler: async (input, ctx) => {
|
||||
const result = await handler(input, ctx);
|
||||
if (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
(('ok' in result && result.ok === true) ||
|
||||
('connected' in result && result.connected === true) ||
|
||||
('completed' in result && result.completed === true))
|
||||
) {
|
||||
return { ...result, configMutated: true, agentId };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getTools(
|
||||
agentId: string,
|
||||
projectId: string,
|
||||
@@ -235,7 +280,7 @@ export class AgentsBuilderToolsService {
|
||||
'Create or replace the agent configuration by writing a complete JSON string. ' +
|
||||
'Requires baseConfigHash from the immediately preceding read_config result — never from a prior ' +
|
||||
'write_config/patch_config success or from a stale response. ' +
|
||||
'Returns { ok: true } on success — no config, hash, or timestamps are returned; call ' +
|
||||
'Returns { ok: true, configMutated: true, agentId } on success — no config, hash, or timestamps are returned; call ' +
|
||||
'read_config again before any later inspection or mutation — or ' +
|
||||
'{ ok: false, stage, errors } with path, message, expected, received fields on failure. ' +
|
||||
'On stage: "stale", call read_config and retry once using its fresh config and configHash.',
|
||||
@@ -270,9 +315,7 @@ export class AgentsBuilderToolsService {
|
||||
if (baseConfigHash !== snapshot.configHash) {
|
||||
return { ok: false, stage: 'stale', errors: [STALE_CONFIG_ERROR] };
|
||||
}
|
||||
const zodResult = RunnableAgentJsonConfigSchema.safeParse(
|
||||
sanitizeAgentJsonConfig(parsed.data),
|
||||
);
|
||||
const zodResult = parseBuilderWriteConfig(parsed.data, snapshot.config);
|
||||
if (!zodResult.success) {
|
||||
return { ok: false, errors: formatZodErrors(zodResult.error) };
|
||||
}
|
||||
@@ -323,7 +366,7 @@ export class AgentsBuilderToolsService {
|
||||
'Requires baseConfigHash from the immediately preceding read_config result — never from a prior ' +
|
||||
'write_config/patch_config success or from a stale response. ' +
|
||||
'Supported ops: add, remove, replace, move, copy, test. ' +
|
||||
'Returns { ok: true } on success — no config, hash, or timestamps are returned; call ' +
|
||||
'Returns { ok: true, configMutated: true, agentId } on success — no config, hash, or timestamps are returned; call ' +
|
||||
'read_config again before any later inspection or mutation — or ' +
|
||||
'{ ok: false, stage, errors } on failure. ' +
|
||||
'stage is "parse", "stale", "patch", or "schema". On stage: "stale", call read_config and retry ' +
|
||||
@@ -390,9 +433,7 @@ export class AgentsBuilderToolsService {
|
||||
const patched = jsonpatch.applyPatch(jsonpatch.deepClone(snapshot.config), ops)
|
||||
.newDocument as unknown as AgentJsonConfig;
|
||||
|
||||
const zodResult = RunnableAgentJsonConfigSchema.safeParse(
|
||||
sanitizeAgentJsonConfig(patched),
|
||||
);
|
||||
const zodResult = parseBuilderWriteConfig(patched, snapshot.config);
|
||||
if (!zodResult.success) {
|
||||
return { ok: false, stage: 'schema', errors: formatZodErrors(zodResult.error) };
|
||||
}
|
||||
@@ -475,7 +516,7 @@ export class AgentsBuilderToolsService {
|
||||
'Idempotent when the draft is already the active published version. Pass optional `versionId` to ' +
|
||||
'activate an existing history row instead of publishing the current draft. Call only when the user ' +
|
||||
'asks to publish, activate, or make the agent live/usable — never tell them to click Publish in the editor. ' +
|
||||
'Returns { ok: true, agentId, activeVersionId, versionId } or { ok: false, errors }.',
|
||||
'Returns { ok: true, configMutated: true, agentId, activeVersionId, versionId } or { ok: false, errors }.',
|
||||
)
|
||||
.input(
|
||||
z.object({
|
||||
@@ -521,7 +562,7 @@ export class AgentsBuilderToolsService {
|
||||
.description(
|
||||
'Unpublish this target agent: clears the live version while preserving the draft, disconnects chat ' +
|
||||
'integrations, and stops scheduled tasks. Call when the user asks to unpublish or take the agent offline. ' +
|
||||
'Returns { ok: true, agentId, activeVersionId: null } or { ok: false, errors }.',
|
||||
'Returns { ok: true, configMutated: true, agentId, activeVersionId: null } or { ok: false, errors }.',
|
||||
)
|
||||
.input(z.object({}))
|
||||
.handler(async () => {
|
||||
@@ -558,13 +599,27 @@ export class AgentsBuilderToolsService {
|
||||
|
||||
const tools: BuiltTool[] = [
|
||||
readConfigTool,
|
||||
writeConfigTool,
|
||||
patchConfigTool,
|
||||
this.withConfigMutationMarker(writeConfigTool, agentId),
|
||||
this.withConfigMutationMarker(patchConfigTool, agentId),
|
||||
listIntegrationTypesTool,
|
||||
listSubAgentsTool,
|
||||
publishAgentTool,
|
||||
unpublishAgentTool,
|
||||
buildResolveLlmTool({ credentialProvider, modelLookup }),
|
||||
this.withConfigMutationMarker(publishAgentTool, agentId),
|
||||
this.withConfigMutationMarker(unpublishAgentTool, agentId),
|
||||
buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: {
|
||||
isEligible: () => this.freeAiCreditsService.isEligible(user),
|
||||
claim: async () => {
|
||||
const credential = await this.freeAiCreditsService.claim(user, projectId);
|
||||
this.telemetry.track('User claimed OpenAI credits', {
|
||||
user_id: user.id,
|
||||
source: 'agentBuilderResolveLlm',
|
||||
});
|
||||
return { credentialId: credential.id, credentialName: credential.name };
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildAskCredentialTool({
|
||||
credentialProvider,
|
||||
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
|
||||
@@ -581,15 +636,55 @@ export class AgentsBuilderToolsService {
|
||||
isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(),
|
||||
}),
|
||||
buildAskQuestionsTool(),
|
||||
buildConfigureChannelTool({
|
||||
this.withConfigMutationMarker(
|
||||
buildConfigureChannelTool({
|
||||
agentId,
|
||||
projectId,
|
||||
listChatIntegrationTypes: () =>
|
||||
this.agentIntegrationPersistenceService
|
||||
.listChatIntegrations()
|
||||
.map((integration) => integration.type),
|
||||
}),
|
||||
agentId,
|
||||
projectId,
|
||||
listChatIntegrationTypes: () =>
|
||||
this.agentIntegrationPersistenceService
|
||||
.listChatIntegrations()
|
||||
.map((integration) => integration.type),
|
||||
}),
|
||||
),
|
||||
this.withConfigMutationMarker(
|
||||
buildFinishSetupTool({
|
||||
credentialProvider,
|
||||
agentId,
|
||||
projectId,
|
||||
isCredentialTypeKnown: (credentialType) =>
|
||||
this.credentialTypes.recognizes(credentialType),
|
||||
listIntegrationCredentialIds: async () => {
|
||||
const agent = await this.agentsService.findById(agentId, projectId);
|
||||
return (agent?.integrations ?? [])
|
||||
.map((integration) => integration.credentialId)
|
||||
.filter((credentialId) => credentialId.length > 0);
|
||||
},
|
||||
listChatIntegrationTypes: () =>
|
||||
this.agentIntegrationPersistenceService
|
||||
.listChatIntegrations()
|
||||
.map((integration) => integration.type),
|
||||
getPublishBlockers: async () => {
|
||||
// Connecting a channel auto-publishes the agent, so gate it on the
|
||||
// same publish validation. Integration issues are excluded: the
|
||||
// draft channel entry itself (`credentialId: ""`) always reports
|
||||
// missing_credential, and that's exactly what this channel phase
|
||||
// is about to resolve.
|
||||
const { issues } = await this.agentValidationService.validateAgentConfiguration(
|
||||
agentId,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
'publish',
|
||||
);
|
||||
return issues
|
||||
.filter((issue) => !issue.path.startsWith('integrations.'))
|
||||
.map((issue) => ({ path: issue.path, code: issue.code }));
|
||||
},
|
||||
}),
|
||||
agentId,
|
||||
),
|
||||
buildVerifyMcpServerTool({
|
||||
agentId,
|
||||
credentialProvider,
|
||||
oauthService: this.oauthService,
|
||||
projectId,
|
||||
@@ -598,6 +693,8 @@ export class AgentsBuilderToolsService {
|
||||
this.ssrfConfig,
|
||||
this.ssrfProtectionService,
|
||||
),
|
||||
applyCredentialToMcpServer: async (serverName, credentialId) =>
|
||||
await this.applyCredentialToMcpServer(agentId, projectId, serverName, credentialId),
|
||||
}),
|
||||
buildSearchMcpServersTool({ mcpRegistryService: this.mcpRegistryService }),
|
||||
buildResolveIntegrationTool({
|
||||
@@ -668,8 +765,9 @@ export class AgentsBuilderToolsService {
|
||||
'runtime uses to decide when to load the skill; the instructions must follow the required ' +
|
||||
'structured Markdown template (Overview, Inputs, Steps, Rules, Example, Gotchas) with each ' +
|
||||
'applicable section filled in with concrete, specific content. If you do not have enough domain ' +
|
||||
'detail to write a genuinely useful skill, ask the user clarifying questions until you do before ' +
|
||||
'calling create_skills. Use allowedTools only with exact target-agent tool names. Use references ' +
|
||||
"detail to write a genuinely useful skill, derive it from the user's goal as stated assumptions " +
|
||||
'listed in your summary; ask the user clarifying questions only when even a reasonable ' +
|
||||
'assumption is impossible. Use allowedTools only with exact target-agent tool names. Use references ' +
|
||||
'only for markdown supporting files under the references/ directory — references are not ' +
|
||||
'automatically loaded, so instructions must say exactly when to load each one by path; scripts and ' +
|
||||
'non-markdown linked files are not supported. Do not invent tool names or reference paths. Batch ' +
|
||||
@@ -710,7 +808,7 @@ export class AgentsBuilderToolsService {
|
||||
'objective field carries its own structured template. The whole batch is all-or-nothing: an ' +
|
||||
'invalid cron or objective rejects every task in the call. This adds a `{ type: "task", id, ' +
|
||||
'enabled }` ref per task to the agent config (config.tasks) and each task starts running once ' +
|
||||
'the agent is (re)published via `publish_agent`. Returns { ok: true, tasks: [{ id, name, enabled }, ...] } (same ' +
|
||||
'the agent is (re)published via `publish_agent`. Returns { ok: true, configMutated: true, agentId, tasks: [{ id, name, enabled }, ...] } (same ' +
|
||||
'order as input, objectives and crons are not echoed back) or { ok: false, errors }.',
|
||||
)
|
||||
.systemInstruction(
|
||||
@@ -718,8 +816,9 @@ export class AgentsBuilderToolsService {
|
||||
'required section, or an unclear schedule. Each objective must follow the required structured ' +
|
||||
'Markdown template (Objective, Context, Steps, Output, Constraints, Success criteria) with every ' +
|
||||
'section filled in with concrete content — it is the exact, self-contained message the agent ' +
|
||||
'receives on each unattended run. If anything is ambiguous, ask the user clarifying questions ' +
|
||||
'(ask_questions with discrete options for choices, or type: "text" for open-ended) before calling ' +
|
||||
"receives on each unattended run. If anything is ambiguous, derive it from the user's goal as " +
|
||||
'stated assumptions listed in your summary; ask the user clarifying questions with ask_questions ' +
|
||||
'only when even a reasonable assumption is impossible, before calling ' +
|
||||
'create_tasks. A task can only use tools the agent already has: if any step in an objective ' +
|
||||
'requires a tool, integration, or web search the agent is missing, you MUST add it to the agent ' +
|
||||
'config (patch_config/write_config) BEFORE calling create_tasks — otherwise the task will fail at ' +
|
||||
@@ -797,7 +896,7 @@ export class AgentsBuilderToolsService {
|
||||
return [
|
||||
buildCustomToolTool,
|
||||
createSkillsTool,
|
||||
createTasksTool,
|
||||
this.withConfigMutationMarker(createTasksTool, agentId),
|
||||
listWorkflowsTool,
|
||||
buildGetResourceLocatorOptionsTool({
|
||||
dynamicNodeParametersService: this.dynamicNodeParametersService,
|
||||
@@ -824,4 +923,47 @@ export class AgentsBuilderToolsService {
|
||||
const config = composeJsonConfig(agent);
|
||||
return snapshotFromConfig(config);
|
||||
}
|
||||
|
||||
private async applyCredentialToMcpServer(
|
||||
agentId: string,
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
credentialId: string,
|
||||
): Promise<{ applied: boolean }> {
|
||||
const snapshot = await this.getConfigSnapshot(agentId, projectId);
|
||||
const config = snapshot.config;
|
||||
const servers = config?.mcpServers;
|
||||
if (!config || !servers) {
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
const serverIndex = servers.findIndex((server) => server.name === serverName);
|
||||
if (serverIndex === -1) {
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
if (servers[serverIndex]?.credential === credentialId) {
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
// Only one field changes, so shallow copies are enough — no deep clone.
|
||||
const patched: AgentJsonConfig = {
|
||||
...config,
|
||||
mcpServers: servers.map((server, index) =>
|
||||
index === serverIndex ? { ...server, credential: credentialId } : server,
|
||||
),
|
||||
};
|
||||
|
||||
const zodResult = parseBuilderWriteConfig(patched, snapshot.config);
|
||||
if (!zodResult.success) {
|
||||
throw new Error(formatZodErrors(zodResult.error)[0]?.message ?? 'Invalid MCP server config');
|
||||
}
|
||||
|
||||
const configWithDefaults = applyPromptCachingBuilderDefaults(
|
||||
applyNativeWebSearchDefaultOn(zodResult.data),
|
||||
);
|
||||
|
||||
await this.agentConfigService.updateConfig(agentId, projectId, configWithDefaults);
|
||||
return { applied: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ import { getModelRecommendationsSection } from './agents-builder-model-recommend
|
||||
import { buildBuilderPrompt } from './agents-builder-prompts';
|
||||
import { AgentsBuilderToolsService } from './agents-builder-tools.service';
|
||||
import { BuilderCheckpointUnavailableError } from './errors';
|
||||
import {
|
||||
BUILDER_PLANNER_TODOS_DESCRIPTION,
|
||||
BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
|
||||
} from './prompts/planner-todos.prompt';
|
||||
import { getBuilderRuntimeSkills } from './skills';
|
||||
import { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
|
||||
import { N8nMemory } from '../integrations/n8n-memory';
|
||||
@@ -229,7 +233,7 @@ export class AgentsBuilderService {
|
||||
user,
|
||||
);
|
||||
|
||||
const { Agent, Memory } = await import('@n8n/agents');
|
||||
const { Agent, Memory, createPlannerTodosTool } = await import('@n8n/agents');
|
||||
|
||||
const onMemoryUsage = async (report: MemoryTaskUsageReport) => {
|
||||
try {
|
||||
@@ -276,6 +280,13 @@ export class AgentsBuilderService {
|
||||
builder.tool(tool);
|
||||
}
|
||||
|
||||
builder.tool(
|
||||
createPlannerTodosTool({
|
||||
description: BUILDER_PLANNER_TODOS_DESCRIPTION,
|
||||
systemInstruction: BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
|
||||
}),
|
||||
);
|
||||
|
||||
applyAgentThinking(builder, modelConfig);
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const BUILDER_TOOLS = {
|
||||
BUILD_CUSTOM_TOOL: 'build_custom_tool',
|
||||
CREATE_SKILLS: 'create_skills',
|
||||
CREATE_TASKS: 'create_tasks',
|
||||
FINISH_SETUP: 'finish_setup',
|
||||
GET_RESOURCE_LOCATOR_OPTIONS: 'get_resource_locator_options',
|
||||
LIST_INTEGRATION_TYPES: 'list_integration_types',
|
||||
LIST_SUB_AGENTS: 'list_sub_agents',
|
||||
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
import type { CredentialListItem, CredentialProvider } from '@n8n/agents';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { buildFinishSetupTool } from '../finish-setup.tool';
|
||||
|
||||
interface TestCtx {
|
||||
resumeData?: unknown;
|
||||
suspendPayload?: unknown;
|
||||
suspend: Mock;
|
||||
}
|
||||
|
||||
function makeCtx(overrides?: { resumeData?: unknown; suspendPayload?: unknown }): TestCtx {
|
||||
return {
|
||||
resumeData: overrides?.resumeData,
|
||||
suspendPayload: overrides?.suspendPayload,
|
||||
suspend: vi.fn(async (payload: unknown) => payload),
|
||||
};
|
||||
}
|
||||
|
||||
function makeProvider(creds: CredentialListItem[]): CredentialProvider {
|
||||
return {
|
||||
list: vi.fn(async () => creds),
|
||||
resolve: vi.fn(async () => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_DEPS = {
|
||||
agentId: 'agent-1',
|
||||
projectId: 'project-1',
|
||||
listChatIntegrationTypes: () => ['slack', 'telegram'],
|
||||
getPublishBlockers: async () => [],
|
||||
};
|
||||
|
||||
describe('finish_setup tool', () => {
|
||||
it('auto-resolves single-credential and channel-matching slots, excluding them from the credential phase', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'My Airtable', type: 'airtableApi' },
|
||||
{ id: 'c2', name: 'Personal Slack', type: 'slackApi' },
|
||||
{ id: 'c3', name: 'Notion A', type: 'notionApi' },
|
||||
{ id: 'c4', name: 'Notion B', type: 'notionApi' },
|
||||
]);
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider,
|
||||
listIntegrationCredentialIds: async () => ['c2'],
|
||||
});
|
||||
const ctx = makeCtx();
|
||||
|
||||
const payload = (await tool.handler!(
|
||||
{
|
||||
credentialRequests: [
|
||||
{ credentialType: 'airtableApi', purpose: 'Airtable log' },
|
||||
{ credentialType: 'slackApi', purpose: 'Slack tool' },
|
||||
{ credentialType: 'notionApi', purpose: 'Notion search' },
|
||||
],
|
||||
},
|
||||
ctx as never,
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(payload.credentialRequests).toEqual([
|
||||
{
|
||||
credentialType: 'notionApi',
|
||||
reason: 'Notion search',
|
||||
existingCredentials: [
|
||||
{ id: 'c3', name: 'Notion A' },
|
||||
{ id: 'c4', name: 'Notion B' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
(payload.finishSetupChain as { collected: { credentials: unknown } }).collected.credentials,
|
||||
).toEqual({
|
||||
airtableApi: { id: 'c1', name: 'My Airtable' },
|
||||
slackApi: { id: 'c2', name: 'Personal Slack' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns completed without suspending when every credential slot auto-resolves and there is nothing else pending', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'My Airtable', type: 'airtableApi' },
|
||||
]);
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider,
|
||||
});
|
||||
const ctx = makeCtx();
|
||||
|
||||
const result = await tool.handler!(
|
||||
{ credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }] },
|
||||
ctx as never,
|
||||
);
|
||||
|
||||
expect(ctx.suspend).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
credentials: { airtableApi: { id: 'c1', name: 'My Airtable' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('chains through questions and credentials to a merged result', async () => {
|
||||
const credentialProvider = makeProvider([]);
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider,
|
||||
});
|
||||
const input = {
|
||||
questions: [
|
||||
{ id: 'model', question: 'Which model?', type: 'single' as const, options: ['gpt'] },
|
||||
],
|
||||
credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }],
|
||||
};
|
||||
|
||||
const questionsPayload = (await tool.handler!(input, makeCtx() as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(questionsPayload).toMatchObject({
|
||||
inputType: 'questions',
|
||||
message: 'Finish setup (1/2)',
|
||||
questions: [{ id: 'model', question: 'Which model?', type: 'single', options: ['gpt'] }],
|
||||
});
|
||||
expect(questionsPayload.finishSetupChain).toMatchObject({
|
||||
currentPhase: { kind: 'questions' },
|
||||
remainingPhases: [{ kind: 'credentials' }],
|
||||
totalPhases: 2,
|
||||
});
|
||||
|
||||
const credentialsPayload = (await tool.handler!(
|
||||
input,
|
||||
makeCtx({
|
||||
resumeData: {
|
||||
approved: true,
|
||||
answers: [{ questionId: 'model', selectedOptions: ['gpt'] }],
|
||||
},
|
||||
suspendPayload: questionsPayload,
|
||||
}) as never,
|
||||
)) as Record<string, unknown>;
|
||||
expect(credentialsPayload.credentialRequests).toEqual([
|
||||
{ credentialType: 'airtableApi', reason: 'Airtable log', existingCredentials: [] },
|
||||
]);
|
||||
expect(credentialsPayload.message).toBe('Finish setup (2/2)');
|
||||
|
||||
const result = await tool.handler!(
|
||||
input,
|
||||
makeCtx({
|
||||
resumeData: { credentials: { airtableApi: 'new-cred' } },
|
||||
suspendPayload: credentialsPayload,
|
||||
}) as never,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
answers: [{ questionId: 'model', selectedOptions: ['gpt'] }],
|
||||
credentials: { airtableApi: { id: 'new-cred', name: 'new-cred' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the credential slot skipped when the credential phase is skipped', async () => {
|
||||
const credentialProvider = makeProvider([]);
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider,
|
||||
});
|
||||
const input = {
|
||||
credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }],
|
||||
};
|
||||
|
||||
const credentialsPayload = (await tool.handler!(input, makeCtx() as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const result = await tool.handler!(
|
||||
input,
|
||||
makeCtx({ resumeData: { skipped: true }, suspendPayload: credentialsPayload }) as never,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
credentials: { airtableApi: 'skipped' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for an unknown credential type', async () => {
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider: makeProvider([]),
|
||||
isCredentialTypeKnown: (credentialType) => credentialType === 'airtableApi',
|
||||
});
|
||||
const ctx = makeCtx();
|
||||
|
||||
await expect(
|
||||
tool.handler!(
|
||||
{ credentialRequests: [{ credentialType: 'unknownApi', purpose: 'x' }] },
|
||||
ctx as never,
|
||||
),
|
||||
).rejects.toThrow('Unknown credential type "unknownApi"');
|
||||
expect(ctx.suspend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an input with no pending setup items', () => {
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider: makeProvider([]),
|
||||
});
|
||||
|
||||
expect((tool.inputSchema as unknown as z.ZodTypeAny).safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('chains through questions, credentials, and multiple channels to a merged result', async () => {
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider: makeProvider([]),
|
||||
});
|
||||
const input = {
|
||||
questions: [
|
||||
{ id: 'model', question: 'Which model?', type: 'single' as const, options: ['gpt'] },
|
||||
],
|
||||
credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }],
|
||||
channels: [{ integrationType: 'slack' }, { integrationType: 'telegram' }],
|
||||
};
|
||||
|
||||
const questionsPayload = (await tool.handler!(input, makeCtx() as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(questionsPayload.message).toBe('Finish setup (1/4)');
|
||||
|
||||
const credentialsPayload = (await tool.handler!(
|
||||
input,
|
||||
makeCtx({
|
||||
resumeData: { answers: [{ questionId: 'model', selectedOptions: ['gpt'] }] },
|
||||
suspendPayload: questionsPayload,
|
||||
}) as never,
|
||||
)) as Record<string, unknown>;
|
||||
expect(credentialsPayload.message).toBe('Finish setup (2/4)');
|
||||
|
||||
const slackPayload = (await tool.handler!(
|
||||
input,
|
||||
makeCtx({
|
||||
resumeData: { credentials: { airtableApi: 'new-cred' } },
|
||||
suspendPayload: credentialsPayload,
|
||||
}) as never,
|
||||
)) as Record<string, unknown>;
|
||||
expect(slackPayload).toMatchObject({
|
||||
message: 'Set up the slack channel',
|
||||
channelConfig: { integrationType: 'slack', agentId: 'agent-1' },
|
||||
projectId: 'project-1',
|
||||
});
|
||||
|
||||
const telegramPayload = (await tool.handler!(
|
||||
input,
|
||||
makeCtx({ resumeData: { approved: false }, suspendPayload: slackPayload }) as never,
|
||||
)) as Record<string, unknown>;
|
||||
expect(telegramPayload.message).toBe('Set up the telegram channel');
|
||||
|
||||
const result = await tool.handler!(
|
||||
input,
|
||||
makeCtx({ resumeData: { approved: true }, suspendPayload: telegramPayload }) as never,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
answers: [{ questionId: 'model', selectedOptions: ['gpt'] }],
|
||||
credentials: { airtableApi: { id: 'new-cred', name: 'new-cred' } },
|
||||
channels: { slack: 'skipped', telegram: 'connected' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for an unsupported channel type', async () => {
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider: makeProvider([]),
|
||||
});
|
||||
const ctx = makeCtx();
|
||||
|
||||
await expect(
|
||||
tool.handler!({ channels: [{ integrationType: 'discord' }] }, ctx as never),
|
||||
).rejects.toThrow('Unsupported chat channel "discord"');
|
||||
expect(ctx.suspend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suspends normally for a channel phase when there are no publish blockers', async () => {
|
||||
const getPublishBlockers = vi.fn(async () => []);
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider: makeProvider([]),
|
||||
getPublishBlockers,
|
||||
});
|
||||
const ctx = makeCtx();
|
||||
|
||||
const payload = (await tool.handler!(
|
||||
{ channels: [{ integrationType: 'slack' }] },
|
||||
ctx as never,
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(getPublishBlockers).toHaveBeenCalled();
|
||||
expect(payload).toMatchObject({
|
||||
message: 'Set up the slack channel',
|
||||
channelConfig: { integrationType: 'slack', agentId: 'agent-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the channel blocked without suspending when the agent cannot be published (channel-first)', async () => {
|
||||
const getPublishBlockers = vi.fn(async () => [{ path: 'model', code: 'missing_required' }]);
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider: makeProvider([]),
|
||||
getPublishBlockers,
|
||||
});
|
||||
const ctx = makeCtx();
|
||||
|
||||
const result = await tool.handler!({ channels: [{ integrationType: 'slack' }] }, ctx as never);
|
||||
|
||||
expect(getPublishBlockers).toHaveBeenCalled();
|
||||
expect(ctx.suspend).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
channels: { slack: 'blocked' },
|
||||
publishBlockedIssues: [{ path: 'model', code: 'missing_required' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks every remaining channel phase blocked when publish blockers appear at the first channel entry', async () => {
|
||||
const getPublishBlockers = vi.fn(async () => [{ path: 'model', code: 'missing_required' }]);
|
||||
const tool = buildFinishSetupTool({
|
||||
...BASE_DEPS,
|
||||
credentialProvider: makeProvider([]),
|
||||
getPublishBlockers,
|
||||
});
|
||||
const input = {
|
||||
credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }],
|
||||
channels: [{ integrationType: 'slack' }, { integrationType: 'telegram' }],
|
||||
};
|
||||
|
||||
const credentialsPayload = (await tool.handler!(input, makeCtx() as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const resumeCtx = makeCtx({
|
||||
resumeData: { credentials: { airtableApi: 'new-cred' } },
|
||||
suspendPayload: credentialsPayload,
|
||||
});
|
||||
const result = await tool.handler!(input, resumeCtx as never);
|
||||
|
||||
expect(resumeCtx.suspend).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
completed: true,
|
||||
credentials: { airtableApi: { id: 'new-cred', name: 'new-cred' } },
|
||||
channels: { slack: 'blocked', telegram: 'blocked' },
|
||||
publishBlockedIssues: [{ path: 'model', code: 'missing_required' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
+287
-17
@@ -1,7 +1,8 @@
|
||||
import type { CredentialListItem, CredentialProvider } from '@n8n/agents';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import type { ModelLookup } from '../resolve-llm.tool';
|
||||
import { LLM_PROVIDER_DEFAULTS, LLM_PROVIDER_PRIORITY } from '../llm-provider-defaults';
|
||||
import type { FreeCreditsProvisioner, ModelLookup } from '../resolve-llm.tool';
|
||||
import { buildResolveLlmTool } from '../resolve-llm.tool';
|
||||
|
||||
function makeProvider(creds: CredentialListItem[]): CredentialProvider {
|
||||
@@ -17,6 +18,21 @@ function makeModelLookup(impl?: ModelLookup['list']): ModelLookup & { list: Mock
|
||||
};
|
||||
}
|
||||
|
||||
function makeFreeCredits(
|
||||
isEligibleImpl?: FreeCreditsProvisioner['isEligible'],
|
||||
claimImpl?: FreeCreditsProvisioner['claim'],
|
||||
): FreeCreditsProvisioner & { isEligible: Mock; claim: Mock } {
|
||||
return {
|
||||
isEligible: vi.fn(isEligibleImpl ?? (() => false)),
|
||||
claim: vi.fn(
|
||||
claimImpl ??
|
||||
(async () => {
|
||||
throw new Error('makeFreeCredits: claim() called without an implementation');
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolve_llm tool', () => {
|
||||
it('auto-resolves when exactly one LLM-provider credential exists', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
@@ -24,7 +40,11 @@ describe('resolve_llm tool', () => {
|
||||
{ id: 'c2', name: 'My Slack', type: 'slackApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({}, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -43,7 +63,11 @@ describe('resolve_llm tool', () => {
|
||||
{ id: 'c2', name: 'My OpenRouter', type: 'openRouterApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'openrouter' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -61,7 +85,11 @@ describe('resolve_llm tool', () => {
|
||||
{ name: 'Grok 4 Fast', value: 'grok-4-fast' },
|
||||
{ name: 'Grok 4', value: 'grok-4' },
|
||||
]);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'xai', model: 'grok-4-fast' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -78,7 +106,11 @@ describe('resolve_llm tool', () => {
|
||||
{ id: 'c1', name: 'My Anthropic', type: 'anthropicApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'openrouter' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -96,7 +128,11 @@ describe('resolve_llm tool', () => {
|
||||
{ id: 'c2', name: 'Work OpenRouter', type: 'openRouterApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'openrouter' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -111,14 +147,43 @@ describe('resolve_llm tool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns ambiguous_provider_or_credential when no provider is requested and multiple credentials exist', async () => {
|
||||
it('returns ambiguous_provider_or_credential when the top-priority provider has multiple credentials', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'Personal Anthropic', type: 'anthropicApi' },
|
||||
{ id: 'c2', name: 'Work Anthropic', type: 'anthropicApi' },
|
||||
{ id: 'c3', name: 'My OpenAI', type: 'openAiApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({}, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'ambiguous_provider_or_credential',
|
||||
credentials: [
|
||||
{ id: 'c1', name: 'Personal Anthropic', type: 'anthropicApi', provider: 'anthropic' },
|
||||
{ id: 'c2', name: 'Work Anthropic', type: 'anthropicApi', provider: 'anthropic' },
|
||||
{ id: 'c3', name: 'My OpenAI', type: 'openAiApi', provider: 'openai' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-pick when a model is requested without a provider', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'My Anthropic', type: 'anthropicApi' },
|
||||
{ id: 'c2', name: 'My OpenAI', type: 'openAiApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const result = await tool.handler!({}, {});
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ model: 'gpt-5-mini' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
@@ -130,13 +195,190 @@ describe('resolve_llm tool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('free OpenAI credits', () => {
|
||||
it('claims free OpenAI credits when no LLM credentials exist and the user is eligible', async () => {
|
||||
const credentialProvider = makeProvider([]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const freeCredits = makeFreeCredits(
|
||||
() => true,
|
||||
async () => ({ credentialId: 'free-1', credentialName: 'n8n free OpenAI API credits' }),
|
||||
);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup, freeCredits });
|
||||
const result = await tool.handler!({}, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt-5-mini',
|
||||
credentialId: 'free-1',
|
||||
credentialName: 'n8n free OpenAI API credits',
|
||||
claimedFreeOpenAiCredits: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns missing_credential when no LLM credentials exist and free credits are not eligible', async () => {
|
||||
const credentialProvider = makeProvider([]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const freeCredits = makeFreeCredits();
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup, freeCredits });
|
||||
const result = await tool.handler!({}, {});
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missing_credential', credentials: [] });
|
||||
expect(freeCredits.claim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to missing_credential when the free-credits claim fails', async () => {
|
||||
const credentialProvider = makeProvider([]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const freeCredits = makeFreeCredits(
|
||||
() => true,
|
||||
async () => {
|
||||
throw new Error('Already claimed');
|
||||
},
|
||||
);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup, freeCredits });
|
||||
const result = await tool.handler!({}, {});
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missing_credential', credentials: [] });
|
||||
});
|
||||
|
||||
it('claims free credits when openai is requested without a model and no openai credential exists', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'My Anthropic', type: 'anthropicApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const freeCredits = makeFreeCredits(
|
||||
() => true,
|
||||
async () => ({ credentialId: 'free-1', credentialName: 'n8n free OpenAI API credits' }),
|
||||
);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup, freeCredits });
|
||||
const result = await tool.handler!({ provider: 'openai' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt-5-mini',
|
||||
credentialId: 'free-1',
|
||||
credentialName: 'n8n free OpenAI API credits',
|
||||
claimedFreeOpenAiCredits: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not claim free credits when openai is requested with a specific model', async () => {
|
||||
const credentialProvider = makeProvider([]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const freeCredits = makeFreeCredits(() => true);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup, freeCredits });
|
||||
const result = await tool.handler!({ provider: 'openai', model: 'gpt-4.1' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing_credential',
|
||||
provider: 'openai',
|
||||
credentialType: 'openAiApi',
|
||||
credentials: [],
|
||||
});
|
||||
expect(freeCredits.claim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not claim free credits when a model is requested without a provider', async () => {
|
||||
const credentialProvider = makeProvider([]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const freeCredits = makeFreeCredits(() => true);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup, freeCredits });
|
||||
const result = await tool.handler!({ model: 'claude-sonnet-4-6' }, {});
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missing_credential', credentials: [] });
|
||||
expect(freeCredits.claim).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentialId', () => {
|
||||
it('resolves a specific credential when credentialId is passed', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'Personal OpenRouter', type: 'openRouterApi' },
|
||||
{ id: 'c2', name: 'Work OpenRouter', type: 'openRouterApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ credentialId: 'c2' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
provider: 'openrouter',
|
||||
model: 'anthropic/claude-sonnet-4.6',
|
||||
credentialId: 'c2',
|
||||
credentialName: 'Work OpenRouter',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unknown_credential for a credentialId that is not an LLM credential', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'My Anthropic', type: 'anthropicApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ credentialId: 'nope' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'unknown_credential',
|
||||
credentialId: 'nope',
|
||||
credentials: [{ id: 'c1', name: 'My Anthropic', type: 'anthropicApi' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-provider auto-pick', () => {
|
||||
it('auto-picks the highest-priority provider when multiple providers each have one credential', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'My OpenAI', type: 'openAiApi' },
|
||||
{ id: 'c2', name: 'My Anthropic', type: 'anthropicApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({}, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
credentialId: 'c2',
|
||||
credentialName: 'My Anthropic',
|
||||
autoPicked: true,
|
||||
otherProviders: ['openai'],
|
||||
});
|
||||
});
|
||||
|
||||
it('LLM_PROVIDER_PRIORITY covers every provider in LLM_PROVIDER_DEFAULTS', () => {
|
||||
const definedProviders = new Set(Object.values(LLM_PROVIDER_DEFAULTS).map((d) => d.provider));
|
||||
expect(new Set(LLM_PROVIDER_PRIORITY)).toEqual(definedProviders);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model validation against modelLookup', () => {
|
||||
it('skips lookup when no model is requested', async () => {
|
||||
const credentialProvider = makeProvider([
|
||||
{ id: 'c1', name: 'My Anthropic', type: 'anthropicApi' },
|
||||
]);
|
||||
const modelLookup = makeModelLookup();
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'anthropic' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -155,7 +397,11 @@ describe('resolve_llm tool', () => {
|
||||
{ name: 'Command R+', value: 'command-r-plus' },
|
||||
{ name: 'Command R', value: 'command-r' },
|
||||
]);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'cohere', model: 'command-r-plus' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -176,7 +422,11 @@ describe('resolve_llm tool', () => {
|
||||
{ name: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20250101' },
|
||||
{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
|
||||
]);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!(
|
||||
{ provider: 'anthropic', model: 'CLAUDE-HAIKU-4-5-20250101' },
|
||||
{},
|
||||
@@ -200,7 +450,11 @@ describe('resolve_llm tool', () => {
|
||||
{ name: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20250101' },
|
||||
{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
|
||||
]);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'anthropic', model: 'claude-haiku-4-5' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -220,7 +474,11 @@ describe('resolve_llm tool', () => {
|
||||
{ name: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20250101' },
|
||||
{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6-20251001' },
|
||||
]);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'anthropic', model: 'haiku 4.5' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -238,7 +496,11 @@ describe('resolve_llm tool', () => {
|
||||
]);
|
||||
const available = [{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }];
|
||||
const modelLookup = makeModelLookup(async () => available);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'anthropic', model: 'gpt-9000' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -259,7 +521,11 @@ describe('resolve_llm tool', () => {
|
||||
{ name: 'Claude Haiku 4.0', value: 'claude-haiku-4-0-20240101' },
|
||||
{ name: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
|
||||
]);
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'anthropic', model: 'haiku' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -281,7 +547,11 @@ describe('resolve_llm tool', () => {
|
||||
const modelLookup = makeModelLookup(async () => {
|
||||
throw new Error('credentials invalid');
|
||||
});
|
||||
const tool = buildResolveLlmTool({ credentialProvider, modelLookup });
|
||||
const tool = buildResolveLlmTool({
|
||||
credentialProvider,
|
||||
modelLookup,
|
||||
freeCredits: makeFreeCredits(),
|
||||
});
|
||||
const result = await tool.handler!({ provider: 'anthropic', model: 'claude-haiku-4-5' }, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -136,8 +136,11 @@ export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool {
|
||||
return new Tool(ASK_CREDENTIAL_TOOL_NAME)
|
||||
.description(
|
||||
'Show a credential picker card in the chat UI and suspend until the user selects ' +
|
||||
'a credential. Call ONCE per credential slot, BEFORE the write_config / patch_config ' +
|
||||
'that introduces the node tool. Returns { credentialId, credentialName, credentials } on success ' +
|
||||
'a credential. Call ONCE per credential slot. For an addition to an existing agent, ' +
|
||||
'call it before the write_config / patch_config that introduces the tool. Never call ' +
|
||||
'this during an initial build — follow the Initial Build rules in your system prompt; ' +
|
||||
'use it for additions to an existing agent and follow-up setup turns. ' +
|
||||
'Returns { credentialId, credentialName, credentials } on success ' +
|
||||
'or { skipped: true } if the user skips credential setup so the tool can be added ' +
|
||||
'without credentials. For node tools, copy the returned `credentials` object into `node.credentials`. Auto-resolves without ' +
|
||||
'rendering a card when the agent has a chat channel configured whose credential matches the ' +
|
||||
|
||||
@@ -30,7 +30,7 @@ export function buildConfigureChannelTool(deps: ConfigureChannelToolDeps): Built
|
||||
'list_integration_types and pass a returned `type` as `integrationType`; do not infer ' +
|
||||
'channel names. Shows setup UI in chat where the user creates a new channel credential ' +
|
||||
'or skips. The setup UI persists the connection, so use this for channel credentials ' +
|
||||
'instead of the credentials tool or config writes. Returns { connected: boolean }; if ' +
|
||||
'instead of the credentials tool or config writes. Returns { connected: boolean } (plus configMutated/agentId refresh metadata when connected); if ' +
|
||||
'false, continue without the channel and do not re-prompt.',
|
||||
)
|
||||
.input(configureChannelInputSchema)
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import type {
|
||||
BuiltTool,
|
||||
CredentialListItem,
|
||||
CredentialProvider,
|
||||
InterruptibleToolContext,
|
||||
} from '@n8n/agents';
|
||||
import { Tool } from '@n8n/agents/tool';
|
||||
import {
|
||||
channelSuspendPayloadSchema,
|
||||
credentialSuspendPayloadSchema,
|
||||
interactionQuestionSchema,
|
||||
questionAnswerSchema,
|
||||
questionsSuspendPayloadSchema,
|
||||
type InteractionQuestion,
|
||||
} from '@n8n/api-types';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BUILDER_TOOLS } from '../builder-tool-names';
|
||||
|
||||
/** Filters an already-fetched credential list down to one type, in the shape the setup cards need. */
|
||||
function credentialsOfType(
|
||||
all: CredentialListItem[],
|
||||
credentialType: string,
|
||||
): Array<{ id: string; name: string }> {
|
||||
return all.filter((c) => c.type === credentialType).map((c) => ({ id: c.id, name: c.name }));
|
||||
}
|
||||
|
||||
/** Compact publish-blocking validation issue, used to gate the channel phase. */
|
||||
export interface PublishBlockerIssue {
|
||||
path: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface FinishSetupToolDeps {
|
||||
credentialProvider: CredentialProvider;
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
isCredentialTypeKnown?: (credentialType: string) => boolean;
|
||||
/** Credential ids of the agent's configured chat channel integrations — reused for a matching credential slot. */
|
||||
listIntegrationCredentialIds?: () => Promise<string[]>;
|
||||
/** Wraps `AgentIntegrationPersistenceService.listChatIntegrations()`. */
|
||||
listChatIntegrationTypes: () => string[];
|
||||
/**
|
||||
* Publish-blocking validation issues on the agent's current (draft) config,
|
||||
* excluding `integrations.*` paths. Checked before entering a channel
|
||||
* phase — connecting a channel auto-publishes the agent, which would
|
||||
* otherwise fail with a raw publish error whenever another part of the
|
||||
* config (e.g. a skipped tool credential) is still invalid.
|
||||
*/
|
||||
getPublishBlockers: () => Promise<PublishBlockerIssue[]>;
|
||||
}
|
||||
|
||||
const finishSetupCredentialRequestInputSchema = z.object({
|
||||
credentialType: z.string().min(1),
|
||||
purpose: z.string().min(1),
|
||||
credentialSlot: z.string().optional(),
|
||||
});
|
||||
type CredentialSlotInput = z.infer<typeof finishSetupCredentialRequestInputSchema>;
|
||||
|
||||
const finishSetupChannelInputSchema = z.object({
|
||||
integrationType: z.string().min(1),
|
||||
});
|
||||
|
||||
const finishSetupInputSchema = z
|
||||
.object({
|
||||
questions: z.array(interactionQuestionSchema).optional(),
|
||||
credentialRequests: z.array(finishSetupCredentialRequestInputSchema).optional(),
|
||||
channels: z.array(finishSetupChannelInputSchema).optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
(v.questions?.length ?? 0) + (v.credentialRequests?.length ?? 0) + (v.channels?.length ?? 0) >
|
||||
0,
|
||||
{ message: 'Pass at least one pending setup item.' },
|
||||
);
|
||||
type FinishSetupInput = z.infer<typeof finishSetupInputSchema>;
|
||||
|
||||
/** One resolved credential outcome per slot key — either a resolved credential or an explicit skip. */
|
||||
const credentialOutcomeSchema = z.union([
|
||||
z.object({ id: z.string(), name: z.string() }),
|
||||
z.literal('skipped'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* A channel is connected (the setup card persisted it), skipped (the user
|
||||
* dismissed the card), or blocked (its card was never shown because the
|
||||
* agent could not be published yet).
|
||||
*/
|
||||
const channelOutcomeSchema = z.union([
|
||||
z.literal('connected'),
|
||||
z.literal('skipped'),
|
||||
z.literal('blocked'),
|
||||
]);
|
||||
|
||||
const questionsPhaseSchema = z.object({ kind: z.literal('questions') });
|
||||
const credentialsPhaseSchema = z.object({
|
||||
kind: z.literal('credentials'),
|
||||
slots: z.array(finishSetupCredentialRequestInputSchema),
|
||||
});
|
||||
const channelPhaseSchema = z.object({
|
||||
kind: z.literal('channel'),
|
||||
integrationType: z.string(),
|
||||
});
|
||||
const phaseDescriptorSchema = z.union([
|
||||
questionsPhaseSchema,
|
||||
credentialsPhaseSchema,
|
||||
channelPhaseSchema,
|
||||
]);
|
||||
type PhaseDescriptor = z.infer<typeof phaseDescriptorSchema>;
|
||||
|
||||
const collectedSchema = z.object({
|
||||
answers: z.array(questionAnswerSchema).optional(),
|
||||
credentials: z.record(credentialOutcomeSchema).optional(),
|
||||
channels: z.record(channelOutcomeSchema).optional(),
|
||||
});
|
||||
type Collected = z.infer<typeof collectedSchema>;
|
||||
|
||||
/**
|
||||
* Chain state carried inside the suspend payload (a member of each phase's
|
||||
* suspend schema, so it round-trips through the builder checkpoint) and
|
||||
* stripped by instance AI's cascade before the FE ever sees it — the FE
|
||||
* routes purely on the presence of `inputType`/`credentialRequests`/
|
||||
* `channelConfig`, identical to the single-purpose interactive tools.
|
||||
*/
|
||||
const chainStateSchema = z.object({
|
||||
currentPhase: phaseDescriptorSchema,
|
||||
remainingPhases: z.array(phaseDescriptorSchema),
|
||||
collected: collectedSchema,
|
||||
totalPhases: z.number(),
|
||||
});
|
||||
type ChainState = z.infer<typeof chainStateSchema>;
|
||||
|
||||
const finishSetupSuspendSchema = z.union([
|
||||
questionsSuspendPayloadSchema.extend({ finishSetupChain: chainStateSchema }),
|
||||
credentialSuspendPayloadSchema.extend({ finishSetupChain: chainStateSchema }),
|
||||
channelSuspendPayloadSchema.extend({ finishSetupChain: chainStateSchema }),
|
||||
]);
|
||||
type FinishSetupSuspendPayload = z.infer<typeof finishSetupSuspendSchema>;
|
||||
|
||||
/**
|
||||
* Deliberately a single permissive object, not a union — the three phases'
|
||||
* resume shapes overlap enough (e.g. questions/credentials and channel both
|
||||
* carry an optional `approved`) that a union would ambiguously match the
|
||||
* wrong arm. The handler always knows which phase a resume belongs to from
|
||||
* `ctx.suspendPayload.finishSetupChain`, so shape ambiguity here is harmless.
|
||||
*/
|
||||
const finishSetupResumeSchema = z.object({
|
||||
approved: z.boolean().optional(),
|
||||
answers: z.array(questionAnswerSchema).optional(),
|
||||
credentials: z.record(z.string()).optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
});
|
||||
type FinishSetupResumeData = z.infer<typeof finishSetupResumeSchema>;
|
||||
|
||||
type FinishSetupCtx = InterruptibleToolContext<FinishSetupSuspendPayload, FinishSetupResumeData>;
|
||||
|
||||
interface FinishSetupToolResult extends Collected {
|
||||
completed: true;
|
||||
/** Present only when a channel phase was skipped because the agent could not be published yet. */
|
||||
publishBlockedIssues?: PublishBlockerIssue[];
|
||||
}
|
||||
|
||||
/** Throws for any credential request whose type isn't recognized. */
|
||||
function validateCredentialTypes(input: FinishSetupInput, deps: FinishSetupToolDeps): void {
|
||||
for (const request of input.credentialRequests ?? []) {
|
||||
if (deps.isCredentialTypeKnown && !deps.isCredentialTypeKnown(request.credentialType)) {
|
||||
throw new Error(
|
||||
`Unknown credential type "${request.credentialType}". Use an exact n8n credential type name.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws for any requested channel whose type isn't a known chat integration. */
|
||||
function validateChannelTypes(input: FinishSetupInput, deps: FinishSetupToolDeps): void {
|
||||
const availableChannelTypes = deps.listChatIntegrationTypes();
|
||||
for (const channel of input.channels ?? []) {
|
||||
if (!availableChannelTypes.includes(channel.integrationType)) {
|
||||
const availableMessage = availableChannelTypes.length
|
||||
? ` Available: ${availableChannelTypes.join(', ')}.`
|
||||
: ' No chat channels are currently available.';
|
||||
throw new Error(
|
||||
`Unsupported chat channel "${channel.integrationType}". Call list_integration_types ` +
|
||||
'and choose a returned type.' +
|
||||
availableMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input, then auto-resolve every credential slot using the same
|
||||
* rules as ask_credential (matching channel credential first, then a single
|
||||
* existing credential of the type). Slots that cannot be auto-resolved
|
||||
* become a phase. Phase order is fixed: questions, then credentials, then
|
||||
* one channel phase per requested channel — channels always run last since
|
||||
* their card persists the connection immediately via REST.
|
||||
*/
|
||||
async function computeInitialPlan(
|
||||
input: FinishSetupInput,
|
||||
deps: FinishSetupToolDeps,
|
||||
): Promise<{ phases: PhaseDescriptor[]; collected: Collected }> {
|
||||
validateCredentialTypes(input, deps);
|
||||
validateChannelTypes(input, deps);
|
||||
|
||||
const collected: Collected = {};
|
||||
const pendingSlots: CredentialSlotInput[] = [];
|
||||
|
||||
if (input.credentialRequests?.length) {
|
||||
const integrationCredentialIds = (await deps.listIntegrationCredentialIds?.()) ?? [];
|
||||
const all = await deps.credentialProvider.list();
|
||||
const credentials: Record<string, z.infer<typeof credentialOutcomeSchema>> = {};
|
||||
|
||||
for (const slot of input.credentialRequests) {
|
||||
const key = slot.credentialSlot ?? slot.credentialType;
|
||||
const existingCredentials = credentialsOfType(all, slot.credentialType);
|
||||
const channelMatch = existingCredentials.find((credential) =>
|
||||
integrationCredentialIds.includes(credential.id),
|
||||
);
|
||||
const autoResolved =
|
||||
channelMatch ?? (existingCredentials.length === 1 ? existingCredentials[0] : undefined);
|
||||
|
||||
if (autoResolved) {
|
||||
credentials[key] = autoResolved;
|
||||
} else {
|
||||
pendingSlots.push(slot);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(credentials).length > 0) collected.credentials = credentials;
|
||||
}
|
||||
|
||||
const phases: PhaseDescriptor[] = [];
|
||||
if (input.questions?.length) phases.push({ kind: 'questions' });
|
||||
if (pendingSlots.length > 0) phases.push({ kind: 'credentials', slots: pendingSlots });
|
||||
for (const channel of input.channels ?? []) {
|
||||
phases.push({ kind: 'channel', integrationType: channel.integrationType });
|
||||
}
|
||||
|
||||
return { phases, collected };
|
||||
}
|
||||
|
||||
/** Merge a phase's resume data into the running `collected` result. Any dismissal/denial marks that phase's items skipped rather than aborting the chain. */
|
||||
async function mergeResumeIntoCollected(
|
||||
phase: PhaseDescriptor,
|
||||
resumeData: FinishSetupResumeData | undefined,
|
||||
previous: Collected,
|
||||
deps: FinishSetupToolDeps,
|
||||
): Promise<Collected> {
|
||||
if (phase.kind === 'questions') {
|
||||
return { ...previous, answers: resumeData?.answers ?? [] };
|
||||
}
|
||||
|
||||
if (phase.kind === 'channel') {
|
||||
const channels = { ...(previous.channels ?? {}) };
|
||||
channels[phase.integrationType] = resumeData?.approved ? 'connected' : 'skipped';
|
||||
return { ...previous, channels };
|
||||
}
|
||||
|
||||
const all = await deps.credentialProvider.list();
|
||||
const credentials = { ...(previous.credentials ?? {}) };
|
||||
for (const slot of phase.slots) {
|
||||
const key = slot.credentialSlot ?? slot.credentialType;
|
||||
const credentialId = resumeData?.credentials?.[slot.credentialType];
|
||||
credentials[key] = credentialId
|
||||
? {
|
||||
id: credentialId,
|
||||
name:
|
||||
credentialsOfType(all, slot.credentialType).find((c) => c.id === credentialId)?.name ??
|
||||
credentialId,
|
||||
}
|
||||
: 'skipped';
|
||||
}
|
||||
return { ...previous, credentials };
|
||||
}
|
||||
|
||||
/**
|
||||
* Before entering a channel phase, verify the agent can currently be
|
||||
* published — connecting a channel auto-publishes it, so an already-invalid
|
||||
* config (e.g. a skipped tool credential) would otherwise surface as a raw
|
||||
* publish error from the channel card's REST call instead of a clear message
|
||||
* here. Channel phases are always the trailing, consecutive phases (see
|
||||
* computeInitialPlan), so once `phase` is a channel phase, every phase in
|
||||
* `remainingPhases` is one too.
|
||||
*/
|
||||
async function checkChannelPublishability(
|
||||
phase: PhaseDescriptor,
|
||||
remainingPhases: PhaseDescriptor[],
|
||||
collected: Collected,
|
||||
deps: FinishSetupToolDeps,
|
||||
): Promise<FinishSetupToolResult | undefined> {
|
||||
if (phase.kind !== 'channel') return undefined;
|
||||
|
||||
const publishBlockedIssues = await deps.getPublishBlockers();
|
||||
if (publishBlockedIssues.length === 0) return undefined;
|
||||
|
||||
const channels = { ...(collected.channels ?? {}) };
|
||||
for (const blockedPhase of [phase, ...remainingPhases]) {
|
||||
if (blockedPhase.kind === 'channel') channels[blockedPhase.integrationType] = 'blocked';
|
||||
}
|
||||
return { completed: true, ...collected, channels, publishBlockedIssues };
|
||||
}
|
||||
|
||||
/** Suspend for the given phase, carrying the remaining plan forward in the chain state. */
|
||||
async function suspendForPhase(params: {
|
||||
phase: PhaseDescriptor;
|
||||
remainingPhases: PhaseDescriptor[];
|
||||
collected: Collected;
|
||||
totalPhases: number;
|
||||
phaseNumber: number;
|
||||
questions: InteractionQuestion[] | undefined;
|
||||
deps: FinishSetupToolDeps;
|
||||
ctx: FinishSetupCtx;
|
||||
}): Promise<never> {
|
||||
const { phase, remainingPhases, collected, totalPhases, phaseNumber, questions, deps, ctx } =
|
||||
params;
|
||||
const finishSetupChain: ChainState = {
|
||||
currentPhase: phase,
|
||||
remainingPhases,
|
||||
collected,
|
||||
totalPhases,
|
||||
};
|
||||
const message = `Finish setup (${phaseNumber}/${totalPhases})`;
|
||||
|
||||
if (phase.kind === 'questions') {
|
||||
return await ctx.suspend({
|
||||
requestId: nanoid(),
|
||||
message,
|
||||
severity: 'info' as const,
|
||||
inputType: 'questions' as const,
|
||||
questions: questions ?? [],
|
||||
finishSetupChain,
|
||||
});
|
||||
}
|
||||
|
||||
if (phase.kind === 'channel') {
|
||||
return await ctx.suspend({
|
||||
requestId: nanoid(),
|
||||
message: `Set up the ${phase.integrationType} channel`,
|
||||
severity: 'info' as const,
|
||||
channelConfig: { integrationType: phase.integrationType, agentId: deps.agentId },
|
||||
projectId: deps.projectId,
|
||||
finishSetupChain,
|
||||
});
|
||||
}
|
||||
|
||||
const all = await deps.credentialProvider.list();
|
||||
const seenTypes = new Set<string>();
|
||||
const credentialRequests: Array<{
|
||||
credentialType: string;
|
||||
reason: string;
|
||||
existingCredentials: Array<{ id: string; name: string }>;
|
||||
}> = [];
|
||||
for (const slot of phase.slots) {
|
||||
if (seenTypes.has(slot.credentialType)) continue;
|
||||
seenTypes.add(slot.credentialType);
|
||||
credentialRequests.push({
|
||||
credentialType: slot.credentialType,
|
||||
reason: slot.purpose,
|
||||
existingCredentials: credentialsOfType(all, slot.credentialType),
|
||||
});
|
||||
}
|
||||
return await ctx.suspend({
|
||||
requestId: nanoid(),
|
||||
message,
|
||||
severity: 'info' as const,
|
||||
credentialRequests,
|
||||
credentialFlow: { stage: 'generic' as const },
|
||||
finishSetupChain,
|
||||
});
|
||||
}
|
||||
|
||||
async function startPlan(
|
||||
input: FinishSetupInput,
|
||||
ctx: FinishSetupCtx,
|
||||
deps: FinishSetupToolDeps,
|
||||
): Promise<FinishSetupToolResult> {
|
||||
const { phases, collected } = await computeInitialPlan(input, deps);
|
||||
if (phases.length === 0) {
|
||||
return { completed: true, ...collected };
|
||||
}
|
||||
|
||||
const [currentPhase, ...remainingPhases] = phases;
|
||||
const blocked = await checkChannelPublishability(currentPhase, remainingPhases, collected, deps);
|
||||
if (blocked) return blocked;
|
||||
|
||||
return await suspendForPhase({
|
||||
phase: currentPhase,
|
||||
remainingPhases,
|
||||
collected,
|
||||
totalPhases: phases.length,
|
||||
phaseNumber: 1,
|
||||
questions: input.questions,
|
||||
deps,
|
||||
ctx,
|
||||
});
|
||||
}
|
||||
|
||||
async function resumePlan(
|
||||
input: FinishSetupInput,
|
||||
ctx: FinishSetupCtx,
|
||||
deps: FinishSetupToolDeps,
|
||||
): Promise<FinishSetupToolResult> {
|
||||
// Guarded by the caller: ctx.suspendPayload is set whenever this branch runs.
|
||||
const chain = ctx.suspendPayload!.finishSetupChain;
|
||||
const collected = await mergeResumeIntoCollected(
|
||||
chain.currentPhase,
|
||||
ctx.resumeData,
|
||||
chain.collected,
|
||||
deps,
|
||||
);
|
||||
|
||||
if (chain.remainingPhases.length === 0) {
|
||||
return { completed: true, ...collected };
|
||||
}
|
||||
|
||||
const [nextPhase, ...restPhases] = chain.remainingPhases;
|
||||
const blocked = await checkChannelPublishability(nextPhase, restPhases, collected, deps);
|
||||
if (blocked) return blocked;
|
||||
|
||||
return await suspendForPhase({
|
||||
phase: nextPhase,
|
||||
remainingPhases: restPhases,
|
||||
collected,
|
||||
totalPhases: chain.totalPhases,
|
||||
phaseNumber: chain.totalPhases - restPhases.length,
|
||||
questions: input.questions,
|
||||
deps,
|
||||
ctx,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFinishSetupTool(deps: FinishSetupToolDeps): BuiltTool {
|
||||
return new Tool(BUILDER_TOOLS.FINISH_SETUP)
|
||||
.description(
|
||||
'Collect everything still needed to finish the initial build in ONE guided flow: open ' +
|
||||
'questions (including the model choice), credential slots, and chat-channel ' +
|
||||
'connections. Call it at most once, only in the trailing step of an initial build ' +
|
||||
'when only blocked tasks remain, and never together with another interactive tool. ' +
|
||||
'It shows the question and credential cards back-to-back without returning control ' +
|
||||
'between them, then one card per requested channel (always last, since connecting a ' +
|
||||
'channel needs credentials to already be resolved) — but a channel card shows ' +
|
||||
'in-chain only when the agent is already publishable when its phase is reached. Pass ' +
|
||||
'`channels` with a returned `type` from list_integration_types, one entry per channel ' +
|
||||
'to connect; do not infer channel names. Connecting a channel publishes the agent, ' +
|
||||
'and answers/credentials collected by this call are NOT applied to the config ' +
|
||||
'mid-flow — so if the agent still has publish-blocking issues once a channel phase ' +
|
||||
'is reached (expected whenever this same call is still collecting the model or a ' +
|
||||
'required credential), that ' +
|
||||
'phase\'s card is never shown — its outcome is `"blocked"` instead of `"connected"`/' +
|
||||
'`"skipped"`, and the result carries `publishBlockedIssues`. Resolve those issues first ' +
|
||||
'(patch in the credentials/model this same call already collected), then call ' +
|
||||
'configure_channel directly for each blocked channel. Returns ' +
|
||||
'{ completed, answers, credentials, channels, publishBlockedIssues } (plus configMutated/agentId refresh metadata when completed): resolve the model ' +
|
||||
'answer with resolve_llm, copy returned credential ids into the config, and verify MCP ' +
|
||||
'servers with them. Auto-resolves credential slots that match an existing single ' +
|
||||
'credential or the connected channel credential.',
|
||||
)
|
||||
.input(finishSetupInputSchema)
|
||||
.suspend(finishSetupSuspendSchema)
|
||||
.resume(finishSetupResumeSchema)
|
||||
.handler(async (input: FinishSetupInput, ctx: FinishSetupCtx) => {
|
||||
if (ctx.suspendPayload) {
|
||||
return await resumePlan(input, ctx, deps);
|
||||
}
|
||||
return await startPlan(input, ctx, deps);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { buildAskCredentialTool, buildAskEmbeddingCredentialTool } from './ask-credential.tool';
|
||||
export { buildAskQuestionsTool } from './ask-questions.tool';
|
||||
export { buildConfigureChannelTool } from './configure-channel.tool';
|
||||
export { buildFinishSetupTool } from './finish-setup.tool';
|
||||
export { buildResolveLlmTool } from './resolve-llm.tool';
|
||||
|
||||
@@ -65,3 +65,18 @@ export const LLM_PROVIDER_DEFAULTS: Record<string, LlmProviderDefault> = {
|
||||
defaultModel: 'anthropic/claude-sonnet-4.6',
|
||||
},
|
||||
};
|
||||
|
||||
/** Order in which resolve_llm auto-picks a provider when credentials span multiple providers. */
|
||||
export const LLM_PROVIDER_PRIORITY: string[] = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'mistral',
|
||||
'xai',
|
||||
'groq',
|
||||
'deepseek',
|
||||
'cohere',
|
||||
'openrouter',
|
||||
'nvidia',
|
||||
'vercel',
|
||||
];
|
||||
|
||||
@@ -4,7 +4,11 @@ import { isModelDiscoveryProvider } from '@n8n/ai-utilities/model-discovery';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BUILDER_TOOLS } from '../builder-tool-names';
|
||||
import { LLM_PROVIDER_DEFAULTS, type LlmProviderDefault } from './llm-provider-defaults';
|
||||
import {
|
||||
LLM_PROVIDER_DEFAULTS,
|
||||
LLM_PROVIDER_PRIORITY,
|
||||
type LlmProviderDefault,
|
||||
} from './llm-provider-defaults';
|
||||
|
||||
export interface ModelLookup {
|
||||
list(
|
||||
@@ -14,13 +18,40 @@ export interface ModelLookup {
|
||||
): Promise<Array<{ name: string; value: string }>>;
|
||||
}
|
||||
|
||||
/** Provisions free OpenAI credits on demand for a zero-credential builder session. */
|
||||
export interface FreeCreditsProvisioner {
|
||||
isEligible(): boolean | Promise<boolean>;
|
||||
claim(): Promise<{ credentialId: string; credentialName: string }>;
|
||||
}
|
||||
|
||||
export interface ResolveLlmToolDeps {
|
||||
credentialProvider: CredentialProvider;
|
||||
modelLookup: ModelLookup;
|
||||
freeCredits: FreeCreditsProvisioner;
|
||||
}
|
||||
|
||||
type LlmCredentialEntry = [credentialType: string, defaults: LlmProviderDefault];
|
||||
|
||||
const FREE_CREDITS_MODEL = 'gpt-5-mini';
|
||||
|
||||
/** Silently claims free OpenAI credits if eligible; never throws. */
|
||||
async function tryClaimFreeCredits(freeCredits: FreeCreditsProvisioner) {
|
||||
try {
|
||||
if (!(await freeCredits.isEligible())) return null;
|
||||
const { credentialId, credentialName } = await freeCredits.claim();
|
||||
return {
|
||||
ok: true as const,
|
||||
provider: 'openai',
|
||||
model: FREE_CREDITS_MODEL,
|
||||
credentialId,
|
||||
credentialName,
|
||||
claimedFreeOpenAiCredits: true as const,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findProviderDefault(provider: string): LlmCredentialEntry | undefined {
|
||||
const requestedProvider = provider.trim();
|
||||
return Object.entries(LLM_PROVIDER_DEFAULTS).find(
|
||||
@@ -92,13 +123,23 @@ export function buildResolveLlmTool(deps: ResolveLlmToolDeps): BuiltTool {
|
||||
return new Tool(BUILDER_TOOLS.RESOLVE_LLM)
|
||||
.description(
|
||||
'Resolve the agent main LLM without showing a picker. ' +
|
||||
'Only call this when the user explicitly names a provider or model — do NOT call it ' +
|
||||
'at the start of a conversation or proactively for fresh agents. ' +
|
||||
'For fresh agents, call it once, silently, before the first config write to detect existing ' +
|
||||
'credentials — with provider/model when the user named them, otherwise without arguments. ' +
|
||||
'Also call it whenever the user names or changes a provider or model. ' +
|
||||
'If provider is given, resolves only that provider; if model is omitted, uses the ' +
|
||||
'provider default model. For "Anthropic via OpenRouter", pass provider="openrouter" ' +
|
||||
'and omit model unless the user named a concrete OpenRouter model id. Returns ok=false ' +
|
||||
'when credentials are missing, unsupported, or ambiguous; use ask_questions to let the ' +
|
||||
'user choose, then call resolve_llm again with the choice.',
|
||||
'when credentials are missing, unsupported, or ambiguous — during an initial build, do not ' +
|
||||
'ask; keep building with model "" and include the model choice in the trailing ' +
|
||||
'finish_setup call, then call resolve_llm again with the answer. For a model ' +
|
||||
'change on an existing agent, ask immediately and keep the current model and credential until the new one resolves. ' +
|
||||
'When no matching credential exists and the user is eligible for free OpenAI credits, the tool ' +
|
||||
'claims them automatically and resolves to openai/gpt-5-mini — the result carries ' +
|
||||
'claimedFreeOpenAiCredits: true; tell the user free OpenAI credits were set up. When multiple ' +
|
||||
'providers each have one credential, the tool auto-picks the recommended provider — the result ' +
|
||||
'carries autoPicked: true and otherProviders; state the pick as changeable, do not ask to confirm it. ' +
|
||||
'When the user picks between multiple credentials of one provider, pass the picked credentialId ' +
|
||||
'from the earlier ambiguous result.',
|
||||
)
|
||||
.input(
|
||||
z.object({
|
||||
@@ -112,78 +153,147 @@ export function buildResolveLlmTool(deps: ResolveLlmToolDeps): BuiltTool {
|
||||
.describe(
|
||||
'Requested model without the selected provider prefix. For OpenRouter use the routed id, e.g. "anthropic/claude-sonnet-4.6".',
|
||||
),
|
||||
credentialId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Credential id picked by the user from an earlier ambiguous resolve_llm result.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ provider, model }: { provider?: string; model?: string }) => {
|
||||
const all = await deps.credentialProvider.list();
|
||||
const llmCredentials = all.filter((credential) => LLM_PROVIDER_DEFAULTS[credential.type]);
|
||||
.handler(
|
||||
async ({
|
||||
provider,
|
||||
model,
|
||||
credentialId,
|
||||
}: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
credentialId?: string;
|
||||
}) => {
|
||||
const all = await deps.credentialProvider.list();
|
||||
const llmCredentials = all.filter((credential) => LLM_PROVIDER_DEFAULTS[credential.type]);
|
||||
|
||||
if (provider) {
|
||||
const providerEntry = findProviderDefault(provider);
|
||||
if (!providerEntry) {
|
||||
return {
|
||||
ok: false as const,
|
||||
reason: 'unsupported_provider' as const,
|
||||
provider,
|
||||
supportedProviders: Object.values(LLM_PROVIDER_DEFAULTS).map(
|
||||
(defaults) => defaults.provider,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const [credentialType, defaults] = providerEntry;
|
||||
const matchingCredentials = llmCredentials.filter(
|
||||
(credential) => credential.type === credentialType,
|
||||
);
|
||||
|
||||
if (matchingCredentials.length === 1) {
|
||||
const credential = matchingCredentials[0];
|
||||
if (credentialId) {
|
||||
const credential = llmCredentials.find((c) => c.id === credentialId);
|
||||
if (!credential) {
|
||||
return {
|
||||
ok: false as const,
|
||||
reason: 'unknown_credential' as const,
|
||||
credentialId,
|
||||
credentials: llmCredentials.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const defaults = LLM_PROVIDER_DEFAULTS[credential.type];
|
||||
if (model?.trim()) {
|
||||
return await resolveModelAgainstLookup(credential, defaults, model, deps.modelLookup);
|
||||
}
|
||||
return toLlmResolution(credential, defaults);
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const providerEntry = findProviderDefault(provider);
|
||||
if (!providerEntry) {
|
||||
return {
|
||||
ok: false as const,
|
||||
reason: 'unsupported_provider' as const,
|
||||
provider,
|
||||
supportedProviders: Object.values(LLM_PROVIDER_DEFAULTS).map(
|
||||
(defaults) => defaults.provider,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const [credentialType, defaults] = providerEntry;
|
||||
const matchingCredentials = llmCredentials.filter(
|
||||
(credential) => credential.type === credentialType,
|
||||
);
|
||||
|
||||
if (matchingCredentials.length === 1) {
|
||||
const credential = matchingCredentials[0];
|
||||
if (model?.trim()) {
|
||||
return await resolveModelAgainstLookup(credential, defaults, model, deps.modelLookup);
|
||||
}
|
||||
return toLlmResolution(credential, defaults);
|
||||
}
|
||||
|
||||
if (
|
||||
matchingCredentials.length === 0 &&
|
||||
defaults.provider === 'openai' &&
|
||||
!model?.trim()
|
||||
) {
|
||||
const claimed = await tryClaimFreeCredits(deps.freeCredits);
|
||||
if (claimed) return claimed;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
reason:
|
||||
matchingCredentials.length === 0
|
||||
? ('missing_credential' as const)
|
||||
: ('ambiguous_credential' as const),
|
||||
provider: defaults.provider,
|
||||
credentialType,
|
||||
credentials: matchingCredentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (llmCredentials.length === 1) {
|
||||
const credential = llmCredentials[0];
|
||||
const defaults = LLM_PROVIDER_DEFAULTS[credential.type];
|
||||
if (model?.trim()) {
|
||||
return await resolveModelAgainstLookup(credential, defaults, model, deps.modelLookup);
|
||||
}
|
||||
return toLlmResolution(credential, defaults);
|
||||
}
|
||||
|
||||
if (llmCredentials.length === 0 && !model?.trim()) {
|
||||
const claimed = await tryClaimFreeCredits(deps.freeCredits);
|
||||
if (claimed) return claimed;
|
||||
}
|
||||
|
||||
if (llmCredentials.length > 1 && !model?.trim()) {
|
||||
const byProvider = new Map<string, CredentialListItem[]>();
|
||||
for (const credential of llmCredentials) {
|
||||
const providerName = LLM_PROVIDER_DEFAULTS[credential.type].provider;
|
||||
byProvider.set(providerName, [...(byProvider.get(providerName) ?? []), credential]);
|
||||
}
|
||||
|
||||
const topProvider = LLM_PROVIDER_PRIORITY.find((candidate) => byProvider.has(candidate));
|
||||
const topCredentials = topProvider ? byProvider.get(topProvider) : undefined;
|
||||
if (topProvider && topCredentials?.length === 1) {
|
||||
return {
|
||||
...toLlmResolution(topCredentials[0], LLM_PROVIDER_DEFAULTS[topCredentials[0].type]),
|
||||
autoPicked: true as const,
|
||||
otherProviders: [...byProvider.keys()].filter((other) => other !== topProvider),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
reason:
|
||||
matchingCredentials.length === 0
|
||||
llmCredentials.length === 0
|
||||
? ('missing_credential' as const)
|
||||
: ('ambiguous_credential' as const),
|
||||
provider: defaults.provider,
|
||||
credentialType,
|
||||
credentials: matchingCredentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
})),
|
||||
: ('ambiguous_provider_or_credential' as const),
|
||||
credentials: llmCredentials.map((credential) => {
|
||||
const defaults = LLM_PROVIDER_DEFAULTS[credential.type];
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: credential.type,
|
||||
provider: defaults.provider,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (llmCredentials.length === 1) {
|
||||
const credential = llmCredentials[0];
|
||||
const defaults = LLM_PROVIDER_DEFAULTS[credential.type];
|
||||
if (model?.trim()) {
|
||||
return await resolveModelAgainstLookup(credential, defaults, model, deps.modelLookup);
|
||||
}
|
||||
return toLlmResolution(credential, defaults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
reason:
|
||||
llmCredentials.length === 0
|
||||
? ('missing_credential' as const)
|
||||
: ('ambiguous_provider_or_credential' as const),
|
||||
credentials: llmCredentials.map((credential) => {
|
||||
const defaults = LLM_PROVIDER_DEFAULTS[credential.type];
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: credential.type,
|
||||
provider: defaults.provider,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
},
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -34,17 +34,18 @@ ${getSchemaReferenceSection()}
|
||||
|
||||
### Recipes
|
||||
|
||||
#### Create Or Replace A Fresh Runnable Agent
|
||||
#### Create A Fresh Agent Draft
|
||||
|
||||
- Requires \`name\`, \`model\`, \`credential\`, and \`instructions\`.
|
||||
- Requires \`name\` and \`instructions\`.
|
||||
- Use the model and credential from \`resolve_llm\` when resolved; while LLM
|
||||
setup is pending, write \`model: ""\` and omit \`credential\`.
|
||||
- Keep \`tools\` and \`skills\` arrays if present.
|
||||
|
||||
Good minimal shape:
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "Support assistant",
|
||||
"model": "openrouter/openai/gpt-5.5",
|
||||
"credential": "<main-llm-credential-id>",
|
||||
"model": "",
|
||||
"instructions": "Help the user with support questions.",
|
||||
"tools": [],
|
||||
"skills": []
|
||||
@@ -139,7 +140,7 @@ Bad: replacing \`config\` while dropping unrelated settings
|
||||
- Removing an integration means deleting its entry from \`integrations[]\`; do
|
||||
not call \`configure_channel\` for removal.
|
||||
- Model-only changes must preserve existing Brave or SearXNG \`config.webSearch\`.
|
||||
- Empty, placeholder, or guessed \`instructions\` values are rejected; ask for details instead.
|
||||
- Empty or placeholder \`instructions\` values are rejected; derive real instructions from the stated goal instead.
|
||||
|
||||
### Verify
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AgentModelSchema, RunnableAgentJsonConfigSchema } from '@n8n/api-types';
|
||||
import { AgentJsonConfigSchema, AgentModelSchema } from '@n8n/api-types';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import type { ZodObject, ZodRawShape } from 'zod';
|
||||
import { z } from 'zod';
|
||||
@@ -41,7 +41,7 @@ const BuilderPromptMemoryConfigSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const BuilderPromptAgentJsonConfigSchema = RunnableAgentJsonConfigSchema.extend({
|
||||
const BuilderPromptAgentJsonConfigSchema = AgentJsonConfigSchema.extend({
|
||||
memory: BuilderPromptMemoryConfigSchema.optional(),
|
||||
});
|
||||
|
||||
@@ -49,8 +49,8 @@ export function getConfigRulesSection(): string {
|
||||
return `\
|
||||
#### Agent Config Rules
|
||||
|
||||
- \`model\` must be "provider/model-name".
|
||||
- \`credential\` must be the id returned by \`resolve_llm\`.
|
||||
- \`model\` must be "provider/model-name", or \`""\` while LLM setup is pending.
|
||||
- A non-empty \`credential\` must be the id returned by \`resolve_llm\`.
|
||||
- Sub-agent configuration lives at top level under \`subAgents\`. Load
|
||||
\`agent-builder-sub-agents\` before adding refs or changing
|
||||
\`subAgents.maxChildren\`.
|
||||
@@ -62,8 +62,9 @@ export function getConfigRulesSection(): string {
|
||||
\`"1h"\`) when the user asks to tune cache duration; OpenAI has no
|
||||
sub-config.
|
||||
- \`config.maxIterations\` caps the number of agent loop iterations per run. Do not set or change this unless the user explicitly asks.
|
||||
- Fresh agents need a real model, credential, and instructions
|
||||
before config is written.`;
|
||||
- Fresh agents need real \`instructions\` before config is written. \`model\`
|
||||
may be \`""\` and \`credential\` omitted in a draft while LLM setup is
|
||||
pending; fill both from a \`resolve_llm\` result before publishing.`;
|
||||
}
|
||||
|
||||
export function getSchemaReferenceSection(): string {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Canonical initial-build contract. Other prompt surfaces (workflow steps,
|
||||
* interactive-tool rules, skills, tool descriptions) state only their unique
|
||||
* mechanics and reference this section instead of restating the rules.
|
||||
*/
|
||||
export const INITIAL_BUILD_SECTION = `\
|
||||
## Initial Build
|
||||
|
||||
"Initial build" means the first build pass on a fresh agent. Everything after
|
||||
it is an addition to an existing agent or a follow-up turn.
|
||||
|
||||
During an initial build:
|
||||
|
||||
- NEVER suspend mid-build on an interactive tool (\`ask_questions\`,
|
||||
\`ask_credential\`, \`ask_embedding_credential\`, \`configure_channel\`). Build
|
||||
everything as a draft first; the only allowed suspends are the single
|
||||
trailing \`finish_setup\` call and, when it reports a blocked channel, the
|
||||
immediate \`configure_channel\` follow-up described below.
|
||||
- Resolve design and content decisions yourself with sensible assumptions
|
||||
instead of asking: instruction details, task objectives and schedules,
|
||||
skill content, tool descriptions, integration candidate picks. Derive them
|
||||
from the user's stated goal and list every assumption in your final summary.
|
||||
- Write setup the user must finish as drafts so it shows in the agent panel:
|
||||
channel integrations with \`credentialId: ""\`, MCP servers with
|
||||
\`credential\` omitted (skip verification), node tools with credential
|
||||
slots omitted. Leave Episodic Memory disabled while its credential is
|
||||
missing.
|
||||
- Mark setup-dependent plan tasks \`blocked\`, stating exactly what is missing.
|
||||
- When only blocked tasks remain, call \`finish_setup\` ONCE with everything
|
||||
pending: the model choice and open decisions as \`questions\`, one
|
||||
\`credentialRequests\` entry per credential slot, and one \`channels\` entry
|
||||
per drafted channel integration — it connects or skips each channel itself,
|
||||
always as the last cards in the flow. Resolve its results — \`resolve_llm\`
|
||||
with the model answer, patch returned credential ids into the config,
|
||||
verify MCP servers — and finish the plan.
|
||||
- If \`finish_setup\` reports a channel as \`'blocked'\` (its
|
||||
\`publishBlockedIssues\` field lists why — the agent could not be published
|
||||
yet, so the card never showed), first apply everything \`finish_setup\`
|
||||
already collected: patch returned credential ids into the config and
|
||||
resolve the model with \`resolve_llm\`. If that resolves every reported
|
||||
issue (nothing was skipped), call \`configure_channel\` once per blocked
|
||||
channel as an immediate follow-up. If anything remains skipped or
|
||||
unresolved, do not call \`configure_channel\`; leave it for the closing
|
||||
checklist instead.
|
||||
- After \`finish_setup\` and any follow-up \`configure_channel\` calls, end
|
||||
your reply with a short setup checklist for whatever remains — any
|
||||
skipped, dismissed, or still-blocked items only — one line per item naming
|
||||
where to complete it in the agent panel (channels: the channel chip opens
|
||||
the setup modal), plus the offer to do it here in chat.
|
||||
- Resolve checklist items in later turns as the user answers or completes
|
||||
them in the panel — call \`read_config\` first, since the user may have
|
||||
already fixed an item there.
|
||||
|
||||
Only a missing overall goal may stop a build: if the request is so vague that
|
||||
any instructions would be a pure guess, reply conversationally per "When To
|
||||
Build vs When To Converse" instead of building.`;
|
||||
|
||||
/**
|
||||
* Shared one-line deferral interpolated by skills and tool text at their
|
||||
* point of use. Kept deliberately agnostic about how the deferred setup is
|
||||
* ultimately resolved (today: a closing checklist) so this sentence and every
|
||||
* skill that embeds it stay unchanged if the resolution mechanism changes —
|
||||
* only INITIAL_BUILD_SECTION's ending and its two references in
|
||||
* agents-builder-prompts.ts would need to.
|
||||
*/
|
||||
export const INITIAL_BUILD_NOTE =
|
||||
'During an initial build, never suspend mid-build: defer pending setup to the end of the build, per the Initial Build rules in your system prompt.';
|
||||
@@ -12,14 +12,17 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Use \`resolve_llm\` when the request contains enough provider/model detail, otherwise ask via \`ask_questions\` and call \`resolve_llm\` with the answer.
|
||||
2. If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`.
|
||||
1. For fresh agents, call \`resolve_llm\` once, silently, before the first config write — with provider/model when the user named them, otherwise with no arguments.
|
||||
2. If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`. If the result has \`claimedFreeOpenAiCredits: true\`, tell the user you set them up with free OpenAI credits. If it has \`autoPicked: true\`, tell the user which provider and model you picked and that they can ask to change it — do not ask for confirmation and do not raise a trailing model question.
|
||||
3. If the user asks to pick, change, confirm, or configure a model or main credential, ask via \`ask_questions\`; do not ask in prose.
|
||||
4. If \`resolve_llm\` reports missing or ambiguous credentials/provider, ask via \`ask_questions\` then retry \`resolve_llm\` with the answer.
|
||||
4. During an initial build, if \`resolve_llm\` reports missing or ambiguous credentials/provider, do not ask: mark the model task \`blocked\`, keep building with \`model: ""\` and no \`credential\`, and include the model choice as a question in the trailing \`finish_setup\` call — for ambiguity between multiple credentials of one provider, use the credential names from the resolve_llm result as the question's options — when it resolves, call \`resolve_llm\` with the answer (pass \`credentialId\` when the user picked a specific credential) and patch \`/model\` and \`/credential\` — after \`read_config\`, since the user may have already set the model in the panel. For a model change on an existing agent, ask immediately instead, and never write \`model: ""\` over an existing model — keep the current model and credential until the new one is resolved.
|
||||
5. If \`resolve_llm\` reports \`unknown_model\`, retry with a plausible returned model value or ask via \`ask_questions\`.
|
||||
6. If the model is still unresolved when the user asks to run or publish the agent, leave the draft with \`model: ""\`, do not guess a model, and tell the user the agent needs a model and credential first — in the panel or here in chat. If they dismiss a model-change question on an existing agent, keep the current model and credential unchanged.
|
||||
|
||||
### Rules
|
||||
|
||||
- Do not enable \`config.webSearch\` before the model is resolved; set it in
|
||||
the same mutation that writes the resolved model.
|
||||
- Only OpenAI and Anthropic models support native web search. Use native web
|
||||
search by default for those providers only, and only for
|
||||
fresh agents or agents with no existing \`config.webSearch\`. Persist
|
||||
|
||||
@@ -35,5 +35,5 @@ disabling memory. Do not load it for ordinary fresh-agent creation.
|
||||
|
||||
### Verify
|
||||
|
||||
- Fresh runnable agents have enabled n8n memory unless explicitly disabled.
|
||||
- Fresh runnable agents set \`observationalMemory.enabled\` to \`true\` unless explicitly disabled.`;
|
||||
- Fresh agents have enabled n8n memory unless explicitly disabled.
|
||||
- Fresh agents set \`observationalMemory.enabled\` to \`true\` unless explicitly disabled.`;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Builder-specific model-visible text for the SDK's `createPlannerTodosTool`.
|
||||
* The SDK ships domain-neutral defaults; these strings keep the agent
|
||||
* builder's `write_todos` tool description/instructions unchanged from
|
||||
* before the SDK text was made generic.
|
||||
*/
|
||||
export const BUILDER_PLANNER_TODOS_DESCRIPTION =
|
||||
'Create or update a structured task list for the current build. Use it to decompose the work, ' +
|
||||
'track progress, and record which tasks are blocked on user input. Use it for every build or ' +
|
||||
'change request. This tool only updates the task list; it does not perform work or ask the user.';
|
||||
|
||||
export const BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION = [
|
||||
'write_todos maintains your plan for the current build. It never asks the user or performs work itself.',
|
||||
'WHEN TO USE write_todos:',
|
||||
'- Every request that builds or changes the agent, before other tool calls.',
|
||||
'- You need to track progress or record tasks blocked on user input.',
|
||||
'WHEN NOT TO USE write_todos:',
|
||||
'- Purely conversational replies with no build work.',
|
||||
'HOW TO USE write_todos:',
|
||||
'- Write concrete, self-contained tasks; mark the first active task in_progress immediately.',
|
||||
'- Mark a task blocked when it cannot proceed without user input, and state exactly what input is missing in the task content.',
|
||||
'- Marking a task blocked IS the action for now. Do not ask the user about it while any non-blocked task remains; continue with unblocked tasks.',
|
||||
'- When only blocked tasks remain: during an initial build, follow the Initial Build rules (call finish_setup); otherwise end your turn with a summary of what is missing, and unblock and finish the tasks in later turns as the user provides input.',
|
||||
'- Update task status as soon as work completes; do not batch completions at the end.',
|
||||
'- Do not call write_todos multiple times in parallel; send one full list update at a time.',
|
||||
].join('\n');
|
||||
@@ -12,9 +12,9 @@ Use this guidance before calling \`resolve_integration\`, \`search_nodes\`,
|
||||
\`search_mcp_servers\`, \`get_node_types\`, \`build_custom_tool\`, or adding,
|
||||
changing, or removing entries in \`tools[]\` / \`mcpServers\` / \`providerTools\`.
|
||||
|
||||
For an external product, first decide whether it is the target agent's chat or
|
||||
trigger surface. Load \`agent-builder-integrations\` whenever the request could
|
||||
mean that the product is where people invoke or converse with the agent.
|
||||
For an external product, load \`agent-builder-external-services\` once and
|
||||
follow it. It covers the chat-integration-versus-callable-tool decision, chat
|
||||
integration setup, MCP servers, and node tools.
|
||||
|
||||
- Chat/trigger integration: call \`list_integration_types\`, then
|
||||
\`configure_channel\` with a returned type. Do not call \`resolve_integration\`
|
||||
@@ -24,10 +24,10 @@ mean that the product is where people invoke or converse with the agent.
|
||||
service, call \`resolve_integration\` separately, using \`queries\` as
|
||||
alternative search terms for that one service. Do not infer MCP availability
|
||||
from memory.
|
||||
- \`kind: "mcp"\`: load \`agent-builder-mcp\` and follow the MCP credential,
|
||||
- \`kind: "mcp"\`: follow the skill's MCP Servers section — credential,
|
||||
verification, and config workflow.
|
||||
- \`kind: "node"\`: load \`agent-builder-node-tools\`, use the returned node
|
||||
results, and continue with \`get_node_types\`.
|
||||
- \`kind: "node"\`: follow the skill's Node Tools section, use the returned
|
||||
node results, and continue with \`get_node_types\`.
|
||||
|
||||
Use \`search_nodes\` directly only when the user explicitly asks for an n8n node,
|
||||
when refining node results, or when a verified MCP server lacks the requested
|
||||
@@ -49,15 +49,15 @@ they cannot perform live network, filesystem, process, timer, or host I/O.
|
||||
|
||||
#### Node Tools
|
||||
|
||||
Load \`agent-builder-node-tools\` after \`resolve_integration\` returns
|
||||
\`kind: "node"\`, or when the user explicitly requests an n8n node. Follow it
|
||||
before adding, changing, or removing node-backed tools, \`nodeParameters\`,
|
||||
\`$fromAI\` usage, or n8n expressions.
|
||||
Load \`agent-builder-external-services\` when the user explicitly requests an
|
||||
n8n node, and follow its Node Tools section before adding, changing, or
|
||||
removing node-backed tools, \`nodeParameters\`, \`$fromAI\` usage, or n8n
|
||||
expressions.
|
||||
|
||||
#### MCP Servers
|
||||
|
||||
Load \`agent-builder-mcp\` after \`resolve_integration\` returns \`kind: "mcp"\`, or
|
||||
when the user explicitly requests a custom MCP server.
|
||||
Load \`agent-builder-external-services\` when the user explicitly requests a
|
||||
custom MCP server, and follow its MCP Servers section.
|
||||
|
||||
#### Custom Tools
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const resolveIntegrationInputSchema = z.object({
|
||||
export function buildResolveIntegrationTool(deps: ResolveIntegrationDeps): BuiltTool {
|
||||
return new Tool(BUILDER_TOOLS.RESOLVE_INTEGRATION)
|
||||
.description(
|
||||
'Resolve external services to MCP servers or n8n node tools. Searches the MCP registry first and returns kind: "mcp" when a match exists; only searches agent-eligible n8n node tools and returns kind: "node" when no MCP server matches. For kind: "mcp", load agent-builder-mcp. For kind: "node", load agent-builder-node-tools and use the returned node results.',
|
||||
'Resolve external services to MCP servers or n8n node tools. Searches the MCP registry first and returns kind: "mcp" when a match exists; only searches agent-eligible n8n node tools and returns kind: "node" when no MCP server matches. For either kind, load agent-builder-external-services and follow its matching section, using the returned results.',
|
||||
)
|
||||
.input(resolveIntegrationInputSchema)
|
||||
.handler(async ({ queries }: { queries: string[] }) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getBuilderRuntimeSkills } from '../index';
|
||||
|
||||
describe('getBuilderRuntimeSkills', () => {
|
||||
it('includes the integrations skill', () => {
|
||||
it('includes the external services skill', () => {
|
||||
const skills = getBuilderRuntimeSkills();
|
||||
|
||||
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(true);
|
||||
expect(skills.some((skill) => skill.id === 'agent-builder-external-services')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
import { ASK_QUESTIONS_TOOL_NAME, McpServerConfigSchema } from '@n8n/api-types';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { jsonSchemaToCompactText } from '../../json-config/schema-text-serializer';
|
||||
import { INITIAL_BUILD_NOTE } from '../prompts/initial-build.prompt';
|
||||
|
||||
const mcpServerSchemaText = jsonSchemaToCompactText(
|
||||
zodToJsonSchema(McpServerConfigSchema) as JSONSchema7,
|
||||
);
|
||||
|
||||
export function externalServicesSkill(): RuntimeSkill {
|
||||
return {
|
||||
id: 'agent-builder-external-services',
|
||||
name: 'Agent Builder External Services',
|
||||
description:
|
||||
'Use when connecting the target agent to any external product: deciding whether Slack, Linear, Telegram, or another platform is a chat integration/trigger versus an MCP, node, or workflow tool; adding, removing, or updating chat integrations or MCP servers; and wiring n8n node-backed tools (search_nodes/get_node_types discovery, nodeParameters, node credential slots, $fromAI usage, n8n expressions).',
|
||||
recommendedTools: [
|
||||
'resolve_integration',
|
||||
'list_integration_types',
|
||||
'configure_channel',
|
||||
'search_nodes',
|
||||
'get_node_types',
|
||||
'ask_credential',
|
||||
'verify_mcp_server',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
],
|
||||
allowedTools: [
|
||||
'resolve_integration',
|
||||
'list_integration_types',
|
||||
'configure_channel',
|
||||
'search_mcp_servers',
|
||||
'search_nodes',
|
||||
'get_node_types',
|
||||
'ask_credential',
|
||||
'verify_mcp_server',
|
||||
'get_resource_locator_options',
|
||||
'ask_questions',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
'write_config',
|
||||
'load_skill',
|
||||
],
|
||||
instructions: `\
|
||||
## Purpose
|
||||
|
||||
Use this to connect the target agent to external products across all three
|
||||
surfaces: chat integrations (the \`integrations\` array), MCP servers
|
||||
(\`mcpServers\`), and n8n node tools (entries in \`tools[]\` with
|
||||
\`nodeParameters\`). Decide the right surface first, then follow that
|
||||
section.
|
||||
|
||||
## Integration vs Callable Tool Decision
|
||||
|
||||
Use an integration when the product is the agent's conversation or trigger
|
||||
surface: humans will mention, message, comment to, or resume the agent there,
|
||||
or the agent needs to respond in that same platform conversation context.
|
||||
|
||||
Use an MCP, node, or workflow tool when the product is only something the agent
|
||||
operates on: searching records, creating tickets, updating objects, or sending a
|
||||
business-process notification while the conversation happens elsewhere.
|
||||
|
||||
Examples:
|
||||
|
||||
- Slack integration: the agent should be chatted with in Slack, respond in
|
||||
Slack threads, DM users, message channels, add reactions, or render rich UI
|
||||
to Slack users.
|
||||
- Linear integration: the agent should be triggered from Linear issues/comments,
|
||||
understand the current Linear subject, or reply in the same Linear
|
||||
conversation.
|
||||
- Linear callable tools: the agent is triggered from Slack, Preview, a task, or a
|
||||
workflow and only needs to search/create/update Linear tickets via MCP or node
|
||||
tools.
|
||||
|
||||
For callable (non-chat) services, call \`resolve_integration\` separately per
|
||||
service and follow the returned \`kind\`: \`"mcp"\` -> MCP Servers section
|
||||
below, \`"node"\` -> Node Tools section below.
|
||||
|
||||
## Chat Integrations
|
||||
|
||||
The \`integrations\` array controls how the target agent is triggered.
|
||||
|
||||
- These are connected external chat platforms, not built-in Preview chat.
|
||||
- Call \`list_integration_types\` first.
|
||||
- Read the returned \`capabilities\`, \`useIntegrationWhen\`, and
|
||||
\`useNodeToolWhen\` fields before deciding to add an integration.
|
||||
- Pick one returned \`type\` and pass it to \`configure_channel\` as
|
||||
\`integrationType\`. ALWAYS use \`configure_channel\` for chat-channel
|
||||
credentials — never \`ask_credential\` or a raw config write. The setup UI it
|
||||
shows creates and persists the credential/connection itself; do not follow up
|
||||
with \`patch_config\`/\`write_config\` to write the credential.
|
||||
- ${INITIAL_BUILD_NOTE} Instead of \`configure_channel\`: after
|
||||
\`list_integration_types\` returns the matching type, \`read_config()\` then
|
||||
\`patch_config\` adding \`{ "type": "<integrationType>", "credentialId": "" }\`
|
||||
to \`/integrations/-\` (include a minimal valid draft \`settings\` object for
|
||||
telegram) so the channel appears in the agent panel as needing setup. Pass
|
||||
the same \`integrationType\` in the trailing \`finish_setup\` call's
|
||||
\`channels\` array — its card connects or skips the channel itself; if
|
||||
skipped, list it in the closing setup checklist pointing at the channel
|
||||
chip in the agent panel. If \`finish_setup\` instead reports the channel as
|
||||
\`'blocked'\` (the agent could not be published yet), patch in the
|
||||
credentials/model it collected first; if that resolves every reported
|
||||
issue, call \`configure_channel\` directly for that channel as a follow-up.
|
||||
Otherwise leave it for the closing checklist.
|
||||
- Preserve existing chat integrations unless the user asked to remove them.
|
||||
- To remove an existing chat integration, call \`read_config\` and inspect
|
||||
\`config.integrations\`.
|
||||
- If exactly one existing integration matches the requested platform, remove
|
||||
that entry with \`patch_config\` by index (or replace \`/integrations\` with a
|
||||
filtered array when clearer).
|
||||
- If multiple existing integrations match the requested platform, ask which one
|
||||
to remove before editing \`integrations\`.
|
||||
- Removing a chat integration means deleting its entry from
|
||||
\`integrations[]\`. Do not call \`configure_channel\` to remove a channel.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- Chat integration types must come from \`list_integration_types\`.
|
||||
- Do not add a chat integration just because the agent needs CRUD or notifications
|
||||
for that product. Resolve the callable capability through \`resolve_integration\`
|
||||
unless the product itself is the chat/trigger context.
|
||||
- For recurring or scheduled runs, create a task (\`create_tasks\`), not an
|
||||
integration.
|
||||
- Omitting \`integrations\` from a config write preserves the current channels.
|
||||
To remove one, write an explicit filtered array or remove the exact array
|
||||
entry.
|
||||
|
||||
## MCP Servers
|
||||
|
||||
MCP servers expose external tool catalogs to the target agent over HTTP. They
|
||||
live on the top-level \`mcpServers\` array, and each entry maps 1:1 to a
|
||||
connected MCP server. Use this section when \`resolve_integration\` returned
|
||||
\`kind: "mcp"\`, the user explicitly asks to add or edit an MCP server, or the
|
||||
user provides or asks to configure a custom MCP server.
|
||||
|
||||
### Discovery and setup
|
||||
|
||||
For a generic external-service request, \`resolve_integration\` must select the
|
||||
integration type before MCP setup. If no resolver result is available yet,
|
||||
call \`resolve_integration\` with queries matching the requested service.
|
||||
Resolve one requested service per call; use \`queries\` only for alternative
|
||||
search terms for that service.
|
||||
|
||||
- If it returns \`kind: "node"\` for a generic service request, follow the Node
|
||||
Tools section with the returned node results. Stop this MCP workflow.
|
||||
- If it returns \`kind: "node"\` but the user explicitly requested an MCP server,
|
||||
do not silently substitute a node tool. Continue with manual MCP setup by
|
||||
asking for the URL and transport/authentication decision through
|
||||
\`${ASK_QUESTIONS_TOOL_NAME}\`.
|
||||
- \`resolve_integration\` returns \`{ kind: "mcp", results: [...] }\` for MCP
|
||||
matches. Never read server fields from the wrapper; select a result first:
|
||||
- If \`results[]\` contains one entry, use it as \`selectedResult\`.
|
||||
- If the request uniquely identifies one entry by \`name\` or \`title\`, use
|
||||
that entry as \`selectedResult\`.
|
||||
- If multiple candidates remain, call \`ask_questions\` with the candidate
|
||||
titles and descriptions; never choose by array order. During an initial
|
||||
build, do not call \`ask_questions\` for this: pick the best candidate by
|
||||
title/description relevance yourself, and list the pick as an assumption
|
||||
in your summary. Use the chosen entry as \`selectedResult\`. If
|
||||
\`ask_questions\` returns \`{ answered: false }\`, stop MCP setup without
|
||||
selecting a server, asking for credentials, verifying a connection, or
|
||||
mutating config. Do not re-present the question.
|
||||
- Use \`name\`, \`url\`, \`transport\`, \`authentication\`, \`credentialType\`,
|
||||
\`tools\`, and optional \`metadata\` only from \`selectedResult\`.
|
||||
|
||||
Follow these steps for the selected MCP result:
|
||||
|
||||
1. Credential: call \`ask_credential\` with a short \`purpose\`, using
|
||||
\`selectedResult.credentialType\` as \`credentialType\`. Never invent
|
||||
credential IDs.
|
||||
2. Verify: call \`verify_mcp_server\` with the selected result's \`name\`, \`url\`,
|
||||
\`transport\`, and \`authentication\`, plus the returned \`credentialId\` as
|
||||
\`credential\` when authentication is required.
|
||||
3. Capability check: confirm the verified tool names and descriptions cover the
|
||||
capability the user requested.
|
||||
4. Write config: call \`read_config\`, then \`patch_config\` to add the entry to
|
||||
\`mcpServers[]\` using the patch pattern below. When the entry already
|
||||
exists and verify returned \`credentialApplied: true\`, skip this step — the
|
||||
credential is already persisted.
|
||||
|
||||
${INITIAL_BUILD_NOTE} For MCP that means: pick the best candidate as an
|
||||
assumption (above), then \`read_config()\` and \`patch_config\` a draft
|
||||
\`/mcpServers/-\` entry using \`name\`, \`url\`, \`transport\`,
|
||||
\`authentication\`, and \`metadata.nodeTypeName\` from \`selectedResult\` with
|
||||
\`credential\` omitted, and skip \`verify_mcp_server\` — there is nothing to
|
||||
authenticate yet. Include the credential in the trailing \`finish_setup\` call;
|
||||
verify with the returned credential id — on success the tool writes the
|
||||
credential into the matching entry itself (\`credentialApplied: true\`); no
|
||||
\`read_config\`/\`patch_config\` follow-up for the credential. Existing-agent
|
||||
additions keep the immediate ask + verify flow above unchanged.
|
||||
|
||||
If verification succeeds but the tools do not cover the requested capability
|
||||
for a generic service request, switch to the Node Tools section, call
|
||||
\`search_nodes\` with the same service queries, and continue with node setup. Do
|
||||
not add the MCP server merely because its registry entry matched.
|
||||
|
||||
Full schema reference:
|
||||
|
||||
${mcpServerSchemaText}
|
||||
|
||||
### Credential flow
|
||||
|
||||
- For \`bearerAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpBearerAuth"\`.
|
||||
- For \`headerAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpHeaderAuth"\`.
|
||||
- For \`multipleHeadersAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpMultipleHeadersAuth"\`.
|
||||
- For \`mcpOAuth2Api\`, call \`ask_credential\` with
|
||||
\`credentialType: "mcpOAuth2Api"\`.
|
||||
|
||||
### Testing the connection
|
||||
|
||||
Before writing to config, call \`verify_mcp_server\` with server \`name\`,
|
||||
\`url\`, \`transport\`, and (if applicable) the credential id from
|
||||
\`ask_credential\`.
|
||||
|
||||
- Success returns \`{ ok: true, tools: [{ name, description }] }\`, and when a
|
||||
matching \`mcpServers\` entry exists, also \`credentialApplied: true,
|
||||
configMutated: true, agentId\` — the credential is written automatically; do
|
||||
not follow with \`read_config\`/\`patch_config\` for the credential.
|
||||
- When verify succeeds but \`credentialApplied: false\` and the entry already
|
||||
exists, fall back to \`read_config\` then \`patch_config\` for the credential.
|
||||
- Use the returned tool list to populate \`toolFilter.tools\` or
|
||||
\`approval.tools\` so the user does not need to type tool names manually.
|
||||
- Failure returns \`{ ok: false, error: "..." }\`.
|
||||
- If verification fails, explain the error and ask the user to check the URL
|
||||
or credentials before proceeding.
|
||||
|
||||
### Incomplete setup
|
||||
|
||||
The user can skip the credential prompt, the URL question, or both. Never
|
||||
invent a credential ID or a placeholder URL to fill the gap, and never abort
|
||||
the server addition — always persist what is known and let the user finish
|
||||
setup later:
|
||||
|
||||
- Credential skipped (\`ask_credential\` returned \`{ skipped: true }\`): omit
|
||||
only the \`credential\` field.
|
||||
- URL skipped: persist \`url: ""\`.
|
||||
- Either case: skip \`verify_mcp_server\` (there is nothing to authenticate or
|
||||
connect to), then \`read_config\` and \`patch_config\` the entry, preserving
|
||||
every other known field — \`name\`, \`transport\`, \`authentication\`, an
|
||||
already-selected credential, and registry \`metadata\`.
|
||||
|
||||
### Selecting credentials
|
||||
|
||||
When using a registry-backed server, always use the \`credentialType\` returned
|
||||
by \`selectedResult\`.
|
||||
|
||||
For custom MCP servers, if credential type is unknown, ask the user which
|
||||
credential type to use (OAuth2, Bearer Token, Header Auth, Multiple Headers
|
||||
Auth, or None) via \`${ASK_QUESTIONS_TOOL_NAME}\`. Then map to:
|
||||
|
||||
- \`bearerAuth\` -> \`ask_credential\` with \`credentialType: "httpBearerAuth"\`
|
||||
- \`headerAuth\` -> \`ask_credential\` with \`credentialType: "httpHeaderAuth"\`
|
||||
- \`multipleHeadersAuth\` -> \`ask_credential\` with
|
||||
\`credentialType: "httpMultipleHeadersAuth"\`
|
||||
- \`mcpOAuth2Api\` -> \`ask_credential\` with \`credentialType: "mcpOAuth2Api"\`
|
||||
|
||||
### Patch pattern
|
||||
|
||||
1. Initialize the array if missing:
|
||||
\`{ "op": "add", "path": "/mcpServers", "value": [] }\`
|
||||
2. Append each server:
|
||||
\`{ "op": "add", "path": "/mcpServers/-", "value": { ... } }\`
|
||||
|
||||
### Gotchas
|
||||
|
||||
- Server \`name\` must be unique across \`mcpServers\` within an agent.
|
||||
- Never fabricate \`metadata.nodeTypeName\`.
|
||||
- When \`selectedResult\` includes \`metadata.nodeTypeName\`, include
|
||||
\`metadata: { nodeTypeName: <selectedResult.metadata.nodeTypeName> }\` in the
|
||||
entry so the UI can render the correct server form.
|
||||
- A registry match proves server availability, not support for the requested
|
||||
capability; use the verified live tool list for that decision.
|
||||
|
||||
## Node Tools
|
||||
|
||||
Use this section to discover, configure, and wire node tools into the target
|
||||
agent's \`tools[]\`, including \`nodeParameters\` and n8n expressions.
|
||||
|
||||
### Workflow
|
||||
|
||||
- For a generic external-service request, call \`resolve_integration\` before
|
||||
node discovery unless a resolver result is already available.
|
||||
- If it returns \`kind: "mcp"\`, follow the MCP Servers section instead and stop
|
||||
this node-tool workflow.
|
||||
- If it returns \`kind: "node"\`, use its returned node results and call
|
||||
\`get_node_types\`; do not repeat the same search with \`search_nodes\`.
|
||||
- Call \`search_nodes\` directly only when the user explicitly requests an n8n
|
||||
node, when refining node results, or when a verified MCP server lacks the
|
||||
requested capability.
|
||||
- Never guess node type names.
|
||||
- Use the tool node id from discovery, usually ending in \`Tool\`.
|
||||
- Put fixed values in \`nodeParameters\`; use complete n8n expressions for values the agent should decide at runtime:
|
||||
\`={{ $fromAI('url', 'The URL to inspect', 'string') }}\`.
|
||||
- For stable dynamic selectors, load \`agent-builder-resource-locators\` and
|
||||
follow it.
|
||||
- Never write literal \`"$fromAI"\` or bare \`$fromAI\`; the node will treat it as the actual value.
|
||||
- Do not pipe AI-chosen fields through \`$json\`.
|
||||
- Do not include \`inputSchema\` or \`toolDescription\` for node tools.
|
||||
- For each required credential slot, call \`ask_credential\` once before the config mutation for an addition to an existing agent. ${INITIAL_BUILD_NOTE} Add the tool with that credential slot omitted; after the trailing \`finish_setup\` resolves the credential, copy the returned credentials into \`node.credentials\` via \`patch_config\`; for resource-locator resolution follow \`agent-builder-resource-locators\` then. Pass the node's credential key as \`credentialSlot\`. On success, copy the returned \`credentials\` object directly to \`node.credentials\`. If skipped, still add the tool and omit only that credential slot.
|
||||
- When the agent already has a chat channel configured and the tool needs the same
|
||||
credential type, \`ask_credential\` reuses the channel's credential automatically —
|
||||
do not ask the user to pick a different one.
|
||||
|
||||
### n8n Expressions
|
||||
|
||||
Node tool parameters inside \`nodeParameters\` can use n8n expressions.
|
||||
Prefer \`$fromAI\` whenever the target agent should decide a value at runtime.
|
||||
Do not use \`$fromAI\` for stable resource IDs that the target agent cannot know
|
||||
at runtime, such as Linear \`teamId\`, project IDs, channel IDs, calendar IDs,
|
||||
database IDs, table IDs, or other dynamic "Name or ID" selectors. Resolve those
|
||||
with the \`agent-builder-resource-locators\` skill, \`ask_credential\`, and
|
||||
\`get_resource_locator_options\`; write the returned \`parameterValue\` into
|
||||
\`nodeParameters\`.
|
||||
|
||||
- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('fieldName', 'What value to provide', 'string') }}\`
|
||||
- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('count', 'How many items', 'number') }}\`
|
||||
- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('enabled', 'Whether to enable this option', 'boolean') }}\`
|
||||
- \`={{ $now.toISO() }}\` for current date/time.
|
||||
- \`={{ $today }}\` for the start of today.
|
||||
|
||||
Always wrap expressions in \`={{ }}\`. Never pipe AI-chosen node-tool fields
|
||||
through \`$json\`; use \`$fromAI\` for those fields instead.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- Do not include \`inputSchema\` or \`toolDescription\` for node tools.
|
||||
- \`$fromAI(...)\` placeholders define the node tool input schema; do not add it manually.
|
||||
- Follow \`agent-builder-resource-locators\` for dynamic selector lookup,
|
||||
credentials, and \`parameterValue\` handling.
|
||||
- If a required node-tool credential is skipped, add the tool and omit only that credential slot.
|
||||
- Node tools execute inline, so never use waiting operations such as \`sendAndWait\`
|
||||
or \`dispatchAndWait\`. When the user requests human approval, configure the
|
||||
intended non-waiting operation and set \`requireApproval: true\` on the tool.
|
||||
|
||||
## Verify
|
||||
|
||||
- Connected chat integrations were set up through \`configure_channel\`, not
|
||||
\`ask_credential\` or a manual config write.
|
||||
- The chosen integration matches \`useIntegrationWhen\`; otherwise resolve the
|
||||
callable capability through \`resolve_integration\` and use MCP, node, or
|
||||
workflow tools.
|
||||
- Generic non-chat external services were routed through \`resolve_integration\`
|
||||
before MCP or node setup.
|
||||
- The final \`integrations\` array keeps unrelated integrations intact and
|
||||
removes only the requested channel entries.
|
||||
- Node tools use discovered tool node ids and valid node parameters.`,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
|
||||
import { customToolsSkill } from './custom-tools.skill';
|
||||
import { integrationsSkill } from './integrations.skill';
|
||||
import { mcpSkill } from './mcp.skill';
|
||||
import { externalServicesSkill } from './external-services.skill';
|
||||
import { memorySkill } from './memory.skill';
|
||||
import { nodeToolsSkill } from './node-tools.skill';
|
||||
import { resourceLocatorsSkill } from './resource-locators.skill';
|
||||
import { subAgentsSkill } from './sub-agents.skill';
|
||||
import { targetSkillsSkill } from './target-skills.skill';
|
||||
@@ -13,10 +11,8 @@ import { targetTasksSkill } from './target-tasks.skill';
|
||||
export function getBuilderRuntimeSkills(): RuntimeSkill[] {
|
||||
return [
|
||||
customToolsSkill(),
|
||||
integrationsSkill(),
|
||||
mcpSkill(),
|
||||
externalServicesSkill(),
|
||||
memorySkill(),
|
||||
nodeToolsSkill(),
|
||||
resourceLocatorsSkill(),
|
||||
subAgentsSkill(),
|
||||
targetSkillsSkill(),
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
|
||||
export function integrationsSkill(): RuntimeSkill {
|
||||
return {
|
||||
id: 'agent-builder-integrations',
|
||||
name: 'Agent Builder Integrations',
|
||||
description:
|
||||
'Use when deciding whether Slack, Linear, Telegram, or another external platform should be a target-agent chat integration/trigger versus an MCP, node, or workflow tool, and when adding, changing, or removing chat integrations; not for built-in Build chat or Preview chat behavior.',
|
||||
recommendedTools: [
|
||||
'resolve_integration',
|
||||
'list_integration_types',
|
||||
'configure_channel',
|
||||
'ask_questions',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
],
|
||||
allowedTools: [
|
||||
'resolve_integration',
|
||||
'list_integration_types',
|
||||
'configure_channel',
|
||||
'ask_questions',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
'write_config',
|
||||
'load_skill',
|
||||
],
|
||||
instructions: `\
|
||||
## Purpose
|
||||
|
||||
Use this to decide whether the target agent needs an entry in \`integrations\`
|
||||
or an MCP, node, or workflow tool for an external product, then configure
|
||||
\`integrations\` only when the integration is the right surface.
|
||||
|
||||
## Use when
|
||||
|
||||
- The user asks to add, update, or remove entries in the target agent's
|
||||
\`integrations\` array.
|
||||
- The user asks to connect the target agent to an external chat platform with
|
||||
credentials.
|
||||
|
||||
## Integration vs Callable Tool Decision
|
||||
|
||||
Use an integration when the product is the agent's conversation or trigger
|
||||
surface: humans will mention, message, comment to, or resume the agent there,
|
||||
or the agent needs to respond in that same platform conversation context.
|
||||
|
||||
Use an MCP, node, or workflow tool when the product is only something the agent
|
||||
operates on: searching records, creating tickets, updating objects, or sending a
|
||||
business-process notification while the conversation happens elsewhere.
|
||||
|
||||
Examples:
|
||||
|
||||
- Slack integration: the agent should be chatted with in Slack, respond in
|
||||
Slack threads, DM users, message channels, add reactions, or render rich UI
|
||||
to Slack users.
|
||||
- Linear integration: the agent should be triggered from Linear issues/comments,
|
||||
understand the current Linear subject, or reply in the same Linear
|
||||
conversation.
|
||||
- Linear callable tools: the agent is triggered from Slack, Preview, a task, or a
|
||||
workflow and only needs to search/create/update Linear tickets via MCP or node
|
||||
tools.
|
||||
|
||||
## Workflow
|
||||
|
||||
The \`integrations\` array controls how the target agent is triggered.
|
||||
|
||||
### Chat Integrations
|
||||
|
||||
- These are connected external chat platforms, not built-in Preview chat.
|
||||
- Call \`list_integration_types\` first.
|
||||
- Read the returned \`capabilities\`, \`useIntegrationWhen\`, and
|
||||
\`useNodeToolWhen\` fields before deciding to add an integration.
|
||||
- Pick one returned \`type\` and pass it to \`configure_channel\` as
|
||||
\`integrationType\`. ALWAYS use \`configure_channel\` for chat-channel
|
||||
credentials — never \`ask_credential\` or a raw config write. The setup UI it
|
||||
shows creates and persists the credential/connection itself; do not follow up
|
||||
with \`patch_config\`/\`write_config\` to write the credential.
|
||||
- Preserve existing chat integrations unless the user asked to remove them.
|
||||
- To remove an existing chat integration, call \`read_config\` and inspect
|
||||
\`config.integrations\`.
|
||||
- If exactly one existing integration matches the requested platform, remove
|
||||
that entry with \`patch_config\` by index (or replace \`/integrations\` with a
|
||||
filtered array when clearer).
|
||||
- If multiple existing integrations match the requested platform, ask which one
|
||||
to remove before editing \`integrations\`.
|
||||
- Removing a chat integration means deleting its entry from
|
||||
\`integrations[]\`. Do not call \`configure_channel\` to remove a channel.
|
||||
|
||||
### Callable External Services
|
||||
|
||||
When the product is not a chat/trigger integration, call \`resolve_integration\`
|
||||
with queries matching the requested service before loading MCP or node-tool
|
||||
skills.
|
||||
|
||||
- If it returns \`kind: "mcp"\`, load \`agent-builder-mcp\` and follow the MCP
|
||||
credential, verification, and config workflow.
|
||||
- If it returns \`kind: "node"\`, load \`agent-builder-node-tools\`, use the
|
||||
returned node results with \`get_node_types\`, and ask for every required
|
||||
credential.
|
||||
- Use workflow tools when the capability should come from an existing workflow
|
||||
instead of a direct MCP or node tool.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Chat integration types must come from \`list_integration_types\`.
|
||||
- Do not add a chat integration just because the agent needs CRUD or notifications
|
||||
for that product. Resolve the callable capability through \`resolve_integration\`
|
||||
unless the product itself is the chat/trigger context.
|
||||
- For recurring or scheduled runs, create a task (\`create_tasks\`), not an
|
||||
integration.
|
||||
- Omitting \`integrations\` from a config write preserves the current channels.
|
||||
To remove one, write an explicit filtered array or remove the exact array
|
||||
entry.
|
||||
|
||||
## Verify
|
||||
|
||||
- Connected chat integrations were set up through \`configure_channel\`, not
|
||||
\`ask_credential\` or a manual config write.
|
||||
- The chosen integration matches \`useIntegrationWhen\`; otherwise resolve the
|
||||
callable capability through \`resolve_integration\` and use MCP, node, or
|
||||
workflow tools.
|
||||
- Generic non-chat external services were routed through \`resolve_integration\`
|
||||
before MCP or node setup.
|
||||
- The final \`integrations\` array keeps unrelated integrations intact and
|
||||
removes only the requested channel entries.`,
|
||||
};
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
import { ASK_QUESTIONS_TOOL_NAME, McpServerConfigSchema } from '@n8n/api-types';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { jsonSchemaToCompactText } from '../../json-config/schema-text-serializer';
|
||||
|
||||
const mcpServerSchemaText = jsonSchemaToCompactText(
|
||||
zodToJsonSchema(McpServerConfigSchema) as JSONSchema7,
|
||||
);
|
||||
|
||||
export function mcpSkill(): RuntimeSkill {
|
||||
return {
|
||||
id: 'agent-builder-mcp',
|
||||
name: 'Agent builder MCP servers',
|
||||
description:
|
||||
'Use when adding, removing, or updating MCP (Model Context Protocol) servers on the target agent.',
|
||||
recommendedTools: [
|
||||
'resolve_integration',
|
||||
'ask_questions',
|
||||
'ask_credential',
|
||||
'verify_mcp_server',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
],
|
||||
allowedTools: [
|
||||
'resolve_integration',
|
||||
'search_mcp_servers',
|
||||
'search_nodes',
|
||||
'get_node_types',
|
||||
'ask_credential',
|
||||
'verify_mcp_server',
|
||||
'ask_questions',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
'write_config',
|
||||
'load_skill',
|
||||
],
|
||||
instructions: `\
|
||||
## Purpose
|
||||
|
||||
Use this to manage external MCP server connections in the target agent config.
|
||||
MCP servers expose external tool catalogs to the target agent over HTTP. They
|
||||
live on the top-level \`mcpServers\` array, and each entry maps 1:1 to a
|
||||
connected MCP server.
|
||||
|
||||
## Use when:
|
||||
|
||||
- \`resolve_integration\` returned \`kind: "mcp"\`.
|
||||
- The user explicitly asks to add or edit an MCP server.
|
||||
- The user provides or asks to configure a custom MCP server.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Discovery and setup
|
||||
|
||||
For a generic external-service request, \`resolve_integration\` must select the
|
||||
integration type before this skill is loaded. If no resolver result is
|
||||
available yet, call \`resolve_integration\` with queries matching the requested
|
||||
service. Resolve one requested service per call; use \`queries\` only for
|
||||
alternative search terms for that service.
|
||||
|
||||
- If it returns \`kind: "node"\` for a generic service request, load
|
||||
\`agent-builder-node-tools\` and continue with the returned node results. Stop
|
||||
this MCP workflow.
|
||||
- If it returns \`kind: "node"\` but the user explicitly requested an MCP server,
|
||||
do not silently substitute a node tool. Continue with manual MCP setup by
|
||||
asking for the URL and transport/authentication decision through
|
||||
\`${ASK_QUESTIONS_TOOL_NAME}\`.
|
||||
- \`resolve_integration\` returns \`{ kind: "mcp", results: [...] }\` for MCP
|
||||
matches. Never read server fields from the wrapper; select a result first:
|
||||
- If \`results[]\` contains one entry, use it as \`selectedResult\`.
|
||||
- If the request uniquely identifies one entry by \`name\` or \`title\`, use
|
||||
that entry as \`selectedResult\`.
|
||||
- If multiple candidates remain, call \`ask_questions\` with the candidate
|
||||
titles and descriptions; never choose by array order. Use the chosen entry
|
||||
as \`selectedResult\`. If \`ask_questions\` returns \`{ answered: false }\`,
|
||||
stop MCP setup without selecting a server, asking for credentials, verifying
|
||||
a connection, or mutating config. Do not re-present the question.
|
||||
- Use \`name\`, \`url\`, \`transport\`, \`authentication\`, \`credentialType\`,
|
||||
\`tools\`, and optional \`metadata\` only from \`selectedResult\`.
|
||||
|
||||
Follow these steps for the selected MCP result:
|
||||
|
||||
1. Credential: call \`ask_credential\` with a short \`purpose\`, using
|
||||
\`selectedResult.credentialType\` as \`credentialType\`. Never invent
|
||||
credential IDs.
|
||||
2. Verify: call \`verify_mcp_server\` with the selected result's \`name\`, \`url\`,
|
||||
\`transport\`, and \`authentication\`, plus the returned \`credentialId\` as
|
||||
\`credential\` when authentication is required.
|
||||
3. Capability check: confirm the verified tool names and descriptions cover the
|
||||
capability the user requested.
|
||||
4. Write config: call \`read_config\`, then \`patch_config\` to add the entry to
|
||||
\`mcpServers[]\` using the patch pattern below.
|
||||
|
||||
If verification succeeds but the tools do not cover the requested capability
|
||||
for a generic service request, load \`agent-builder-node-tools\`, call
|
||||
\`search_nodes\` with the same service queries, and continue with node setup. Do
|
||||
not add the MCP server merely because its registry entry matched.
|
||||
|
||||
Full schema reference:
|
||||
|
||||
${mcpServerSchemaText}
|
||||
|
||||
### Credential flow
|
||||
|
||||
- For \`bearerAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpBearerAuth"\`.
|
||||
- For \`headerAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpHeaderAuth"\`.
|
||||
- For \`multipleHeadersAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpMultipleHeadersAuth"\`.
|
||||
- For \`mcpOAuth2Api\`, call \`ask_credential\` with
|
||||
\`credentialType: "mcpOAuth2Api"\`.
|
||||
|
||||
### Testing the connection
|
||||
|
||||
Before writing to config, call \`verify_mcp_server\` with server \`name\`,
|
||||
\`url\`, \`transport\`, and (if applicable) the credential id from
|
||||
\`ask_credential\`.
|
||||
|
||||
- Success returns \`{ ok: true, tools: [{ name, description }] }\`.
|
||||
- Use the returned tool list to populate \`toolFilter.tools\` or
|
||||
\`approval.tools\` so the user does not need to type tool names manually.
|
||||
- Failure returns \`{ ok: false, error: "..." }\`.
|
||||
- If verification fails, explain the error and ask the user to check the URL
|
||||
or credentials before proceeding.
|
||||
|
||||
### Incomplete setup
|
||||
|
||||
The user can skip the credential prompt, the URL question, or both. Never
|
||||
invent a credential ID or a placeholder URL to fill the gap, and never abort
|
||||
the server addition — always persist what is known and let the user finish
|
||||
setup later:
|
||||
|
||||
- Credential skipped (\`ask_credential\` returned \`{ skipped: true }\`): omit
|
||||
only the \`credential\` field.
|
||||
- URL skipped: persist \`url: ""\`.
|
||||
- Either case: skip \`verify_mcp_server\` (there is nothing to authenticate or
|
||||
connect to), then \`read_config\` and \`patch_config\` the entry, preserving
|
||||
every other known field — \`name\`, \`transport\`, \`authentication\`, an
|
||||
already-selected credential, and registry \`metadata\`.
|
||||
|
||||
### Selecting credentials
|
||||
|
||||
When using a registry-backed server, always use the \`credentialType\` returned
|
||||
by \`selectedResult\`.
|
||||
|
||||
For custom MCP servers, if credential type is unknown, ask the user which
|
||||
credential type to use (OAuth2, Bearer Token, Header Auth, Multiple Headers
|
||||
Auth, or None) via \`${ASK_QUESTIONS_TOOL_NAME}\`. Then map to:
|
||||
|
||||
- \`bearerAuth\` -> \`ask_credential\` with \`credentialType: "httpBearerAuth"\`
|
||||
- \`headerAuth\` -> \`ask_credential\` with \`credentialType: "httpHeaderAuth"\`
|
||||
- \`multipleHeadersAuth\` -> \`ask_credential\` with
|
||||
\`credentialType: "httpMultipleHeadersAuth"\`
|
||||
- \`mcpOAuth2Api\` -> \`ask_credential\` with \`credentialType: "mcpOAuth2Api"\`
|
||||
|
||||
### Patch pattern
|
||||
|
||||
1. Initialize the array if missing:
|
||||
\`{ "op": "add", "path": "/mcpServers", "value": [] }\`
|
||||
2. Append each server:
|
||||
\`{ "op": "add", "path": "/mcpServers/-", "value": { ... } }\`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Server \`name\` must be unique across \`mcpServers\` within an agent.
|
||||
- Never fabricate \`metadata.nodeTypeName\`.
|
||||
- When \`selectedResult\` includes \`metadata.nodeTypeName\`, include
|
||||
\`metadata: { nodeTypeName: <selectedResult.metadata.nodeTypeName> }\` in the
|
||||
entry so the UI can render the correct server form.
|
||||
- A registry match proves server availability, not support for the requested
|
||||
capability; use the verified live tool list for that decision.`,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
|
||||
import { INITIAL_BUILD_NOTE } from '../prompts/initial-build.prompt';
|
||||
|
||||
export function memorySkill(): RuntimeSkill {
|
||||
return {
|
||||
id: 'agent-builder-memory',
|
||||
@@ -47,7 +49,7 @@ Memory (long-term, cross-session recall).
|
||||
## Episodic Memory
|
||||
|
||||
- Enable \`memory.episodicMemory\` only when the user asks for Episodic Memory, long-term memory, prior conversations, remembered decisions, exact artifacts, or cross-session memory.
|
||||
- Before enabling it, call \`ask_embedding_credential({ credentialType: "openAiApi", purpose: "OpenAI credential for Episodic Memory embeddings" })\`.
|
||||
- Before enabling it, call \`ask_embedding_credential({ credentialType: "openAiApi", purpose: "OpenAI credential for Episodic Memory embeddings" })\`. Without the assistant proxy: ${INITIAL_BUILD_NOTE} Leave Episodic Memory disabled, include an \`openAiApi\` entry in the trailing \`finish_setup\` call, and enable it with the returned credential id — the draft schema requires a credential to enable it.
|
||||
- On success, set \`memory.episodicMemory = { "enabled": true, "credential": "<credentialId>" }\`, using the returned \`credentialId\` value. This can be a real credential id or \`"managed"\` when the assistant proxy is available. Preserve existing \`topK\` or \`maxEntriesPerRun\`.
|
||||
- \`memory.episodicMemory.credential\` is only for OpenAI embeddings. It is separate from optional \`extractorModel\` and \`reflectorModel\` worker credentials.
|
||||
- If credential selection is skipped, do not enable Episodic Memory; explain that it needs an OpenAI credential for embeddings.
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
|
||||
export function nodeToolsSkill(): RuntimeSkill {
|
||||
return {
|
||||
id: 'agent-builder-node-tools',
|
||||
name: 'Agent Builder Node Tools',
|
||||
description:
|
||||
'Use when resolve_integration returns kind: "node" or the user explicitly requests an n8n node-backed tool: search_nodes/get_node_types discovery, nodeParameters, node credential slots, $fromAI usage, or other n8n expressions.',
|
||||
recommendedTools: [
|
||||
'resolve_integration',
|
||||
'search_nodes',
|
||||
'get_node_types',
|
||||
'ask_credential',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
],
|
||||
allowedTools: [
|
||||
'resolve_integration',
|
||||
'search_nodes',
|
||||
'get_node_types',
|
||||
'ask_credential',
|
||||
'get_resource_locator_options',
|
||||
'ask_questions',
|
||||
'read_config',
|
||||
'patch_config',
|
||||
'write_config',
|
||||
'load_skill',
|
||||
],
|
||||
instructions: `\
|
||||
## Purpose
|
||||
|
||||
Use this to discover, configure, and wire node tools into the target agent's
|
||||
\`tools[]\`, including \`nodeParameters\` and n8n expressions.
|
||||
|
||||
## Workflow
|
||||
|
||||
- For a generic external-service request, call \`resolve_integration\` before
|
||||
node discovery unless a resolver result is already available.
|
||||
- If it returns \`kind: "mcp"\`, load \`agent-builder-mcp\` and stop this node-tool
|
||||
workflow.
|
||||
- If it returns \`kind: "node"\`, use its returned node results and call
|
||||
\`get_node_types\`; do not repeat the same search with \`search_nodes\`.
|
||||
- Call \`search_nodes\` directly only when the user explicitly requests an n8n
|
||||
node, when refining node results, or when a verified MCP server lacks the
|
||||
requested capability.
|
||||
- Never guess node type names.
|
||||
- Use the tool node id from discovery, usually ending in \`Tool\`.
|
||||
- Put fixed values in \`nodeParameters\`; use complete n8n expressions for values the agent should decide at runtime:
|
||||
\`={{ $fromAI('url', 'The URL to inspect', 'string') }}\`.
|
||||
- For stable dynamic selectors, load \`agent-builder-resource-locators\` and
|
||||
follow it.
|
||||
- Never write literal \`"$fromAI"\` or bare \`$fromAI\`; the node will treat it as the actual value.
|
||||
- Do not pipe AI-chosen fields through \`$json\`.
|
||||
- Do not include \`inputSchema\` or \`toolDescription\` for node tools.
|
||||
- For each required credential slot, call \`ask_credential\` once before config mutation. Pass the node's credential key as \`credentialSlot\`. On success, copy the returned \`credentials\` object directly to \`node.credentials\`. If skipped, still add the tool and omit only that credential slot.
|
||||
- When the agent already has a chat channel configured and the tool needs the same
|
||||
credential type, \`ask_credential\` reuses the channel's credential automatically —
|
||||
do not ask the user to pick a different one.
|
||||
|
||||
## n8n Expressions
|
||||
|
||||
Node tool parameters inside \`nodeParameters\` can use n8n expressions.
|
||||
Prefer \`$fromAI\` whenever the target agent should decide a value at runtime.
|
||||
Do not use \`$fromAI\` for stable resource IDs that the target agent cannot know
|
||||
at runtime, such as Linear \`teamId\`, project IDs, channel IDs, calendar IDs,
|
||||
database IDs, table IDs, or other dynamic "Name or ID" selectors. Resolve those
|
||||
with the \`agent-builder-resource-locators\` skill, \`ask_credential\`, and
|
||||
\`get_resource_locator_options\`; write the returned \`parameterValue\` into
|
||||
\`nodeParameters\`.
|
||||
|
||||
- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('fieldName', 'What value to provide', 'string') }}\`
|
||||
- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('count', 'How many items', 'number') }}\`
|
||||
- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('enabled', 'Whether to enable this option', 'boolean') }}\`
|
||||
- \`={{ $now.toISO() }}\` for current date/time.
|
||||
- \`={{ $today }}\` for the start of today.
|
||||
|
||||
Always wrap expressions in \`={{ }}\`. Never pipe AI-chosen node-tool fields
|
||||
through \`$json\`; use \`$fromAI\` for those fields instead.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Do not include \`inputSchema\` or \`toolDescription\` for node tools.
|
||||
- \`$fromAI(...)\` placeholders define the node tool input schema; do not add it manually.
|
||||
- Follow \`agent-builder-resource-locators\` for dynamic selector lookup,
|
||||
credentials, and \`parameterValue\` handling.
|
||||
- If a required node-tool credential is skipped, add the tool and omit only that credential slot.
|
||||
- Node tools execute inline, so never use waiting operations such as \`sendAndWait\`
|
||||
or \`dispatchAndWait\`. When the user requests human approval, configure the
|
||||
intended non-waiting operation and set \`requireApproval: true\` on the tool.
|
||||
|
||||
## Verify
|
||||
|
||||
- Node tools use discovered tool node ids and valid node parameters.`,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
|
||||
import { INITIAL_BUILD_NOTE } from '../prompts/initial-build.prompt';
|
||||
|
||||
export function resourceLocatorsSkill(): RuntimeSkill {
|
||||
return {
|
||||
id: 'agent-builder-resource-locators',
|
||||
@@ -41,6 +43,18 @@ locator values that the target agent cannot reliably guess at runtime.
|
||||
- \`write_config\` or \`patch_config\` rejects a node parameter with a dynamic
|
||||
selector / \`get_resource_locator_options\` error.
|
||||
|
||||
## Initial-build timing
|
||||
|
||||
${INITIAL_BUILD_NOTE} If the tool needs a credential to resolve a stable
|
||||
selector, skip adding that tool for now instead of blocking the rest of the
|
||||
build. Include the credential in the trailing \`finish_setup\` call, then run
|
||||
the option lookup with the returned credential in the same turn and write the
|
||||
config mutation only when the result is unambiguous (an exact or single
|
||||
filtered match). If options remain ambiguous, do not call \`ask_questions\` —
|
||||
leave the tool deferred and add a one-line setup checklist item naming the
|
||||
pending selection; resolve it in a later turn. In an addition to an existing
|
||||
agent, resolve the credential and any ambiguity immediately instead.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Discover and inspect the node with \`search_nodes\`, then \`get_node_types\`.
|
||||
@@ -62,9 +76,10 @@ locator values that the target agent cannot reliably guess at runtime.
|
||||
- current \`nodeParameters\`
|
||||
- returned \`credentials\`, when available
|
||||
- \`filter\` when the user named a specific team, channel, project, or object
|
||||
6. If results are ambiguous, use \`ask_questions\` with the returned option names.
|
||||
If there are many pages, retry with \`paginationToken\` or a narrower
|
||||
\`filter\`.
|
||||
6. If results are ambiguous, use \`ask_questions\` with the returned option
|
||||
names (existing agents only — during an initial build, defer per
|
||||
Initial-build timing above). If there are many pages, retry with
|
||||
\`paginationToken\` or a narrower \`filter\`.
|
||||
7. Write the selected result's \`parameterValue\` exactly into
|
||||
\`nodeParameters\`. For resource locators this is an object with \`__rl\`,
|
||||
\`mode\`, and \`value\`; for classic dynamic options this is the raw ID/value.
|
||||
|
||||
@@ -43,21 +43,23 @@ ${SKILL_BODY_FORMAT_RULE}
|
||||
|
||||
${SKILL_BODY_TEMPLATE}
|
||||
|
||||
## Ask first (required)
|
||||
## Fill the template with assumptions (required)
|
||||
|
||||
Do NOT call \`create_skills\` until you have enough concrete domain detail to write
|
||||
a genuinely useful skill: a specific routing description and a body whose
|
||||
applicable sections are filled with real content (the actual steps, rules,
|
||||
examples, and edge cases). If any of that is missing, ask the user clarifying
|
||||
questions (use \`ask_questions\`, batching multiple questions into one call —
|
||||
discrete options for choices, or \`type: "text"\` for open-ended) until you can
|
||||
write it. Never create a placeholder or vague skill.
|
||||
examples, and edge cases). Derive missing domain detail from the user's
|
||||
stated goal as stated assumptions, and list them in your summary. Use
|
||||
\`ask_questions\` only when even a reasonable assumption is impossible — never
|
||||
during an initial build: mark the task \`blocked\` instead, per the Initial
|
||||
Build rules in your system prompt. Never create a placeholder or vague
|
||||
skill.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Gather the domain detail you need, asking clarifying questions until the
|
||||
description and every applicable body section can be written with concrete
|
||||
content, for every skill you plan to create.
|
||||
- Fill the domain detail you need, deriving missing detail from the goal as
|
||||
stated assumptions so the description and every applicable body section can
|
||||
be written with concrete content, for every skill you plan to create.
|
||||
- Write each skill's \`description\` as the routing contract and \`instructions\`
|
||||
using the template above. Put all "when to use" / "when not to use" guidance
|
||||
in the description, never in the body (the body is invisible until the skill
|
||||
@@ -104,7 +106,8 @@ write it. Never create a placeholder or vague skill.
|
||||
- \`create_skills\` does not attach any skill to the target agent config.
|
||||
- A skill that is useful for every request probably belongs in instructions, not in \`skills\`.
|
||||
- A vague description creates a vague skill, even if the body is excellent.
|
||||
- Do not create placeholder or vague skills; ask for missing domain details first.
|
||||
- Do not create placeholder or vague skills; derive missing domain details from
|
||||
the stated goal as assumptions instead.
|
||||
- Do not call \`create_skills\` once per skill when several are ready — batch them
|
||||
into one call so the whole set is stored in a single round trip.
|
||||
|
||||
|
||||
@@ -53,26 +53,30 @@ ${TASK_OBJECTIVE_FORMAT_RULE}
|
||||
|
||||
${TASK_OBJECTIVE_TEMPLATE}
|
||||
|
||||
## Ask first (required)
|
||||
## Fill the template with assumptions (required)
|
||||
|
||||
Do NOT call \`create_tasks\` for a task until BOTH of these are true for it:
|
||||
|
||||
1. You can fill EVERY section of the objective template above with concrete,
|
||||
specific content — no placeholders, no guesses, nothing left to "refine
|
||||
specific content — no placeholders, nothing left to "refine
|
||||
later". The objective is the ONLY message the agent receives when the task
|
||||
fires, so it must stand on its own and must not rely on the current chat.
|
||||
2. The schedule is concrete — how often and at what time it should run.
|
||||
When the user did not specify a detail, derive it from the goal as a
|
||||
stated assumption and list it in your summary.
|
||||
2. The schedule is concrete — how often and at what time it should run. If
|
||||
the user did not specify a cadence, pick a sensible default and state it
|
||||
as an assumption.
|
||||
|
||||
If any section would be empty or a guess, ask the user clarifying questions (use
|
||||
\`ask_questions\`, batching multiple questions into one call — discrete options for
|
||||
choices, or \`type: "text"\` for open-ended) until you can complete the whole
|
||||
template and pin down the cadence for every task. Never create a placeholder or
|
||||
Use \`ask_questions\` only when even a reasonable assumption is impossible —
|
||||
never during an initial build: mark the task \`blocked\` instead, per the
|
||||
Initial Build rules in your system prompt. Never create a placeholder or
|
||||
"refine-it-later" task.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Gather everything the template needs (every objective section + the cadence)
|
||||
for every task, asking clarifying questions until no section is a guess.
|
||||
- Fill everything the template needs (every objective section + the cadence)
|
||||
for every task, deriving missing details from the goal as stated
|
||||
assumptions instead of asking.
|
||||
- Write each objective using the exact template above, filling each section.
|
||||
- Make sure the agent already has every tool the steps need (an integration,
|
||||
node/workflow tool, or web search). If something is missing, add it to the agent
|
||||
|
||||
@@ -10,10 +10,17 @@ import { BUILDER_TOOLS } from './builder-tool-names';
|
||||
import { buildMcpClientForServer } from '../json-config/mcp-client-factory';
|
||||
|
||||
export interface VerifyMcpServerDeps {
|
||||
agentId?: string;
|
||||
credentialProvider: CredentialProvider;
|
||||
oauthService: OauthService;
|
||||
projectId: string;
|
||||
proxyFetch: CustomFetch;
|
||||
/** When verification succeeds with a credential, writes it into the matching
|
||||
* mcpServers entry so the builder can skip read_config → patch_config. */
|
||||
applyCredentialToMcpServer?: (
|
||||
serverName: string,
|
||||
credentialId: string,
|
||||
) => Promise<{ applied: boolean }>;
|
||||
}
|
||||
|
||||
/** Default deadline for the whole verify operation (connect + listTools) when the
|
||||
@@ -108,7 +115,10 @@ export function buildVerifyMcpServerTool(deps: VerifyMcpServerDeps): BuiltTool {
|
||||
'Establishes a temporary connection, lists the available tools, then closes the connection. ' +
|
||||
'Returns { ok: true, tools: [{ name, description }] } on success, or ' +
|
||||
'{ ok: false, error: string } on failure. ' +
|
||||
'Call this after ask_credential (when authentication is not "none") and before patch_config.',
|
||||
'When a credential is provided and a matching mcpServers entry already exists, ' +
|
||||
'a successful verify also writes the credential into that entry ' +
|
||||
'({ credentialApplied: true, configMutated: true, agentId }) — no read_config/patch_config follow-up. ' +
|
||||
'Call this after ask_credential when authentication is not "none".',
|
||||
)
|
||||
.input(verifyMcpServerInputSchema)
|
||||
.handler(async (input: VerifyMcpServerInput, ctx: ToolContext) => {
|
||||
@@ -127,12 +137,35 @@ export function buildVerifyMcpServerTool(deps: VerifyMcpServerDeps): BuiltTool {
|
||||
deps,
|
||||
);
|
||||
const tools = await listToolsWithinDeadline(client, timeoutMs, ctx.abortSignal);
|
||||
const mappedTools = tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
}));
|
||||
|
||||
if (input.credential && deps.applyCredentialToMcpServer) {
|
||||
try {
|
||||
const { applied } = await deps.applyCredentialToMcpServer(input.name, input.credential);
|
||||
if (applied && deps.agentId) {
|
||||
return {
|
||||
ok: true,
|
||||
tools: mappedTools,
|
||||
credentialApplied: true,
|
||||
configMutated: true,
|
||||
agentId: deps.agentId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: true,
|
||||
tools: mappedTools,
|
||||
credentialApplied: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
})),
|
||||
tools: mappedTools,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
+2
-2
@@ -368,7 +368,7 @@ describe('SlackAppSetupService', () => {
|
||||
'project-1',
|
||||
user,
|
||||
undefined,
|
||||
{ syncIntegrations: false },
|
||||
{ syncIntegrations: false, ignoreDraftIntegrations: true },
|
||||
);
|
||||
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
@@ -429,7 +429,7 @@ describe('SlackAppSetupService', () => {
|
||||
'project-1',
|
||||
user,
|
||||
undefined,
|
||||
{ syncIntegrations: false },
|
||||
{ syncIntegrations: false, ignoreDraftIntegrations: true },
|
||||
);
|
||||
expect(
|
||||
agentIntegrationPersistenceService.saveCredentialIntegration.mock.invocationCallOrder[0],
|
||||
|
||||
@@ -266,6 +266,7 @@ export class SlackAppSetupService {
|
||||
undefined,
|
||||
{
|
||||
syncIntegrations: false,
|
||||
ignoreDraftIntegrations: true,
|
||||
},
|
||||
);
|
||||
await this.chatIntegrationService.connect(session.agentId, integration, session.projectId);
|
||||
|
||||
@@ -2394,6 +2394,45 @@ describe('InstanceAiService — suspended run user revalidation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rebinds the suspended orchestration context tracing to the resume trace', async () => {
|
||||
const service = createSuspendedRunResumeService();
|
||||
const freshUser = { id: 'user-1', disabled: false } as User;
|
||||
service.revalidateActiveUser.mockResolvedValue(freshUser);
|
||||
const staleTracing = { id: 'stale-trace' };
|
||||
const resumeTracing = { id: 'resume-trace' };
|
||||
const orchestrationContext = { tracing: staleTracing };
|
||||
service.runState.findSuspendedByRequestId.mockReturnValue({
|
||||
agent: {},
|
||||
runId: 'run-1',
|
||||
agentRunId: 'agent-run-1',
|
||||
threadId: 'thread-a',
|
||||
user: fakeUser,
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'workflows',
|
||||
suspendPayload: { workflowId: 'wf-1', setupRequests: [] },
|
||||
abortController: new AbortController(),
|
||||
tracing: staleTracing,
|
||||
modelId: undefined,
|
||||
messageGroupId: 'group-1',
|
||||
checkpoint: undefined,
|
||||
runHandoff: undefined,
|
||||
orchestrationContext,
|
||||
});
|
||||
service.tracing.createOrchestratorResumeTraceContext.mockResolvedValue(resumeTracing);
|
||||
|
||||
await service.resumeSuspendedRun('user-1', 'req-1', { approved: true });
|
||||
|
||||
expect(orchestrationContext.tracing).toBe(resumeTracing);
|
||||
expect(service.processResumedStream).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ approved: true }),
|
||||
expect.objectContaining({
|
||||
orchestrationContext,
|
||||
tracing: resumeTracing,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rebuilds the agent when autoSetup is set, and resumes with the rebuilt one', async () => {
|
||||
const service = createSuspendedRunResumeService();
|
||||
const freshUser = { id: 'user-1', disabled: false } as User;
|
||||
@@ -2532,6 +2571,7 @@ describe('InstanceAiService — rebuildAgentForAutoSetupResume', () => {
|
||||
expect(result).toEqual({
|
||||
agent: rebuiltAgent,
|
||||
modelId: { provider: 'anthropic', model: 'claude' },
|
||||
orchestrationContext,
|
||||
});
|
||||
expect(createOrchestratorRunControl).toHaveBeenCalledWith(orchestrationContext, runHandoff);
|
||||
});
|
||||
|
||||
@@ -3684,6 +3684,7 @@ export class InstanceAiService {
|
||||
runId,
|
||||
agentRunId: result.agentRunId,
|
||||
agent,
|
||||
orchestrationContext,
|
||||
threadId,
|
||||
user,
|
||||
toolCallId: result.suspension.toolCallId,
|
||||
@@ -4526,6 +4527,7 @@ export class InstanceAiService {
|
||||
runId: orphan.runId,
|
||||
agentRunId: orphan.checkpointKey,
|
||||
agent,
|
||||
orchestrationContext: environment.orchestrationContext,
|
||||
threadId: orphan.threadId,
|
||||
user,
|
||||
toolCallId: orphan.toolCallId,
|
||||
@@ -4584,7 +4586,12 @@ export class InstanceAiService {
|
||||
runHandoff: OrchestratorRunHandoffState | undefined,
|
||||
messageGroupId?: string,
|
||||
): Promise<
|
||||
{ agent: Awaited<ReturnType<typeof createInstanceAgent>>; modelId: ModelConfig } | undefined
|
||||
| {
|
||||
agent: Awaited<ReturnType<typeof createInstanceAgent>>;
|
||||
modelId: ModelConfig;
|
||||
orchestrationContext: OrchestrationContext;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
try {
|
||||
const rebuilt = await this.buildFreshInstanceAgent(
|
||||
@@ -4597,7 +4604,11 @@ export class InstanceAiService {
|
||||
this.threadPushRef.get(threadId),
|
||||
);
|
||||
createOrchestratorRunControl(rebuilt.orchestrationContext, runHandoff ?? {});
|
||||
return { agent: rebuilt.agent, modelId: rebuilt.modelId };
|
||||
return {
|
||||
agent: rebuilt.agent,
|
||||
modelId: rebuilt.modelId,
|
||||
orchestrationContext: rebuilt.orchestrationContext,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn('Failed to rebuild agent for credential auto-setup resume', {
|
||||
threadId,
|
||||
@@ -4638,6 +4649,7 @@ export class InstanceAiService {
|
||||
checkpoint,
|
||||
plannedBuild,
|
||||
runHandoff,
|
||||
orchestrationContext,
|
||||
} = suspended;
|
||||
if (user.id !== requestingUserId) return null;
|
||||
|
||||
@@ -4711,8 +4723,16 @@ export class InstanceAiService {
|
||||
});
|
||||
const effectiveTracing = resumeTracing ?? tracing;
|
||||
|
||||
// Orchestration tools (e.g. build-agent) read `context.tracing` at call
|
||||
// time from this shared object; without the rebind the resumed sub-agent
|
||||
// emits spans through the suspended turn's shut-down trace runtime.
|
||||
if (orchestrationContext && effectiveTracing) {
|
||||
orchestrationContext.tracing = effectiveTracing;
|
||||
}
|
||||
|
||||
let resumeAgent = agent;
|
||||
let resumeModelId = modelId;
|
||||
let resumeOrchestrationContext = orchestrationContext;
|
||||
if (data.autoSetup) {
|
||||
const rebuilt = await this.rebuildAgentForAutoSetupResume(
|
||||
activeUser,
|
||||
@@ -4729,6 +4749,7 @@ export class InstanceAiService {
|
||||
}
|
||||
resumeAgent = rebuilt.agent;
|
||||
resumeModelId = rebuilt.modelId;
|
||||
resumeOrchestrationContext = rebuilt.orchestrationContext;
|
||||
}
|
||||
|
||||
this.startProcessResumedStream(resumeAgent, resumeData, {
|
||||
@@ -4743,6 +4764,7 @@ export class InstanceAiService {
|
||||
abortController,
|
||||
snapshotStorage: this.dbSnapshotStorage,
|
||||
tracing: effectiveTracing,
|
||||
orchestrationContext: resumeOrchestrationContext,
|
||||
modelId: resumeModelId,
|
||||
checkpoint,
|
||||
plannedBuild,
|
||||
@@ -4772,6 +4794,7 @@ export class InstanceAiService {
|
||||
abortController: AbortController;
|
||||
snapshotStorage: DbSnapshotStorage;
|
||||
tracing?: InstanceAiTraceContext;
|
||||
orchestrationContext?: OrchestrationContext;
|
||||
modelId?: ModelConfig;
|
||||
checkpoint?: { isCheckpointFollowUp: true; checkpointTaskId: string };
|
||||
plannedBuild?: PlannedBuildFollowUp;
|
||||
@@ -4854,6 +4877,7 @@ export class InstanceAiService {
|
||||
runId: opts.runId,
|
||||
agentRunId: result.agentRunId,
|
||||
agent,
|
||||
orchestrationContext: opts.orchestrationContext,
|
||||
threadId: opts.threadId,
|
||||
user: opts.user,
|
||||
toolCallId: result.suspension.toolCallId,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { LicenseState } from '@n8n/backend-common';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { AiService } from '@/services/ai.service';
|
||||
import type { UserService } from '@/services/user.service';
|
||||
|
||||
import { FreeAiCreditsService } from '../free-ai-credits.service';
|
||||
|
||||
describe('FreeAiCreditsService', () => {
|
||||
const licenseState = mock<LicenseState>();
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
aiAssistant: { baseUrl: 'https://ai-assistant.n8n.io' },
|
||||
});
|
||||
const aiService = mock<AiService>();
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const userService = mock<UserService>();
|
||||
|
||||
const service = new FreeAiCreditsService(
|
||||
licenseState,
|
||||
globalConfig,
|
||||
aiService,
|
||||
credentialsService,
|
||||
userService,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
licenseState.isAiCreditsLicensed.mockReturnValue(true);
|
||||
licenseState.isAiGatewayLicensed.mockReturnValue(false);
|
||||
globalConfig.aiAssistant.baseUrl = 'https://ai-assistant.n8n.io';
|
||||
});
|
||||
|
||||
describe('isEligible', () => {
|
||||
it('returns true when AI credits are licensed, the base URL is set, the gateway is not licensed, and the user has not claimed', () => {
|
||||
const user = { settings: {} } as User;
|
||||
|
||||
expect(service.isEligible(user)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when AI credits are not licensed', () => {
|
||||
licenseState.isAiCreditsLicensed.mockReturnValue(false);
|
||||
const user = { settings: {} } as User;
|
||||
|
||||
expect(service.isEligible(user)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the AI assistant base URL is not configured', () => {
|
||||
globalConfig.aiAssistant.baseUrl = '';
|
||||
const user = { settings: {} } as User;
|
||||
|
||||
expect(service.isEligible(user)).toBe(false);
|
||||
|
||||
globalConfig.aiAssistant.baseUrl = 'https://ai-assistant.n8n.io';
|
||||
});
|
||||
|
||||
it('returns false when the AI gateway is licensed', () => {
|
||||
licenseState.isAiGatewayLicensed.mockReturnValue(true);
|
||||
const user = { settings: {} } as User;
|
||||
|
||||
expect(service.isEligible(user)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the user already claimed free credits', () => {
|
||||
const user = { settings: { userClaimedAiCredits: true } } as User;
|
||||
|
||||
expect(service.isEligible(user)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claim', () => {
|
||||
it('generates credits, creates a managed credential, and marks the user as claimed', async () => {
|
||||
const user = { id: 'user123', settings: {} } as User;
|
||||
aiService.createFreeAiCredits.mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
url: 'https://proxy.n8n.io',
|
||||
});
|
||||
const credential = mock<Awaited<ReturnType<CredentialsService['createManagedCredential']>>>();
|
||||
credentialsService.createManagedCredential.mockResolvedValue(credential);
|
||||
|
||||
const result = await service.claim(user, 'project123');
|
||||
|
||||
expect(aiService.createFreeAiCredits).toHaveBeenCalledWith(user);
|
||||
expect(credentialsService.createManagedCredential).toHaveBeenCalledWith(
|
||||
{
|
||||
name: FREE_AI_CREDITS_CREDENTIAL_NAME,
|
||||
type: 'openAiApi',
|
||||
data: { apiKey: 'sk-test', url: 'https://proxy.n8n.io' },
|
||||
projectId: 'project123',
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(userService.updateSettings).toHaveBeenCalledWith('user123', {
|
||||
userClaimedAiCredits: true,
|
||||
});
|
||||
expect(result).toBe(credential);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
|
||||
|
||||
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { AiService } from '@/services/ai.service';
|
||||
import { UserService } from '@/services/user.service';
|
||||
|
||||
/**
|
||||
* Provisions the free OpenAI credits managed credential. Backs both the
|
||||
* `POST /ai/free-credits` route (browser-initiated claim) and the agent
|
||||
* builder's `resolve_llm` tool (server-initiated silent claim).
|
||||
*/
|
||||
@Service()
|
||||
export class FreeAiCreditsService {
|
||||
constructor(
|
||||
private readonly licenseState: LicenseState,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly aiService: AiService,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
/** Whether this user can claim free OpenAI credits right now. */
|
||||
isEligible(user: User): boolean {
|
||||
return (
|
||||
this.licenseState.isAiCreditsLicensed() &&
|
||||
!!this.globalConfig.aiAssistant.baseUrl &&
|
||||
!this.licenseState.isAiGatewayLicensed() &&
|
||||
!user.settings?.userClaimedAiCredits
|
||||
);
|
||||
}
|
||||
|
||||
/** Claims free credits: provisions the managed openAiApi credential and marks the user as claimed. */
|
||||
async claim(user: User, projectId?: string) {
|
||||
const aiCredits = await this.aiService.createFreeAiCredits(user);
|
||||
|
||||
const credential = await this.credentialsService.createManagedCredential(
|
||||
{
|
||||
name: FREE_AI_CREDITS_CREDENTIAL_NAME,
|
||||
type: OPEN_AI_API_CREDENTIAL_TYPE,
|
||||
data: {
|
||||
apiKey: aiCredits.apiKey,
|
||||
url: aiCredits.url,
|
||||
},
|
||||
projectId,
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
await this.userService.updateSettings(user.id, { userClaimedAiCredits: true });
|
||||
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
@@ -6467,6 +6467,7 @@
|
||||
"instanceAi.credential.selected": "Credential selected",
|
||||
"instanceAi.credential.required": "This action requires a credential",
|
||||
"instanceAi.credential.allSelected": "All credentials configured",
|
||||
"instanceAi.credential.someSkipped": "Credentials configured — skipped ones can be added later",
|
||||
"instanceAi.credential.useSelected": "Use selected",
|
||||
"instanceAi.credential.deny": "Later",
|
||||
"instanceAi.credential.confirmAll": "Confirm all credentials",
|
||||
|
||||
+75
-74
@@ -854,7 +854,6 @@ describe('AgentBuilderView — preview routing', () => {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
const header = wrapper.findComponent({ name: 'AgentBuilderHeader' });
|
||||
@@ -1178,7 +1177,6 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1218,7 +1216,6 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1232,13 +1229,39 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a config edit queued right before the artifact lock engages instead of persisting it', async () => {
|
||||
const wrapper = await renderView({
|
||||
props: {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactEditingLocked: false,
|
||||
},
|
||||
});
|
||||
updateConfigMock.mockClear();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
wrapper
|
||||
.findComponent({ name: 'AgentBuilderEditorColumn' })
|
||||
.vm.$emit('update:config', { name: 'Renamed while building' });
|
||||
|
||||
await wrapper.setProps({ artifactEditingLocked: true });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await flushPromises();
|
||||
|
||||
expect(updateConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps artifact mode tab switching out of the route query', async () => {
|
||||
const wrapper = await renderView({
|
||||
props: {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
routerReplace.mockClear();
|
||||
@@ -1260,7 +1283,6 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1278,25 +1300,6 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes the artifact shell when the artifact refresh key changes', async () => {
|
||||
const wrapper = await renderView({
|
||||
props: {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
getAgentMock.mockClear();
|
||||
fetchConfigMock.mockClear();
|
||||
|
||||
await wrapper.setProps({ artifactRefreshKey: 1 });
|
||||
await flushPromises();
|
||||
|
||||
expect(getAgentMock).toHaveBeenCalledWith({ baseUrl: 'http://localhost:5678' }, 'p2', 'a2');
|
||||
expect(fetchConfigMock).toHaveBeenCalledWith('p2', 'a2');
|
||||
});
|
||||
|
||||
it('refreshes the shell when another surface reports an update to this agent', async () => {
|
||||
// Unique ids: earlier tests leave mounted instances (and their bus
|
||||
// listeners) behind, so shared ids would inflate the mock call counts.
|
||||
@@ -1305,13 +1308,18 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p-bus',
|
||||
artifactAgentId: 'a-bus',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
getAgentMock.mockClear();
|
||||
fetchConfigMock.mockClear();
|
||||
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-bus', source: 'channel-setup-card' });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-bus', source: 'channel-setup-card' });
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await flushPromises();
|
||||
|
||||
expect(getAgentMock).toHaveBeenCalledWith(
|
||||
@@ -1324,8 +1332,14 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
// Other agents' updates and the builder's own writes are ignored.
|
||||
getAgentMock.mockClear();
|
||||
fetchConfigMock.mockClear();
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-other', source: 'channel-setup-card' });
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-bus', source: 'agent-builder' });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-other', source: 'channel-setup-card' });
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-bus', source: 'agent-builder' });
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await flushPromises();
|
||||
|
||||
expect(getAgentMock).not.toHaveBeenCalled();
|
||||
@@ -1334,6 +1348,33 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('coalesces rapid external agent updates into one refresh cascade', async () => {
|
||||
const wrapper = await renderView({
|
||||
props: {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p-debounce',
|
||||
artifactAgentId: 'a-debounce',
|
||||
},
|
||||
});
|
||||
getAgentMock.mockClear();
|
||||
fetchConfigMock.mockClear();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-debounce', source: 'channel-setup-card' });
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-debounce', source: 'instance-ai' });
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await flushPromises();
|
||||
|
||||
expect(getAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchConfigMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('replays external agent updates that arrive before initialization completes', async () => {
|
||||
let resolveAgent!: (agent: ReturnType<typeof makeAgentResponse>) => void;
|
||||
getAgentMock.mockReturnValueOnce(new Promise((resolve) => (resolveAgent = resolve)));
|
||||
@@ -1345,7 +1386,6 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p-bus-init',
|
||||
artifactAgentId: 'a-bus-init',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
@@ -1374,45 +1414,7 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('replays artifact refresh key changes that arrive before initialization completes', async () => {
|
||||
let resolveAgent!: (agent: ReturnType<typeof makeAgentResponse>) => void;
|
||||
getAgentMock.mockReturnValueOnce(new Promise((resolve) => (resolveAgent = resolve)));
|
||||
|
||||
const wrapper = await renderView({
|
||||
waitForAsyncSetup: false,
|
||||
props: {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(getAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchConfigMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await wrapper.setProps({ artifactRefreshKey: 1 });
|
||||
await nextTick();
|
||||
expect(getAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchConfigMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrapper.setProps({ artifactRefreshKey: 2 });
|
||||
await nextTick();
|
||||
expect(getAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchConfigMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveAgent(makeAgentResponse());
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(getAgentMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchConfigMock).toHaveBeenCalledTimes(2);
|
||||
expect(getAgentMock).toHaveBeenLastCalledWith({ baseUrl: 'http://localhost:5678' }, 'p2', 'a2');
|
||||
expect(fetchConfigMock).toHaveBeenLastCalledWith('p2', 'a2');
|
||||
});
|
||||
|
||||
it('surfaces errors from pending artifact refresh replay', async () => {
|
||||
it('surfaces errors from a replayed external agent update', async () => {
|
||||
let resolveAgent!: (agent: ReturnType<typeof makeAgentResponse>) => void;
|
||||
getAgentMock.mockReturnValueOnce(new Promise((resolve) => (resolveAgent = resolve)));
|
||||
fetchConfigMock.mockImplementationOnce(async () => {
|
||||
@@ -1425,24 +1427,23 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
waitForAsyncSetup: false,
|
||||
props: {
|
||||
artifactMode: true,
|
||||
artifactProjectId: 'p2',
|
||||
artifactAgentId: 'a2',
|
||||
artifactRefreshKey: 0,
|
||||
artifactProjectId: 'p-err',
|
||||
artifactAgentId: 'a-err',
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(getAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchConfigMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await wrapper.setProps({ artifactRefreshKey: 1 });
|
||||
await nextTick();
|
||||
// Lands mid-initialize → queued via pendingExternalRefresh, replayed after init.
|
||||
agentsEventBus.emit('agentUpdated', { agentId: 'a-err', source: 'channel-setup-card' });
|
||||
|
||||
resolveAgent(makeAgentResponse());
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith(replayError, 'agents.builder.loadError');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('adds JSON import and export actions to the header menu', async () => {
|
||||
|
||||
+90
-4
@@ -638,15 +638,101 @@ describe('AgentCapabilitiesSection', () => {
|
||||
expect(wrapper.emitted('tasks-changed')).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('hides the add-tool and add-skill buttons when disabled (read-only host)', async () => {
|
||||
const wrapper = mountSection([]);
|
||||
it('disables the add-tool and add-skill buttons when disabled (read-only host)', async () => {
|
||||
const wrapper = mountSection(
|
||||
[],
|
||||
{},
|
||||
configWithMcpServers([
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://mcp.github.com',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
},
|
||||
]),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-1',
|
||||
skill: { name: 'Refund policy', description: '', instructions: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-tool"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-skill"]').exists()).toBe(true);
|
||||
expect(
|
||||
wrapper.find('[data-testid="agent-capabilities-add-tool"]').attributes('disabled'),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
wrapper.find('[data-testid="agent-capabilities-add-skill"]').attributes('disabled'),
|
||||
).toBeUndefined();
|
||||
|
||||
await wrapper.setProps({ disabled: true });
|
||||
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-tool"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-skill"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-tool"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="agent-capabilities-add-skill"]').exists()).toBe(true);
|
||||
expect(
|
||||
wrapper.find('[data-testid="agent-capabilities-add-tool"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
wrapper.find('[data-testid="agent-capabilities-add-skill"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
|
||||
const toolChip = wrapper.find('[data-testid="agent-capabilities-tool-row"]');
|
||||
const skillChip = wrapper.find('[data-testid="agent-capabilities-skill-row"]');
|
||||
expect(toolChip.attributes('disabled')).toBeDefined();
|
||||
expect(skillChip.attributes('disabled')).toBeDefined();
|
||||
|
||||
await toolChip.trigger('click');
|
||||
await skillChip.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('open-tool')).toBeUndefined();
|
||||
expect(wrapper.emitted('open-skill')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('disables the grouped-tool dropdown menu when disabled (read-only host)', async () => {
|
||||
getNodeType.mockImplementation((type: string) => {
|
||||
if (type === 'n8n-nodes-base.gmailTool') {
|
||||
return createNodeType('n8n-nodes-base.gmailTool', 'Gmail Tool');
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const wrapper = mountSection([
|
||||
{
|
||||
type: 'node',
|
||||
name: 'inbox_triage',
|
||||
node: {
|
||||
nodeType: 'n8n-nodes-base.gmailTool',
|
||||
nodeTypeVersion: 1,
|
||||
nodeParameters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'node',
|
||||
name: 'send_follow_up',
|
||||
node: {
|
||||
nodeType: 'n8n-nodes-base.gmailTool',
|
||||
nodeTypeVersion: 1,
|
||||
nodeParameters: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Reka's DropdownMenuTrigger — not the read-only chip inside it — is what
|
||||
// actually gates opening the menu, so assert its own disabled state.
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]');
|
||||
expect(trigger.attributes('disabled')).toBe('false');
|
||||
|
||||
await wrapper.setProps({ disabled: true });
|
||||
|
||||
expect(wrapper.find('[aria-haspopup="menu"]').attributes('disabled')).toBe('true');
|
||||
});
|
||||
|
||||
describe('channel modal', () => {
|
||||
|
||||
+55
@@ -19,6 +19,10 @@ const {
|
||||
setCredentialsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentApi', () => ({
|
||||
createSlackAgentApp: vi.fn().mockResolvedValue({ installUrl: 'https://slack.test/install' }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ restApiContext: {} }),
|
||||
}));
|
||||
@@ -97,4 +101,55 @@ describe('useAgentChannelSetup', () => {
|
||||
expect(fetchProjectMock).toHaveBeenCalledWith('artifact-project');
|
||||
expect(credentialPermissions.value.create).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves setupSlackApp successfully when the popup closes while an in-flight poll is about to confirm the connection', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
class FakeBroadcastChannel {
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
postMessage() {}
|
||||
}
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
|
||||
const fakePopup = { closed: false, close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue(fakePopup as unknown as Window);
|
||||
|
||||
let resolveFirstPoll!: () => void;
|
||||
const firstPoll = new Promise<void>((resolve) => {
|
||||
resolveFirstPoll = resolve;
|
||||
});
|
||||
let isConnected = false;
|
||||
const fetchStatus = vi.fn().mockImplementation(async () => {
|
||||
if (fetchStatus.mock.calls.length === 1) {
|
||||
await firstPoll;
|
||||
}
|
||||
});
|
||||
|
||||
const onConnected = vi.fn();
|
||||
const { setupSlackApp } = useAgentChannelSetup({
|
||||
projectId: () => 'artifact-project',
|
||||
agentId: () => 'agent-1',
|
||||
currentIntegration: null,
|
||||
connectedCredentials: {},
|
||||
fetchStatus,
|
||||
isIntegrationConnected: () => isConnected,
|
||||
});
|
||||
|
||||
const setupPromise = setupSlackApp('token', onConnected);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
fakePopup.closed = true;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
isConnected = true;
|
||||
resolveFirstPoll();
|
||||
|
||||
await expect(setupPromise).resolves.toBe(true);
|
||||
expect(onConnected).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
+21
-2
@@ -50,11 +50,11 @@ describe('useAgentConfigAutosave', () => {
|
||||
let rejectFirstSave: (error: Error) => void = () => {};
|
||||
const save = vi.fn((snapshot: { value: string }) => {
|
||||
if (snapshot.value === 'old') {
|
||||
return new Promise<void>((_resolve, reject) => {
|
||||
return new Promise<'skipped' | undefined>((_resolve, reject) => {
|
||||
rejectFirstSave = reject;
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const autosave = useAgentConfigAutosave<{ value: string }>({
|
||||
save,
|
||||
@@ -92,4 +92,23 @@ describe('useAgentConfigAutosave', () => {
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps saveStatus idle and skips onSaved when save resolves "skipped"', async () => {
|
||||
vi.useFakeTimers();
|
||||
const save = vi.fn().mockResolvedValue('skipped' as const);
|
||||
const onSaved = vi.fn();
|
||||
const autosave = useAgentConfigAutosave<{ value: string }>({
|
||||
save,
|
||||
onSaved,
|
||||
debounceMs: 500,
|
||||
});
|
||||
|
||||
autosave.scheduleAutosave({ value: 'latest' });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await autosave.settleAutosave();
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(autosave.saveStatus.value).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ const props = defineProps<{
|
||||
beforeRevertToPublished?: () => Promise<void> | void;
|
||||
isVersionHistoryOpen?: boolean;
|
||||
artifactMode?: boolean;
|
||||
/** True while the AI is actively building/mutating this agent in artifact mode — disables publish/revert/unpublish without hiding them. */
|
||||
editingLocked?: boolean;
|
||||
configValidationStatus?: 'valid' | 'invalid' | null;
|
||||
beforePublish?: () => Promise<boolean>;
|
||||
}>();
|
||||
@@ -223,7 +225,7 @@ const isVersionHistoryDisabled = computed(() => !props.agent?.hasPublishHistory)
|
||||
:agent="agent"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:is-saving="saveStatus === 'saving'"
|
||||
:is-saving="saveStatus === 'saving' || editingLocked"
|
||||
:before-revert-to-published="beforeRevertToPublished"
|
||||
:config-validation-status="configValidationStatus"
|
||||
:before-publish="beforePublish"
|
||||
|
||||
+11
-12
@@ -570,11 +570,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:class="[$style.section, props.disabled && $style.disabled]"
|
||||
:inert="props.disabled || undefined"
|
||||
data-testid="agent-capabilities-section"
|
||||
>
|
||||
<div :class="$style.section" data-testid="agent-capabilities-section">
|
||||
<div v-if="showSection('channels')" :class="$style.capabilityRow">
|
||||
<N8nText size="small" color="text-light" :class="$style.rowLabel">
|
||||
{{ i18n.baseText('agents.builder.triggers.title') }}
|
||||
@@ -587,6 +583,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
:icon="channel.icon"
|
||||
:invalid="channel.invalid"
|
||||
:invalid-reasons="channel.invalidReasons"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.capabilityChip"
|
||||
data-testid="agent-capabilities-channel-row"
|
||||
@click="openChannelEdit(channel.type)"
|
||||
@@ -626,6 +623,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
<N8nDropdownMenu
|
||||
v-if="tool.isGrouped"
|
||||
:items="toolMenuItems(tool)"
|
||||
:disabled="props.disabled"
|
||||
placement="bottom-start"
|
||||
data-testid="agent-capabilities-tool-group"
|
||||
@select="onToolMenuSelect"
|
||||
@@ -634,6 +632,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
<AgentChipButton
|
||||
:invalid="tool.invalid"
|
||||
:invalid-reasons="tool.invalidReasons"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.capabilityChip"
|
||||
data-testid="agent-capabilities-tool-row"
|
||||
>
|
||||
@@ -659,6 +658,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
v-else-if="tool.nodeType"
|
||||
:invalid="tool.invalid"
|
||||
:invalid-reasons="tool.invalidReasons"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.capabilityChip"
|
||||
data-testid="agent-capabilities-tool-row"
|
||||
@click="emit('open-tool', tool.tool.openTarget)"
|
||||
@@ -673,6 +673,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
:icon="tool.fallbackIcon"
|
||||
:invalid="tool.invalid"
|
||||
:invalid-reasons="tool.invalidReasons"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.capabilityChip"
|
||||
data-testid="agent-capabilities-tool-row"
|
||||
@click="emit('open-tool', tool.tool.openTarget)"
|
||||
@@ -682,7 +683,6 @@ function handleChannelDisconnected(channelType: string) {
|
||||
</template>
|
||||
|
||||
<N8nTooltip
|
||||
v-if="!props.disabled"
|
||||
:disabled="!hasTools"
|
||||
:content="i18n.baseText('agents.builder.tools.add')"
|
||||
placement="top"
|
||||
@@ -691,6 +691,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
:icon-only="hasTools"
|
||||
:disabled="props.disabled"
|
||||
data-testid="agent-capabilities-add-tool"
|
||||
@click="emit('add-tool')"
|
||||
>
|
||||
@@ -715,6 +716,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
icon="sparkles"
|
||||
:invalid="(skillIssueMessages.get(id) ?? []).length > 0"
|
||||
:invalid-reasons="skillIssueMessages.get(id) ?? []"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.capabilityChip"
|
||||
data-testid="agent-capabilities-skill-row"
|
||||
@click="emit('open-skill', id)"
|
||||
@@ -723,7 +725,6 @@ function handleChannelDisconnected(channelType: string) {
|
||||
</AgentChipButton>
|
||||
|
||||
<N8nTooltip
|
||||
v-if="!props.disabled"
|
||||
:disabled="!hasSkills"
|
||||
:content="i18n.baseText('agents.builder.skills.add')"
|
||||
placement="top"
|
||||
@@ -732,6 +733,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
:icon-only="hasSkills"
|
||||
:disabled="props.disabled"
|
||||
data-testid="agent-capabilities-add-skill"
|
||||
@click="emit('add-skill')"
|
||||
>
|
||||
@@ -755,6 +757,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
icon="bot"
|
||||
:invalid="subAgent.invalid"
|
||||
:invalid-reasons="subAgent.invalidReasons"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.capabilityChip"
|
||||
data-testid="agent-capabilities-sub-agent-row"
|
||||
@click="openExistingSubAgentModal(subAgent)"
|
||||
@@ -792,6 +795,7 @@ function handleChannelDisconnected(channelType: string) {
|
||||
icon="clipboard-list"
|
||||
:invalid="task.invalid"
|
||||
:invalid-reasons="task.invalidReasons"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.capabilityChip"
|
||||
data-testid="agent-capabilities-task-row"
|
||||
@click="openTaskModal(task)"
|
||||
@@ -882,11 +886,6 @@ function handleChannelDisconnected(channelType: string) {
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color--danger);
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ function onEpisodicMemoryToggle(enabled: boolean) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.container, props.disabled && $style.disabled]">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.titleGroup">
|
||||
<N8nText step="sm" bold :class="shared.dataEntryLabel">
|
||||
@@ -404,10 +404,6 @@ function onEpisodicMemoryToggle(enabled: boolean) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.inlineInput {
|
||||
width: 70px;
|
||||
text-align: center;
|
||||
|
||||
@@ -251,7 +251,7 @@ function clearDifficultyMapping(difficulty: SubAgentTaskDifficulty) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.subAgentsPanel, disabled && $style.disabled]" :aria-disabled="disabled">
|
||||
<div :class="$style.subAgentsPanel" :aria-disabled="disabled">
|
||||
<div :class="$style.settingRow">
|
||||
<div :class="$style.settingLabel">
|
||||
<N8nText step="sm" bold :class="shared.dataEntryLabel">
|
||||
@@ -361,11 +361,6 @@ function clearDifficultyMapping(difficulty: SubAgentTaskDifficulty) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.subAgentsPanel.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.settingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+24
-13
@@ -189,7 +189,7 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
|
||||
async function waitForSlackAppSetupCompletion(popup: Window): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const oauthChannel = new BroadcastChannel('oauth-callback');
|
||||
let pollInFlight = false;
|
||||
let activePoll: Promise<void> | null = null;
|
||||
let settled = false;
|
||||
|
||||
const closePopup = () => {
|
||||
@@ -209,20 +209,31 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (pollInFlight || settled) return;
|
||||
pollInFlight = true;
|
||||
try {
|
||||
await options.fetchStatus(['slack']);
|
||||
if (options.isIntegrationConnected('slack')) settle(true);
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
if (activePoll || settled) return;
|
||||
activePoll = (async () => {
|
||||
try {
|
||||
await options.fetchStatus(['slack']);
|
||||
if (options.isIntegrationConnected('slack')) settle(true);
|
||||
} finally {
|
||||
activePoll = null;
|
||||
}
|
||||
})();
|
||||
await activePoll;
|
||||
};
|
||||
|
||||
const pollInterval = window.setInterval(
|
||||
() => void pollStatus(),
|
||||
SLACK_APP_SETUP_POLL_INTERVAL_MS,
|
||||
);
|
||||
const pollInterval = window.setInterval(() => {
|
||||
// User closed the popup — the OAuth flow can't complete anymore. Let any
|
||||
// in-flight poll finish (it may confirm success), check status once more,
|
||||
// then give up instead of blocking the UI until the full timeout.
|
||||
if (popup.closed) {
|
||||
void (activePoll ?? Promise.resolve())
|
||||
.catch(() => {})
|
||||
.then(pollStatus)
|
||||
.finally(() => settle(false));
|
||||
return;
|
||||
}
|
||||
void pollStatus();
|
||||
}, SLACK_APP_SETUP_POLL_INTERVAL_MS);
|
||||
const timeout = window.setTimeout(() => settle(false), SLACK_APP_SETUP_TIMEOUT_MS);
|
||||
|
||||
oauthChannel.addEventListener('message', (event: MessageEvent) => {
|
||||
|
||||
+11
-2
@@ -10,8 +10,13 @@ export interface UseAgentConfigAutosaveParams<TSnapshot> {
|
||||
* for snapshotting any per-agent context (projectId/agentId/config) so that
|
||||
* a save scheduled for agent A doesn't accidentally fire against agent B
|
||||
* after a switch.
|
||||
*
|
||||
* Return `'skipped'` when the save was intentionally declined (e.g. a
|
||||
* write-lock is active) rather than performed — this suppresses `onSaved`
|
||||
* and keeps `saveStatus` at `'idle'` instead of flashing `'saved'` for an
|
||||
* edit that was never persisted.
|
||||
*/
|
||||
save: (snapshot: TSnapshot) => Promise<void>;
|
||||
save: (snapshot: TSnapshot) => Promise<'skipped' | undefined>;
|
||||
/** Called after a successful save so the caller can fire telemetry. */
|
||||
onSaved?: (snapshot: TSnapshot) => void;
|
||||
/** Called when the save throws — caller decides how to surface the error. */
|
||||
@@ -61,7 +66,11 @@ export function useAgentConfigAutosave<TSnapshot>(params: UseAgentConfigAutosave
|
||||
saveStatusResetTimer = null;
|
||||
}
|
||||
try {
|
||||
await params.save(snapshot);
|
||||
const result = await params.save(snapshot);
|
||||
if (result === 'skipped') {
|
||||
saveStatus.value = 'idle';
|
||||
return;
|
||||
}
|
||||
params.onSaved?.(snapshot);
|
||||
saveStatus.value = 'saved';
|
||||
saveStatusResetTimer = setTimeout(() => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
CONTINUE_SESSION_ID_PARAM,
|
||||
PROJECT_AGENTS,
|
||||
} from '../constants';
|
||||
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
||||
import { agentsEventBus, type AgentUpdatedEvent } from '../agents.eventBus';
|
||||
import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
|
||||
import AgentBuilderPreviewHeader from '../components/AgentBuilderPreviewHeader.vue';
|
||||
@@ -80,13 +81,14 @@ const props = withDefaults(
|
||||
artifactMode?: boolean;
|
||||
artifactProjectId?: string;
|
||||
artifactAgentId?: string;
|
||||
artifactRefreshKey?: number;
|
||||
/** True while the AI is actively building/mutating this agent in artifact mode — disables editing/publishing without hiding content. */
|
||||
artifactEditingLocked?: boolean;
|
||||
}>(),
|
||||
{
|
||||
artifactMode: false,
|
||||
artifactProjectId: undefined,
|
||||
artifactAgentId: undefined,
|
||||
artifactRefreshKey: 0,
|
||||
artifactEditingLocked: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -134,6 +136,11 @@ const agentId = computed(
|
||||
const isFavorite = computed(() => favoritesStore.isFavorite(agentId.value, 'agent'));
|
||||
|
||||
const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissions(projectId);
|
||||
// Combines permission with the artifact-mode build lock: while the AI is
|
||||
// actively building/mutating this agent, editing is disabled even for a user
|
||||
// who otherwise has permission — mirrors the workflow artifact's read-only
|
||||
// lock during a build.
|
||||
const effectiveCanEditAgent = computed(() => canEditAgent.value && !props.artifactEditingLocked);
|
||||
|
||||
const isVersionHistoryOpen = ref(false);
|
||||
|
||||
@@ -160,7 +167,6 @@ async function onSendPreviewToAssistant(executionId?: string) {
|
||||
* - render the preview chat before the route/config/session state has settled.
|
||||
*/
|
||||
const initialized = ref(false);
|
||||
const pendingArtifactRefreshKey = ref<number>();
|
||||
/** Queues `agentUpdated` bus events that land mid-initialize for replay (see `onExternalAgentUpdated`). */
|
||||
const pendingExternalRefresh = ref(false);
|
||||
const agentName = ref('');
|
||||
@@ -592,7 +598,10 @@ interface SkillAutosaveSnapshot {
|
||||
skill: AgentSkill;
|
||||
}
|
||||
|
||||
async function saveConfig(snapshot: ConfigAutosaveSnapshot): Promise<void> {
|
||||
async function saveConfig(snapshot: ConfigAutosaveSnapshot): Promise<'skipped' | undefined> {
|
||||
// The AI may be mutating this agent right now — a save queued just before
|
||||
// the lock engaged must not persist its now-stale full config over it.
|
||||
if (props.artifactEditingLocked) return 'skipped';
|
||||
const result = await updateConfig(snapshot.projectId, snapshot.agentId, snapshot.config);
|
||||
// The write landed regardless of staleness below — tell other surfaces
|
||||
// (e.g. canvas agent cards invalidate their capability-summary cache).
|
||||
@@ -601,7 +610,7 @@ async function saveConfig(snapshot: ConfigAutosaveSnapshot): Promise<void> {
|
||||
// meantime — both `config` (handled inside useAgentConfig) and
|
||||
// `agent.versionId` would otherwise be polluted with values for the
|
||||
// previous agent.
|
||||
if (result.stale) return;
|
||||
if (result.stale) return undefined;
|
||||
if (agent.value && agent.value.id === snapshot.agentId && result.versionId !== undefined) {
|
||||
agent.value = { ...agent.value, versionId: result.versionId };
|
||||
}
|
||||
@@ -609,9 +618,11 @@ async function saveConfig(snapshot: ConfigAutosaveSnapshot): Promise<void> {
|
||||
fetchAgent(snapshot.projectId, snapshot.agentId),
|
||||
refreshConfigValidation(snapshot.projectId, snapshot.agentId),
|
||||
]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function saveSkill(snapshot: SkillAutosaveSnapshot): Promise<void> {
|
||||
async function saveSkill(snapshot: SkillAutosaveSnapshot): Promise<'skipped' | undefined> {
|
||||
if (props.artifactEditingLocked) return 'skipped';
|
||||
const result = await updateAgentSkill(
|
||||
rootStore.restApiContext,
|
||||
snapshot.projectId,
|
||||
@@ -620,7 +631,7 @@ async function saveSkill(snapshot: SkillAutosaveSnapshot): Promise<void> {
|
||||
snapshot.skill,
|
||||
);
|
||||
agentsEventBus.emit('agentUpdated', { agentId: snapshot.agentId, source: 'agent-builder' });
|
||||
if (agent.value?.id !== snapshot.agentId) return;
|
||||
if (agent.value?.id !== snapshot.agentId) return undefined;
|
||||
agent.value = {
|
||||
...agent.value,
|
||||
versionId: result.versionId,
|
||||
@@ -630,6 +641,7 @@ async function saveSkill(snapshot: SkillAutosaveSnapshot): Promise<void> {
|
||||
},
|
||||
};
|
||||
await refreshConfigValidation(snapshot.projectId, snapshot.agentId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Debounce shorter than the workflow canvas' 1500ms — the publish button's
|
||||
@@ -681,9 +693,27 @@ async function settleAutosave() {
|
||||
}
|
||||
|
||||
async function flushAutosave() {
|
||||
// Locked means the AI is mutating this agent right now — flushing a
|
||||
// pending edit here would persist a stale full config over its writes.
|
||||
if (props.artifactEditingLocked) {
|
||||
configAutosave.cancelPendingAutosave();
|
||||
skillAutosave.cancelPendingAutosave();
|
||||
return;
|
||||
}
|
||||
await Promise.all([configAutosave.flushAutosave(), skillAutosave.flushAutosave()]);
|
||||
}
|
||||
|
||||
// Makes the lock a write boundary rather than only a disabled UI state: drop
|
||||
// any autosave queued before the AI started mutating this agent.
|
||||
watch(
|
||||
() => props.artifactEditingLocked,
|
||||
(locked) => {
|
||||
if (!locked) return;
|
||||
configAutosave.cancelPendingAutosave();
|
||||
skillAutosave.cancelPendingAutosave();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Authoritative pre-publish gate for the frontend: flush any pending edit so
|
||||
* the backend validates the config the user is about to publish (not a
|
||||
@@ -809,7 +839,7 @@ function replaceConfigAndScheduleSave(nextConfig: AgentJsonConfig, recordEdit =
|
||||
}
|
||||
|
||||
function persistMissingPersonalisationGradient() {
|
||||
if (!canEditAgent.value) return;
|
||||
if (!effectiveCanEditAgent.value) return;
|
||||
if (!localConfig.value) return;
|
||||
|
||||
const nextConfig = addMissingAgentPersonalisation(localConfig.value);
|
||||
@@ -848,40 +878,24 @@ function handleArtifactRefreshError(error: unknown) {
|
||||
showError(error, locale.baseText('agents.builder.loadError'));
|
||||
}
|
||||
|
||||
async function replayPendingArtifactRefresh() {
|
||||
if (!isArtifactMode.value || pendingArtifactRefreshKey.value === undefined) return;
|
||||
pendingArtifactRefreshKey.value = undefined;
|
||||
await refreshArtifactShell();
|
||||
let externalRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function scheduleExternalRefresh() {
|
||||
clearTimeout(externalRefreshTimer);
|
||||
externalRefreshTimer = setTimeout(() => {
|
||||
void refreshArtifactShell().catch(handleArtifactRefreshError);
|
||||
}, getDebounceTime(400));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.artifactRefreshKey,
|
||||
async (refreshKey, previousRefreshKey) => {
|
||||
if (!isArtifactMode.value || refreshKey === previousRefreshKey) return;
|
||||
if (!initialized.value) {
|
||||
pendingArtifactRefreshKey.value = refreshKey;
|
||||
return;
|
||||
}
|
||||
pendingArtifactRefreshKey.value = undefined;
|
||||
try {
|
||||
await refreshArtifactShell();
|
||||
} catch (error: unknown) {
|
||||
handleArtifactRefreshError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function onExternalAgentUpdated(event?: AgentUpdatedEvent) {
|
||||
if (event?.source === 'agent-builder') return;
|
||||
if (!event?.agentId || event.agentId !== agentId.value) return;
|
||||
// Mid-initialize the write may have landed after initialize()'s own config
|
||||
// fetch already resolved, so queue a replay instead of dropping the event.
|
||||
// Unlike `replayPendingArtifactRefresh` this isn't gated on artifact mode.
|
||||
if (!initialized.value) {
|
||||
pendingExternalRefresh.value = true;
|
||||
return;
|
||||
}
|
||||
void refreshArtifactShell().catch(handleArtifactRefreshError);
|
||||
scheduleExternalRefresh();
|
||||
}
|
||||
|
||||
async function replayPendingExternalRefresh() {
|
||||
@@ -891,7 +905,6 @@ async function replayPendingExternalRefresh() {
|
||||
}
|
||||
|
||||
agentsEventBus.on('agentUpdated', onExternalAgentUpdated);
|
||||
onBeforeUnmount(() => agentsEventBus.off('agentUpdated', onExternalAgentUpdated));
|
||||
|
||||
const headerActions = computed(() => {
|
||||
const actions: Array<ActionDropdownItem<string>> = [
|
||||
@@ -902,7 +915,7 @@ const headerActions = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (canEditAgent.value) {
|
||||
if (effectiveCanEditAgent.value) {
|
||||
actions.push({
|
||||
id: 'import-json',
|
||||
label: locale.baseText('agents.builder.importJson' as BaseTextKey),
|
||||
@@ -959,7 +972,7 @@ async function exportAgentJson() {
|
||||
}
|
||||
|
||||
function openImportJsonModal() {
|
||||
if (!canEditAgent.value) return;
|
||||
if (!effectiveCanEditAgent.value) return;
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_JSON_IMPORT_MODAL_KEY,
|
||||
@@ -1045,6 +1058,8 @@ async function onHeaderAction(action: string) {
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
clearTimeout(externalRefreshTimer);
|
||||
// A refresh queued for the previous agent must not fire against this one.
|
||||
initialized.value = false;
|
||||
// A refresh queued before this (re)initialize is obsolete: it targeted the
|
||||
// agent that was current when the event fired, and the fetches below return
|
||||
@@ -1117,7 +1132,6 @@ async function initialize() {
|
||||
showError(error, locale.baseText('agents.builder.loadError'));
|
||||
} finally {
|
||||
initialized.value = true;
|
||||
void replayPendingArtifactRefresh().catch(handleArtifactRefreshError);
|
||||
void replayPendingExternalRefresh().catch(handleArtifactRefreshError);
|
||||
warmAgentKnowledgeSandboxForPage();
|
||||
}
|
||||
@@ -1126,6 +1140,8 @@ async function initialize() {
|
||||
watch(agentId, initialize, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
agentsEventBus.off('agentUpdated', onExternalAgentUpdated);
|
||||
clearTimeout(externalRefreshTimer);
|
||||
sessionsStore.stopAutoRefresh();
|
||||
void flushAutosave().catch(() => {});
|
||||
});
|
||||
@@ -1290,6 +1306,7 @@ function onPreviewBreadcrumbSelect(item: PathItem) {
|
||||
:before-revert-to-published="settleAutosave"
|
||||
:is-version-history-open="isVersionHistoryOpen"
|
||||
:artifact-mode="isArtifactMode"
|
||||
:editing-locked="props.artifactEditingLocked"
|
||||
:config-validation-status="configValidation?.status ?? null"
|
||||
:before-publish="refreshValidationBeforePublish"
|
||||
@header-action="onHeaderAction"
|
||||
@@ -1362,7 +1379,7 @@ function onPreviewBreadcrumbSelect(item: PathItem) {
|
||||
:deleting-agent-file-id="deletingAgentFileId"
|
||||
:applied-skills="appliedSkills"
|
||||
:connected-triggers="connectedTriggers"
|
||||
:can-edit-agent="canEditAgent"
|
||||
:can-edit-agent="effectiveCanEditAgent"
|
||||
:tasks-reload-key="tasksReloadKey"
|
||||
:main-tab-options="mainTabOptions"
|
||||
:executions-description="executionsDescription"
|
||||
|
||||
@@ -1027,7 +1027,6 @@ async function dismissComposerContextChip() {
|
||||
:class="$style.previewSlot"
|
||||
:agent-id="preview.activeAgentId.value"
|
||||
:project-id="preview.activeAgentProjectId.value"
|
||||
:refresh-key="preview.agentRefreshKey.value"
|
||||
/>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
|
||||
+64
@@ -446,6 +446,70 @@ describe('InstanceAiCredentialSetup', () => {
|
||||
// Should show the form again (not deferred state)
|
||||
expect(getByText('instanceAi.credential.deny')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('submits the selected credential and marks the skipped one when skipping the first of two', async () => {
|
||||
const requests = makeCredentialRequestsWithExisting(2);
|
||||
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
const resolveSpy = vi.spyOn(thread, 'resolveConfirmation');
|
||||
|
||||
const { getByText, getByTestId } = renderComponent({
|
||||
props: {
|
||||
requestId: 'req-1',
|
||||
credentialRequests: requests,
|
||||
message: 'Set up credentials',
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByText('instanceAi.credential.deny'));
|
||||
expect(getByText('2 of 2')).toBeTruthy();
|
||||
|
||||
await userEvent.click(getByTestId('credential-picker'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
|
||||
kind: 'credentialSelection',
|
||||
credentials: { type2: 'cred-123' },
|
||||
});
|
||||
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'approved');
|
||||
expect(getByText('instanceAi.credential.someSkipped')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('auto-advances after a selection that follows a skipped step', async () => {
|
||||
const requests = makeCredentialRequestsWithExisting(3);
|
||||
|
||||
const { getByText, getByTestId } = renderComponent({
|
||||
props: {
|
||||
requestId: 'req-1',
|
||||
credentialRequests: requests,
|
||||
message: 'Set up credentials',
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByText('instanceAi.credential.deny'));
|
||||
expect(getByText('2 of 3')).toBeTruthy();
|
||||
|
||||
await userEvent.click(getByTestId('credential-picker'));
|
||||
expect(getByText('3 of 3')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defers the whole card once every credential slot has been skipped', async () => {
|
||||
const requests = makeCredentialRequests(2);
|
||||
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
const resolveSpy = vi.spyOn(thread, 'resolveConfirmation');
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
requestId: 'req-1',
|
||||
credentialRequests: requests,
|
||||
message: 'Set up credentials',
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByText('instanceAi.credential.deny'));
|
||||
await userEvent.click(getByText('instanceAi.credential.deny'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
|
||||
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'deferred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser-use setup choice (094 experiment)', () => {
|
||||
|
||||
+2
-5
@@ -188,7 +188,6 @@ const InstanceAiAgentPreviewStub = defineComponent({
|
||||
props: {
|
||||
agentId: { type: String, required: true },
|
||||
projectId: { type: String, required: true },
|
||||
refreshKey: { type: Number, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
@@ -196,7 +195,6 @@ const InstanceAiAgentPreviewStub = defineComponent({
|
||||
'data-test-id': 'instance-ai-agent-preview-stub',
|
||||
'data-agent-id': props.agentId,
|
||||
'data-project-id': props.projectId,
|
||||
'data-refresh-key': String(props.refreshKey),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -939,7 +937,7 @@ describe('InstanceAiThreadView', () => {
|
||||
{
|
||||
agentId: 'agent-builder-child',
|
||||
role: 'agent-builder',
|
||||
kind: 'builder',
|
||||
kind: 'agent-builder',
|
||||
status: 'completed',
|
||||
textContent: '',
|
||||
reasoning: '',
|
||||
@@ -970,7 +968,6 @@ describe('InstanceAiThreadView', () => {
|
||||
|
||||
expect(preview).toHaveAttribute('data-agent-id', 'agent-1');
|
||||
expect(preview).toHaveAttribute('data-project-id', 'proj-1');
|
||||
expect(preview).toHaveAttribute('data-refresh-key', '1');
|
||||
});
|
||||
|
||||
it('closes the agent artifact preview from the wrapper toggle', async () => {
|
||||
@@ -999,7 +996,7 @@ describe('InstanceAiThreadView', () => {
|
||||
{
|
||||
agentId: 'agent-builder-child',
|
||||
role: 'agent-builder',
|
||||
kind: 'builder',
|
||||
kind: 'agent-builder',
|
||||
status: 'completed',
|
||||
textContent: '',
|
||||
reasoning: '',
|
||||
|
||||
+62
@@ -8,6 +8,7 @@ import {
|
||||
getLatestDeletedDataTableId,
|
||||
getLatestWorkflowUpdateResult,
|
||||
getLatestAgentArtifactResult,
|
||||
getLatestAgentConfigMutation,
|
||||
getExecutionResultsByWorkflow,
|
||||
isAgentEditingWorkflow,
|
||||
isAgentEditingAgent,
|
||||
@@ -463,6 +464,67 @@ describe('getLatestAgentArtifactResult', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestAgentConfigMutation', () => {
|
||||
test('returns the latest stamped mutation with agentId and toolCallId', () => {
|
||||
const node = makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-write',
|
||||
toolName: 'write_config',
|
||||
isLoading: false,
|
||||
result: { ok: true, configMutated: true, agentId: 'agent-1' },
|
||||
}),
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-patch',
|
||||
toolName: 'patch_config',
|
||||
isLoading: false,
|
||||
result: { ok: true, configMutated: true, agentId: 'agent-1' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(getLatestAgentConfigMutation(node)).toEqual({
|
||||
agentId: 'agent-1',
|
||||
toolCallId: 'tc-patch',
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores loading calls and results without the configMutated marker', () => {
|
||||
const inFlight = makeAgentNode({
|
||||
toolCalls: [makeToolCall({ toolName: 'write_config', isLoading: true })],
|
||||
});
|
||||
expect(getLatestAgentConfigMutation(inFlight)).toBeUndefined();
|
||||
|
||||
const unstamped = makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({ toolName: 'write_config', isLoading: false, result: { ok: true } }),
|
||||
makeToolCall({ toolName: 'read_config', isLoading: false, result: { ok: true } }),
|
||||
],
|
||||
});
|
||||
expect(getLatestAgentConfigMutation(unstamped)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('finds stamped mutations in nested children, most recent last', () => {
|
||||
const nestedChild = makeAgentNode({
|
||||
agentId: 'nested-child',
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-nested',
|
||||
toolName: 'patch_config',
|
||||
isLoading: false,
|
||||
result: { ok: true, configMutated: true, agentId: 'agent-1' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const parent = makeAgentNode({ children: [nestedChild] });
|
||||
|
||||
expect(getLatestAgentConfigMutation(parent)).toEqual({
|
||||
agentId: 'agent-1',
|
||||
toolCallId: 'tc-nested',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestDataTableResult', () => {
|
||||
test('returns undefined for node with no tool calls', () => {
|
||||
expect(getLatestDataTableResult(makeAgentNode())).toBeUndefined();
|
||||
|
||||
+40
-143
@@ -6,6 +6,7 @@ import type {
|
||||
InstanceAiToolCallState,
|
||||
} from '@n8n/api-types';
|
||||
import { useCanvasPreview } from '../useCanvasPreview';
|
||||
import { agentsEventBus } from '@/features/agents/agents.eventBus';
|
||||
import type { ResourceEntry } from '../useResourceRegistry';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -757,160 +758,56 @@ describe('useCanvasPreview', () => {
|
||||
expect(ctx.activeTabId.value).toBeUndefined();
|
||||
expect(ctx.isPreviewVisible.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('auto-opens agent artifact when build-agent creates a new agent', async () => {
|
||||
const ctx = setup();
|
||||
ctx.thread.isStreaming = true;
|
||||
registerAgent(ctx.thread, 'agent-1', 'SEO Auditor', 'project-1');
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-child',
|
||||
role: 'agent-builder',
|
||||
kind: 'builder',
|
||||
targetResource: {
|
||||
type: 'agent',
|
||||
id: 'agent-1',
|
||||
projectId: 'project-1',
|
||||
name: 'SEO Auditor',
|
||||
},
|
||||
}),
|
||||
],
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-create-agent',
|
||||
toolName: 'build-agent',
|
||||
args: { message: 'build me an SEO auditor', name: 'SEO Auditor' },
|
||||
result: { ok: true, builderReply: 'Created the agent.' },
|
||||
}),
|
||||
],
|
||||
describe('signal agent config mutations on the event bus', () => {
|
||||
function makeAgentConfigMutationTree(toolCallId: string) {
|
||||
return makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId,
|
||||
toolName: 'patch_config',
|
||||
isLoading: false,
|
||||
result: { ok: true, configMutated: true, agentId: 'agent-1' },
|
||||
}),
|
||||
}),
|
||||
];
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test('emits agentUpdated for each new config mutation', async () => {
|
||||
const emitSpy = vi.spyOn(agentsEventBus, 'emit');
|
||||
const ctx = setup();
|
||||
|
||||
ctx.thread.messages = [makeMessage({ agentTree: makeAgentConfigMutationTree('tc-1') })];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeAgentId.value).toBe('agent-1');
|
||||
expect(ctx.activeAgentProjectId.value).toBe('project-1');
|
||||
expect(ctx.isPreviewVisible.value).toBe(true);
|
||||
expect(emitSpy).toHaveBeenCalledWith('agentUpdated', {
|
||||
agentId: 'agent-1',
|
||||
source: 'instance-ai',
|
||||
});
|
||||
|
||||
ctx.thread.messages = [makeMessage({ agentTree: makeAgentConfigMutationTree('tc-2') })];
|
||||
await nextTick();
|
||||
|
||||
expect(emitSpy).toHaveBeenLastCalledWith('agentUpdated', {
|
||||
agentId: 'agent-1',
|
||||
source: 'instance-ai',
|
||||
});
|
||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
emitSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not auto-open agent artifact while hydrating', async () => {
|
||||
test('does not emit while hydrating the thread', async () => {
|
||||
const emitSpy = vi.spyOn(agentsEventBus, 'emit');
|
||||
const ctx = setup();
|
||||
ctx.thread.isHydratingThread = true;
|
||||
registerAgent(ctx.thread, 'agent-1', 'SEO Auditor', 'project-1');
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
children: [
|
||||
makeAgentNode({
|
||||
agentId: 'agent-builder-child',
|
||||
role: 'agent-builder',
|
||||
kind: 'builder',
|
||||
targetResource: {
|
||||
type: 'agent',
|
||||
id: 'agent-1',
|
||||
projectId: 'project-1',
|
||||
name: 'SEO Auditor',
|
||||
},
|
||||
}),
|
||||
],
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-create-agent',
|
||||
toolName: 'build-agent',
|
||||
args: { message: 'build me an SEO auditor', name: 'SEO Auditor' },
|
||||
result: { ok: true, builderReply: 'Created the agent.' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
ctx.thread.messages = [makeMessage({ agentTree: makeAgentConfigMutationTree('tc-1') })];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.activeAgentId.value).toBeNull();
|
||||
expect(ctx.isPreviewVisible.value).toBe(false);
|
||||
});
|
||||
|
||||
test('increments agentRefreshKey when active agent is mutated', async () => {
|
||||
const ctx = setup();
|
||||
registerAgent(ctx.thread, 'agent-1', 'SEO Auditor', 'project-1');
|
||||
ctx.openAgentPreview('agent-1', 'project-1');
|
||||
const initialKey = ctx.agentRefreshKey.value;
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
targetResource: { type: 'agent', id: 'agent-1', projectId: 'project-1' },
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-build-agent',
|
||||
toolName: 'build-agent',
|
||||
args: { message: 'add a skill' },
|
||||
result: { ok: true, configUpdated: true },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.agentRefreshKey.value).toBe(initialKey + 1);
|
||||
expect(ctx.activeAgentId.value).toBe('agent-1');
|
||||
});
|
||||
|
||||
test('increments agentRefreshKey for active agent mutations without targetResource', async () => {
|
||||
const ctx = setup();
|
||||
registerAgent(ctx.thread, 'agent-1', 'SEO Auditor', 'project-1');
|
||||
ctx.openAgentPreview('agent-1', 'project-1');
|
||||
const initialKey = ctx.agentRefreshKey.value;
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-build-agent',
|
||||
toolName: 'build-agent',
|
||||
args: { message: 'add a skill' },
|
||||
result: { ok: true, configUpdated: true },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.agentRefreshKey.value).toBe(initialKey + 1);
|
||||
expect(ctx.activeAgentId.value).toBe('agent-1');
|
||||
});
|
||||
|
||||
test('does not open or refresh on a reply-only turn (no name, no configUpdated)', async () => {
|
||||
const ctx = setup();
|
||||
registerAgent(ctx.thread, 'agent-1', 'SEO Auditor', 'project-1');
|
||||
ctx.openAgentPreview('agent-1', 'project-1');
|
||||
const initialKey = ctx.agentRefreshKey.value;
|
||||
|
||||
ctx.thread.messages = [
|
||||
makeMessage({
|
||||
agentTree: makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-build-agent-reply',
|
||||
toolName: 'build-agent',
|
||||
args: { message: 'what does this agent do?' },
|
||||
result: { ok: true, builderReply: 'It triages your inbox.' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
await nextTick();
|
||||
|
||||
expect(ctx.agentRefreshKey.value).toBe(initialKey);
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
emitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InstanceAiAgentNode } from '@n8n/api-types';
|
||||
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
export interface ExecutionResult {
|
||||
executionId: string;
|
||||
@@ -417,67 +418,112 @@ function getAgentTarget(node: InstanceAiAgentNode): AgentArtifactTarget | undefi
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentArtifactWalk {
|
||||
result?: AgentArtifactResult;
|
||||
interface AgentTargetedWalk<T> {
|
||||
result?: T;
|
||||
/**
|
||||
* Most specific agent target found in this subtree: this node's own
|
||||
* targetResource, or one bubbled up from a child. Lets an orchestrator's
|
||||
* own `build-agent` tool call (which carries no agentId in its result)
|
||||
* resolve identity from the builder sub-agent it just spawned, whose
|
||||
* targetResource carries the agentId via the `agent-spawned` event.
|
||||
* own tool call (which carries no agentId in its result) resolve identity
|
||||
* from the builder sub-agent it just spawned, whose targetResource
|
||||
* carries the agentId via the `agent-spawned` event.
|
||||
*/
|
||||
target?: AgentArtifactTarget;
|
||||
}
|
||||
|
||||
function walkAgentArtifact(
|
||||
/**
|
||||
* Walks an agent tree depth-first (most recent last), threading the nearest
|
||||
* agent target down to descendants and back up to callers, and returns the
|
||||
* first `match` hit among a node's own tool calls (also most recent last).
|
||||
* Shared by artifact discovery and config-mutation preview refresh so their
|
||||
* identical target-resolution/traversal logic can't drift between the two.
|
||||
*/
|
||||
function walkAgentTargetedResult<T>(
|
||||
node: InstanceAiAgentNode,
|
||||
fallbackTarget: AgentArtifactTarget | undefined,
|
||||
): AgentArtifactWalk {
|
||||
match: (
|
||||
toolCall: InstanceAiToolCallState,
|
||||
callTarget: AgentArtifactTarget | undefined,
|
||||
) => T | undefined,
|
||||
): AgentTargetedWalk<T> {
|
||||
const ownTarget = getAgentTarget(node);
|
||||
const target = ownTarget ?? fallbackTarget;
|
||||
|
||||
let childTarget: AgentArtifactTarget | undefined;
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
const childWalk = walkAgentArtifact(node.children[i], target);
|
||||
const childWalk = walkAgentTargetedResult(node.children[i], target, match);
|
||||
if (childWalk.result) return childWalk;
|
||||
if (childTarget === undefined) childTarget = childWalk.target;
|
||||
}
|
||||
|
||||
// Identity for this node's own build-agent call: prefer its own
|
||||
// targetResource, then one discovered on a child (the builder sub-agent
|
||||
// spawned by this call), then the fallback threaded down from an ancestor.
|
||||
// Identity for this node's own tool calls: prefer its own targetResource,
|
||||
// then one discovered on a child (e.g. a builder sub-agent it spawned),
|
||||
// then the fallback threaded down from an ancestor.
|
||||
const callTarget = ownTarget ?? childTarget ?? fallbackTarget;
|
||||
|
||||
for (let i = node.toolCalls.length - 1; i >= 0; i--) {
|
||||
const tc = node.toolCalls[i];
|
||||
if (tc.isLoading || !tc.result || typeof tc.result !== 'object') continue;
|
||||
const result = tc.result as Record<string, unknown>;
|
||||
const args = tc.args as Record<string, unknown> | undefined;
|
||||
|
||||
if (tc.toolName === 'build-agent' && callTarget) {
|
||||
if (result.ok === true && typeof args?.name === 'string') {
|
||||
return {
|
||||
result: { ...callTarget, toolCallId: tc.toolCallId, kind: 'created' },
|
||||
target: callTarget,
|
||||
};
|
||||
}
|
||||
if (result.configUpdated === true) {
|
||||
return {
|
||||
result: { ...callTarget, toolCallId: tc.toolCallId, kind: 'mutated' },
|
||||
target: callTarget,
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = match(node.toolCalls[i], callTarget);
|
||||
if (result !== undefined) return { result, target: callTarget };
|
||||
}
|
||||
|
||||
return { target: callTarget };
|
||||
}
|
||||
|
||||
function matchAgentArtifactToolCall(
|
||||
tc: InstanceAiToolCallState,
|
||||
callTarget: AgentArtifactTarget | undefined,
|
||||
): AgentArtifactResult | undefined {
|
||||
if (tc.isLoading || !tc.result || typeof tc.result !== 'object' || !callTarget) return undefined;
|
||||
if (tc.toolName !== 'build-agent') return undefined;
|
||||
|
||||
const result = tc.result as Record<string, unknown>;
|
||||
const args = tc.args as Record<string, unknown> | undefined;
|
||||
|
||||
if (result.ok === true && typeof args?.name === 'string') {
|
||||
return { ...callTarget, toolCallId: tc.toolCallId, kind: 'created' };
|
||||
}
|
||||
if (result.configUpdated === true) {
|
||||
return { ...callTarget, toolCallId: tc.toolCallId, kind: 'mutated' };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getLatestAgentArtifactResult(
|
||||
node: InstanceAiAgentNode,
|
||||
fallbackTarget?: AgentArtifactTarget,
|
||||
): AgentArtifactResult | undefined {
|
||||
return walkAgentArtifact(node, fallbackTarget).result;
|
||||
return walkAgentTargetedResult(node, fallbackTarget, matchAgentArtifactToolCall).result;
|
||||
}
|
||||
|
||||
/** Builder tool calls whose success means the persisted agent changed in a panel-visible way. */
|
||||
export interface AgentConfigMutationResult {
|
||||
agentId: string;
|
||||
/** Unique per mutation — a later mutation in the same build re-fires watchers. */
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an agent tree depth-first (most recent last) and returns the latest
|
||||
* resolved tool call stamped with `configMutated: true` by the backend.
|
||||
*/
|
||||
export function getLatestAgentConfigMutation(
|
||||
node: InstanceAiAgentNode,
|
||||
): AgentConfigMutationResult | undefined {
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
const childResult = getLatestAgentConfigMutation(node.children[i]);
|
||||
if (childResult) return childResult;
|
||||
}
|
||||
for (let i = node.toolCalls.length - 1; i >= 0; i--) {
|
||||
const tc = node.toolCalls[i];
|
||||
if (
|
||||
!tc.isLoading &&
|
||||
isRecord(tc.result) &&
|
||||
tc.result.configMutated === true &&
|
||||
typeof tc.result.agentId === 'string'
|
||||
) {
|
||||
return { agentId: tc.result.agentId, toolCallId: tc.toolCallId };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-38
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { N8nCanvasThinkingPill } from '@n8n/design-system';
|
||||
import AgentBuilderView from '@/features/agents/views/AgentBuilderView.vue';
|
||||
import { isAgentEditingAgent } from '../canvasPreview.utils';
|
||||
import { useThread } from '../instanceAi.store';
|
||||
@@ -8,13 +7,15 @@ import { useThread } from '../instanceAi.store';
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
refreshKey: number;
|
||||
}>();
|
||||
|
||||
// === Editing lock ===
|
||||
// Lock the artifact's editor while the AI is actively building/mutating THIS
|
||||
// agent, so the user can't edit into a mid-stream conflict. `isAgentEditingAgent`
|
||||
// defines the signals that trigger the lock.
|
||||
// Lock the artifact's editing (not its visibility) while the AI is actively
|
||||
// building/mutating THIS agent, so the user can't edit into a mid-stream
|
||||
// conflict. `isAgentEditingAgent` defines the signals that trigger the lock.
|
||||
// Parity with the workflow artifact: content stays fully visible and
|
||||
// inspectable — only editing/publishing is disabled, via
|
||||
// `artifact-editing-locked` on `AgentBuilderView`.
|
||||
const thread = useThread();
|
||||
|
||||
const isAgentBuilding = computed(() => {
|
||||
@@ -28,21 +29,12 @@ const isAgentBuilding = computed(() => {
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.builder" :inert="isAgentBuilding">
|
||||
<AgentBuilderView
|
||||
artifact-mode
|
||||
:artifact-project-id="props.projectId"
|
||||
:artifact-agent-id="props.agentId"
|
||||
:artifact-refresh-key="props.refreshKey"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isAgentBuilding"
|
||||
:class="$style.buildingOverlay"
|
||||
data-testid="agent-preview-building-overlay"
|
||||
>
|
||||
<N8nCanvasThinkingPill />
|
||||
</div>
|
||||
<AgentBuilderView
|
||||
artifact-mode
|
||||
:artifact-project-id="props.projectId"
|
||||
:artifact-agent-id="props.agentId"
|
||||
:artifact-editing-locked="isAgentBuilding"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,22 +44,4 @@ const isAgentBuilding = computed(() => {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.builder {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
&[inert] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.buildingOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
+76
-23
@@ -65,6 +65,8 @@ const isSubmitted = ref(false);
|
||||
const isDeferred = ref(false);
|
||||
|
||||
const selections = ref<Record<string, string | null>>({});
|
||||
/** Credential types the user explicitly skipped via "Later" on their step, distinct from never-visited types. */
|
||||
const skippedTypes = ref<Set<string>>(new Set());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-select from existing credentials
|
||||
@@ -126,14 +128,27 @@ function isStepComplete(credentialType: string): boolean {
|
||||
return selections.value[credentialType] !== null;
|
||||
}
|
||||
|
||||
const allSelected = computed(() =>
|
||||
props.credentialRequests.every((r) => isStepComplete(r.credentialType)),
|
||||
/** A step is handled once it has a selection or the user explicitly skipped it — either way, nothing left to do there. */
|
||||
function isStepHandled(credentialType: string): boolean {
|
||||
return isStepComplete(credentialType) || skippedTypes.value.has(credentialType);
|
||||
}
|
||||
|
||||
const allHandled = computed(() =>
|
||||
props.credentialRequests.every((r) => isStepHandled(r.credentialType)),
|
||||
);
|
||||
|
||||
const anySelected = computed(() =>
|
||||
props.credentialRequests.some((r) => isStepComplete(r.credentialType)),
|
||||
);
|
||||
|
||||
/** The submitted-state label: finalize has its own copy; otherwise distinguish a full submit from a mixed skip/select one. */
|
||||
const submittedLabelKey = computed(() => {
|
||||
if (isFinalize.value) return 'instanceAi.credential.finalize.applied';
|
||||
return skippedTypes.value.size > 0
|
||||
? 'instanceAi.credential.someSkipped'
|
||||
: 'instanceAi.credential.allSelected';
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-advance
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -159,7 +174,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
const nextIncomplete = props.credentialRequests.findIndex(
|
||||
(r, idx) => idx > currentStepIndex.value && !isStepComplete(r.credentialType),
|
||||
(r, idx) => idx > currentStepIndex.value && !isStepHandled(r.credentialType),
|
||||
);
|
||||
if (nextIncomplete >= 0) {
|
||||
goToStep(nextIncomplete);
|
||||
@@ -167,13 +182,15 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-continue when all credentials have been selected. Runs immediately
|
||||
// so a single existing credential auto-selected on init resolves the card
|
||||
// without user input, as the setup tool describes.
|
||||
// Auto-continue once every step is handled (selected or skipped) and at
|
||||
// least one credential was provided. Runs immediately so a single existing
|
||||
// credential auto-selected on init resolves the card without user input, as
|
||||
// the setup tool describes. The per-step skip path submits directly instead
|
||||
// of relying on this watcher (see handleLater).
|
||||
watch(
|
||||
allSelected,
|
||||
async (nowComplete, wasComplete) => {
|
||||
if (nowComplete && !wasComplete) {
|
||||
() => allHandled.value && anySelected.value,
|
||||
async (nowReady, wasReady) => {
|
||||
if (nowReady && !wasReady) {
|
||||
await nextTick();
|
||||
await handleContinue();
|
||||
}
|
||||
@@ -199,7 +216,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
const firstIncomplete = props.credentialRequests.findIndex(
|
||||
(r) => !isStepComplete(r.credentialType),
|
||||
(r) => !isStepHandled(r.credentialType),
|
||||
);
|
||||
if (firstIncomplete > 0) {
|
||||
goToStep(firstIncomplete);
|
||||
@@ -305,6 +322,7 @@ function onCredentialSelected(
|
||||
const credentialId = typeof credentialData === 'string' ? undefined : credentialData?.id;
|
||||
if (credentialId) {
|
||||
selections.value[credentialType] = credentialId;
|
||||
skippedTypes.value.delete(credentialType);
|
||||
} else {
|
||||
selections.value[credentialType] = null;
|
||||
}
|
||||
@@ -335,6 +353,10 @@ function trackCredentialInput() {
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
// Guards against a double submit when a per-step skip in handleLater and
|
||||
// the allHandled/anySelected watcher both become ready from the same tick.
|
||||
if (isSubmitted.value) return;
|
||||
|
||||
const credentials: Record<string, string> = {};
|
||||
for (const [type, id] of Object.entries(selections.value)) {
|
||||
if (id) credentials[type] = id;
|
||||
@@ -355,12 +377,8 @@ async function handleContinue() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLater() {
|
||||
trackCredentialInput();
|
||||
if (showSetupChoice.value) {
|
||||
trackSetupChoiceClicked('skip');
|
||||
}
|
||||
|
||||
/** Whole-card deferral: every step is left unresolved and the card resolves as deferred. */
|
||||
async function deferWholeCard() {
|
||||
isSubmitted.value = true;
|
||||
isDeferred.value = true;
|
||||
|
||||
@@ -376,6 +394,47 @@ async function handleLater() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLater() {
|
||||
// Finalize (workflow-setup) keeps "do it all later" as a single whole-card
|
||||
// deferral — unlike the generic stage, there's no per-step wizard to skip
|
||||
// through individually.
|
||||
if (isFinalize.value) {
|
||||
trackCredentialInput();
|
||||
if (showSetupChoice.value) {
|
||||
trackSetupChoiceClicked('skip');
|
||||
}
|
||||
await deferWholeCard();
|
||||
return;
|
||||
}
|
||||
|
||||
if (showSetupChoice.value) {
|
||||
trackSetupChoiceClicked('skip');
|
||||
}
|
||||
|
||||
const req = currentRequest.value;
|
||||
if (req) {
|
||||
skippedTypes.value.add(req.credentialType);
|
||||
selections.value[req.credentialType] = null;
|
||||
}
|
||||
|
||||
const nextUnhandled = props.credentialRequests.findIndex((r) => !isStepHandled(r.credentialType));
|
||||
if (nextUnhandled >= 0) {
|
||||
userNavigated.value = false;
|
||||
goToStep(nextUnhandled);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every step is now handled: submit the mixed selected/skipped result if
|
||||
// anything was selected, otherwise defer the whole card as before.
|
||||
if (anySelected.value) {
|
||||
await handleContinue();
|
||||
return;
|
||||
}
|
||||
|
||||
trackCredentialInput();
|
||||
await deferWholeCard();
|
||||
}
|
||||
|
||||
function trackSetupChoiceClicked(choice: CredentialSetupChoice | 'skip') {
|
||||
telemetry.track('Instance AI Browser Use User clicked credential setup option', {
|
||||
credential_type: currentRequest.value?.credentialType,
|
||||
@@ -600,13 +659,7 @@ async function handleSetupAutomatically() {
|
||||
</template>
|
||||
<template v-else>
|
||||
<N8nIcon icon="check" size="small" :class="$style.successIcon" />
|
||||
<span>{{
|
||||
i18n.baseText(
|
||||
isFinalize
|
||||
? 'instanceAi.credential.finalize.applied'
|
||||
: 'instanceAi.credential.allSelected',
|
||||
)
|
||||
}}</span>
|
||||
<span>{{ i18n.baseText(submittedLabelKey) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { IconName } from '@n8n/design-system';
|
||||
import { agentsEventBus } from '@/features/agents/agents.eventBus';
|
||||
import {
|
||||
getLatestBuildResult,
|
||||
getLatestBuilderTarget,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
getLatestWorkflowUpdateResult,
|
||||
getLatestDataTableResult,
|
||||
getLatestDeletedDataTableId,
|
||||
getLatestAgentArtifactResult,
|
||||
getLatestAgentConfigMutation,
|
||||
getLatestAgentBuilderTarget,
|
||||
getExecutionResultsByWorkflow,
|
||||
type ExecutionResult,
|
||||
@@ -82,16 +83,6 @@ export function useCanvasPreview({ thread }: UseCanvasPreviewOptions) {
|
||||
return tab?.type === 'agent' ? (tab.projectId ?? null) : null;
|
||||
});
|
||||
|
||||
const activeAgentTarget = computed(() => {
|
||||
const agentId = activeAgentId.value;
|
||||
if (!agentId) return undefined;
|
||||
const projectId = activeAgentProjectId.value;
|
||||
return {
|
||||
agentId,
|
||||
...(projectId ? { projectId } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const executionResultsByWorkflow = computed(() => {
|
||||
const results = new Map<string, ExecutionResult>();
|
||||
for (const message of thread.messages) {
|
||||
@@ -109,7 +100,6 @@ export function useCanvasPreview({ thread }: UseCanvasPreviewOptions) {
|
||||
});
|
||||
|
||||
const dataTableRefreshKey = ref(0);
|
||||
const agentRefreshKey = ref(0);
|
||||
|
||||
const isPreviewVisible = computed(() => isPreviewOpen.value && activeTabId.value !== undefined);
|
||||
|
||||
@@ -268,8 +258,7 @@ export function useCanvasPreview({ thread }: UseCanvasPreviewOptions) {
|
||||
// Mirrors the workflow-builder spawn-open above. The builder node id is
|
||||
// stable per target agent (`agent-builder:<id>`), so this opens once per
|
||||
// target per thread — later spawns for the same agent intentionally don't
|
||||
// re-yank the view. Doesn't bump agentRefreshKey — the artifact-result
|
||||
// watch below owns refreshes.
|
||||
// re-yank the view. Config refreshes are driven by the agents event bus.
|
||||
|
||||
const latestAgentBuilderTarget = computed(() => {
|
||||
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
||||
@@ -404,13 +393,16 @@ export function useCanvasPreview({ thread }: UseCanvasPreviewOptions) {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Auto-open / refresh agent preview when AI creates or mutates an agent ---
|
||||
// --- Signal persisted builder config mutations onto the agents event bus ---
|
||||
// Every successful config-mutating builder tool call (stamped configMutated
|
||||
// by the backend) notifies any mounted AgentBuilderView for that agent —
|
||||
// the artifact panel, or a full-page builder in another route.
|
||||
|
||||
const latestAgentArtifactResult = computed(() => {
|
||||
const latestAgentConfigMutation = computed(() => {
|
||||
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
||||
const msg = thread.messages[i];
|
||||
if (msg.agentTree) {
|
||||
const result = getLatestAgentArtifactResult(msg.agentTree, activeAgentTarget.value);
|
||||
const result = getLatestAgentConfigMutation(msg.agentTree);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
@@ -418,22 +410,14 @@ export function useCanvasPreview({ thread }: UseCanvasPreviewOptions) {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => latestAgentArtifactResult.value?.toolCallId,
|
||||
() => latestAgentConfigMutation.value?.toolCallId,
|
||||
(toolCallId) => {
|
||||
if (!toolCallId || !latestAgentArtifactResult.value) return;
|
||||
if (!toolCallId || !latestAgentConfigMutation.value) return;
|
||||
if (thread.isHydratingThread) return;
|
||||
|
||||
const targetId = latestAgentArtifactResult.value.agentId;
|
||||
if (latestAgentArtifactResult.value.kind === 'created') {
|
||||
activeTabId.value = targetId;
|
||||
isPreviewOpen.value = true;
|
||||
agentRefreshKey.value++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTabId.value === targetId) {
|
||||
agentRefreshKey.value++;
|
||||
}
|
||||
agentsEventBus.emit('agentUpdated', {
|
||||
agentId: latestAgentConfigMutation.value.agentId,
|
||||
source: 'instance-ai',
|
||||
});
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
@@ -448,7 +432,6 @@ export function useCanvasPreview({ thread }: UseCanvasPreviewOptions) {
|
||||
activeAgentProjectId,
|
||||
activeWorkflowExecutionResult,
|
||||
dataTableRefreshKey,
|
||||
agentRefreshKey,
|
||||
isPreviewVisible,
|
||||
workflowRefreshKey,
|
||||
selectTab,
|
||||
|
||||
Reference in New Issue
Block a user