fix: Move agent types to api-types package (no-changelog) (#30484)

This commit is contained in:
yehorkardash 2026-05-15 14:15:07 +03:00 committed by GitHub
parent 4336fffab8
commit 148bc89be9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 1058 additions and 1265 deletions

View File

@ -1,321 +0,0 @@
import {
CHAT_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
} from 'n8n-workflow';
import { z } from 'zod';
import type { AgentIntegrationSettings } from './dto/agents/agent-integration.dto';
/**
* Describes a chat platform integration that agents can connect to.
* Source of truth: the backend `ChatIntegrationRegistry`.
*/
export interface ChatIntegrationDescriptor {
type: string;
label: string;
icon: string;
credentialTypes: string[];
}
/**
* Node types a workflow can use as its trigger to be eligible as an agent
* tool. Single source of truth for both the backend compatibility check
* (`workflow-tool-factory.ts:SUPPORTED_TRIGGERS`) and the frontend Available
* list's pre-filter. Body-node incompatibility (Wait / RespondToWebhook) is
* enforced separately at save time.
*/
export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [
MANUAL_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
] as const;
/**
* Node types in a workflow's body that disqualify it from being used as an
* agent tool (execution model can't handle pause/respond-style nodes). Single
* source of truth for the backend `validateCompatibility` check in
* `workflow-tool-factory.ts` and the frontend pre-check in
* `AgentToolsModal.vue` so the two sides can't drift.
*/
export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [
'n8n-nodes-base.wait',
'n8n-nodes-base.form',
'n8n-nodes-base.respondToWebhook',
] as const;
export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule';
/**
* Source string recorded on agent executions invoked from a workflow via the
* MessageAnAgent node. Mirrors the pattern set by chat/slack/schedule sources
* so the session detail view can attribute thread origin uniformly.
*/
export const AGENT_WORKFLOW_TRIGGER_TYPE = 'workflow';
export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT =
'Automated message: you were triggered on schedule.';
export interface AgentCredentialIntegration {
type: string;
credentialId: string;
credentialName: string;
settings?: AgentIntegrationSettings;
}
export interface AgentScheduleIntegration {
type: typeof AGENT_SCHEDULE_TRIGGER_TYPE;
active: boolean;
cronExpression: string;
wakeUpPrompt: string;
}
export type AgentIntegration = AgentCredentialIntegration | AgentScheduleIntegration;
export interface AgentScheduleConfig {
active: boolean;
cronExpression: string;
wakeUpPrompt: string;
}
export interface AgentIntegrationStatusEntry {
type: string;
credentialId?: string;
settings?: AgentIntegrationSettings;
}
export interface AgentIntegrationStatusResponse {
status: 'connected' | 'disconnected';
integrations: AgentIntegrationStatusEntry[];
}
export function isAgentScheduleIntegration(
integration: AgentIntegration | null | undefined,
): integration is AgentScheduleIntegration {
return integration?.type === AGENT_SCHEDULE_TRIGGER_TYPE;
}
export function isAgentCredentialIntegration(
integration: AgentIntegration | null | undefined,
): integration is AgentCredentialIntegration {
return (
integration !== null &&
integration !== undefined &&
integration.type !== AGENT_SCHEDULE_TRIGGER_TYPE &&
'credentialId' in integration &&
typeof integration.credentialId === 'string'
);
}
export interface NodeToolConfig {
nodeType: string;
nodeTypeVersion: number;
nodeParameters?: Record<string, unknown>;
credentials?: Record<string, { id: string; name: string }>;
}
interface BaseAgentJsonToolRef {
name?: string;
description?: string;
workflow?: string;
node?: NodeToolConfig;
requireApproval?: boolean;
allOutputs?: boolean;
}
export type AgentJsonToolRef =
| (BaseAgentJsonToolRef & {
type: 'custom';
id: string;
})
| (BaseAgentJsonToolRef & {
type: 'workflow';
id?: never;
})
| (BaseAgentJsonToolRef & {
type: 'node';
id?: never;
});
export interface AgentJsonSkillRef {
type: 'skill';
id: string;
}
export type AgentJsonConfigRef = AgentJsonToolRef | AgentJsonSkillRef;
export interface AgentSkill {
name: string;
description: string;
instructions: string;
}
export interface AgentSkillMutationResponse {
id: string;
skill: AgentSkill;
versionId: string | null;
}
export interface AgentJsonConfig {
name: string;
description?: string;
/** Optional icon/emoji shown in the agent builder header. */
icon?: { type: 'icon' | 'emoji'; value: string };
model: string;
credential?: string;
instructions: string;
memory?: {
enabled: boolean;
storage: 'n8n' | 'sqlite' | 'postgres';
connection?: Record<string, unknown>;
lastMessages?: number;
semanticRecall?: {
topK: number;
scope?: 'thread' | 'resource';
messageRange?: { before: number; after: number };
embedder?: string;
};
};
tools?: AgentJsonToolRef[];
skills?: AgentJsonSkillRef[];
providerTools?: Record<string, Record<string, unknown>>;
/**
* Triggers (scheduled execution + chat integrations) attached to this agent.
* Mirrors the contents of `agent.integrations` storage column so the builder
* can read and modify triggers through the same JSON config flow as tools.
*/
integrations?: AgentIntegration[];
config?: {
thinking?: {
provider: 'anthropic' | 'openai';
budgetTokens?: number;
reasoningEffort?: string;
};
toolCallConcurrency?: number;
nodeTools?: {
enabled: boolean;
};
};
}
/**
* The snapshot of an agent at publish time. Returned by publish/unpublish
* endpoints as part of the agent payload so the UI can derive publish state
* (`not-published` / `published-no-changes` / `published-with-changes`) from
* `agent.versionId` vs `publishedVersion.publishedFromVersionId`.
*/
export interface AgentPublishedVersionDto {
schema: AgentJsonConfig | null;
skills: Record<string, AgentSkill> | null;
publishedFromVersionId: string;
model: string | null;
provider: string | null;
credentialId: string | null;
publishedById: string | null;
}
/**
* A single part inside a persisted chat/builder message. Mirrors the content
* parts emitted by the agents SDK; known `type` values are enumerated for
* autocomplete but the field is left open because new SDK versions may
* introduce additional kinds.
*/
export interface AgentPersistedMessageContentPart {
type: 'text' | 'reasoning' | 'tool-call' | (string & {});
text?: string;
toolName?: string;
toolCallId?: string;
input?: unknown;
state?: string;
output?: unknown;
error?: string;
}
/**
* Persisted chat/builder message shape returned by
* `GET /projects/:projectId/agents/v2/:agentId/chat/messages` and
* `GET /projects/:projectId/agents/v2/:agentId/build/messages`. The UI
* converts these into its own display-oriented representation.
*
* Distinct from the request-body `AgentChatMessageDto` (a single outbound
* message) this is the history shape, one entry per persisted turn.
*/
export interface AgentPersistedMessageDto {
id: string;
role: 'user' | 'assistant' | (string & {});
content: AgentPersistedMessageContentPart[];
}
// ─── Agent builder admin settings ─────────────────────────────────────────
// The agent builder uses a model picked by the instance admin. By default it
// runs through the n8n AI assistant proxy; admins can switch to a custom
// provider + credential at any time.
/** Default model name used when the builder runs through the proxy or the env-var backstop. */
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-5' as const;
export const agentBuilderModeSchema = z.enum(['default', 'custom']);
export type AgentBuilderMode = z.infer<typeof agentBuilderModeSchema>;
/**
* Discriminated union of the persisted admin settings.
*
* The builder defaults to the n8n AI assistant proxy. An admin can switch to
* a custom provider/credential at any time. Provider id values must come from
* the agent runtime's supported list (see `mapCredentialForProvider` on the
* backend) the schema accepts any non-empty string here so the api-types
* package doesn't need to know the runtime list; the backend validates the
* provider against the runtime mapper.
*/
export const agentBuilderAdminSettingsSchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('default') }),
z.object({
mode: z.literal('custom'),
provider: z.string().min(1),
credentialId: z.string().min(1),
modelName: z.string().min(1),
}),
]);
export type AgentBuilderAdminSettings = z.infer<typeof agentBuilderAdminSettingsSchema>;
export const agentBuilderAdminSettingsResponseSchema = z.object({
settings: agentBuilderAdminSettingsSchema,
isConfigured: z.boolean(),
});
export type AgentBuilderAdminSettingsResponse = z.infer<
typeof agentBuilderAdminSettingsResponseSchema
>;
/** Body schema for the PATCH /agent-builder/settings endpoint. */
export const AgentBuilderAdminSettingsUpdateDto = agentBuilderAdminSettingsSchema;
export type AgentBuilderAdminSettingsUpdateRequest = AgentBuilderAdminSettings;
export const agentBuilderStatusResponseSchema = z.object({
isConfigured: z.boolean(),
});
export type AgentBuilderStatusResponse = z.infer<typeof agentBuilderStatusResponseSchema>;
/**
* One still-open interactive tool call, surfaced alongside persisted messages
* so the FE can re-attach a `runId` to suspended interactive cards after a
* page refresh.
*/
export interface AgentBuilderOpenSuspension {
toolCallId: string;
runId: string;
}
/**
* Response body of `GET /projects/:projectId/agents/v2/:agentId/build/messages`.
*
* `messages` is the merged history (persisted memory + any in-flight checkpoint
* messages). `openSuspensions` carries the runIds for every still-open
* interactive tool call so the FE can resume them.
*/
export interface AgentBuilderMessagesResponse {
messages: AgentPersistedMessageDto[];
openSuspensions: AgentBuilderOpenSuspension[];
}

View File

@ -1,4 +1,4 @@
import { AgentIntegrationSchema } from '../json-config/integration-config';
import { AgentIntegrationSchema } from '../agent-integration.schema';
describe('AgentIntegrationSchema', () => {
it('accepts a schedule integration', () => {
@ -11,21 +11,21 @@ describe('AgentIntegrationSchema', () => {
expect(result.success).toBe(true);
});
it('accepts a chat integration with credential name', () => {
it('accepts a telegram integration with credential id', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'slack',
type: 'telegram',
credentialId: 'cred-123',
credentialName: 'Acme Slack',
settings: { accessMode: 'private', allowedUsers: ['123'] },
});
expect(result.success).toBe(true);
});
it('rejects a chat integration missing credentialName', () => {
it('accepts a chat integration with credential id', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'slack',
credentialId: 'cred-123',
});
expect(result.success).toBe(false);
expect(result.success).toBe(true);
});
it('rejects a schedule integration missing cronExpression', () => {
@ -41,7 +41,15 @@ describe('AgentIntegrationSchema', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'schedule',
credentialId: 'cred-123',
credentialName: 'Acme',
});
expect(result.success).toBe(false);
});
it('rejects Telegram private settings without allowed users', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'telegram',
credentialId: 'cred-telegram',
settings: { accessMode: 'private', allowedUsers: [] },
});
expect(result.success).toBe(false);
});
@ -57,16 +65,13 @@ describe('AgentIntegrationSchema', () => {
expect(result.success).toBe(false);
});
it('rejects a schedule integration whose cronExpression is malformed', () => {
const malformed = ['not-a-cron', '* * *', '99 99 * * *'];
for (const cron of malformed) {
const result = AgentIntegrationSchema.safeParse({
type: 'schedule',
active: false,
cronExpression: cron,
wakeUpPrompt: 'go',
});
expect(result.success).toBe(false);
}
it('rejects an empty cronExpression', () => {
const result = AgentIntegrationSchema.safeParse({
type: 'schedule',
active: false,
cronExpression: '',
wakeUpPrompt: 'go',
});
expect(result.success).toBe(false);
});
});

View File

@ -1,5 +1,4 @@
import { CreateAgentSkillDto } from '../create-agent-skill.dto';
import { UpdateAgentSkillDto } from '../update-agent-skill.dto';
import { CreateAgentSkillDto, UpdateAgentSkillDto } from '../dto';
describe('agent skill DTOs', () => {
const validSkill = {

View File

@ -0,0 +1,109 @@
import { z } from 'zod';
const createCredIntegrationSchema = <
Value extends string,
Settings extends z.ZodTypeAny | z.ZodEffects<z.ZodTypeAny>,
>(
typeName: Value,
settingsSchema: Settings,
) =>
z.object({
type: z.literal<Value>(typeName),
credentialId: z.string().min(1),
settings: settingsSchema,
});
const createSimpleIntegrationSchema = <Value extends string>(typeName: Value) =>
z.object({
type: z.literal<Value>(typeName),
credentialId: z.string().min(1),
});
export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const;
export const AgentTelegramSettingsSchema = z
.object({
accessMode: z.enum(AGENT_TELEGRAM_ACCESS_MODES),
allowedUsers: z
.array(
z
.string()
.trim()
.regex(
/^@?[a-zA-Z0-9_]+$/,
'Enter a valid Telegram user ID (numbers only) or username (letters, numbers, underscores)',
),
)
.default([])
.transform((items) => [...new Set(items)]),
})
.strict()
.superRefine((settings, ctx) => {
if (settings.accessMode === 'private' && settings.allowedUsers.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['allowedUsers'],
message: 'Add at least one Telegram user ID or username',
});
}
});
export type AgentTelegramIntegrationSettings = z.infer<typeof AgentTelegramSettingsSchema>;
export const AgentIntegrationSettingsSchema = z.union([AgentTelegramSettingsSchema, z.undefined()]);
export type AgentIntegrationSettings = z.infer<typeof AgentIntegrationSettingsSchema>;
export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule';
export const AgentScheduleIntegrationSchema = z
.object({
type: z.literal(AGENT_SCHEDULE_TRIGGER_TYPE),
active: z.boolean(),
cronExpression: z.string().min(1, 'cronExpression is required'),
wakeUpPrompt: z.string().min(1, 'wakeUpPrompt is required'),
})
.strict();
const credentialIntegrations = [
createCredIntegrationSchema('telegram', AgentTelegramSettingsSchema).extend({
// keep optional for older agents
settings: AgentTelegramSettingsSchema.optional(),
}),
createSimpleIntegrationSchema('slack'),
createSimpleIntegrationSchema('linear'),
] as const;
export const AgentCredentialIntegrationSchema = z.discriminatedUnion(
'type',
credentialIntegrations,
);
export const AgentIntegrationSchema = z.discriminatedUnion('type', [
...credentialIntegrations,
AgentScheduleIntegrationSchema,
]);
export type AgentIntegrationConfig = z.infer<typeof AgentIntegrationSchema>;
export type AgentScheduleIntegrationConfig = z.infer<typeof AgentScheduleIntegrationSchema>;
export type AgentCredentialIntegrationConfig = Exclude<
AgentIntegrationConfig,
{ type: typeof AGENT_SCHEDULE_TRIGGER_TYPE }
>;
export type AgentScheduleIntegration = AgentScheduleIntegrationConfig;
export type AgentCredentialIntegrationDto = AgentCredentialIntegrationConfig;
export type AgentIntegration = AgentIntegrationConfig;
export function isAgentScheduleIntegration(
integration: AgentIntegrationConfig | null | undefined,
): integration is AgentScheduleIntegrationConfig {
return integration?.type === AGENT_SCHEDULE_TRIGGER_TYPE;
}
export function isAgentCredentialIntegration(
integration: AgentIntegrationConfig | null | undefined,
): integration is AgentCredentialIntegrationConfig {
return (
integration !== null && integration !== undefined && !isAgentScheduleIntegration(integration)
);
}

View File

@ -1,6 +1,6 @@
import { z, type ZodError } from 'zod';
import { AgentIntegrationSchema } from './integration-config';
import { AgentIntegrationSchema } from './agent-integration.schema';
const SemanticRecallSchema = z.object({
topK: z.number().int().min(1).max(100),
@ -14,7 +14,6 @@ const SemanticRecallSchema = z.object({
embedder: z.string().optional(),
});
// TODO: Create a list of all supported memory storages, define connection params for each storage
const MemoryConfigSchema = z.object({
enabled: z.boolean(),
storage: z.enum(['n8n', 'sqlite', 'postgres']),
@ -132,9 +131,12 @@ export const AgentJsonConfigPartialSchema = AgentJsonConfigSchema.partial();
export type AgentJsonConfig = z.infer<typeof AgentJsonConfigSchema>;
export type AgentJsonToolConfig = z.infer<typeof AgentJsonToolConfigSchema>;
export type AgentJsonWorkflowToolConfig = Extract<AgentJsonToolConfig, { type: 'workflow' }>;
export type AgentJsonNodeToolConfig = Extract<AgentJsonToolConfig, { type: 'node' }>;
export type AgentJsonCustomToolConfig = Extract<AgentJsonToolConfig, { type: 'custom' }>;
export type AgentJsonSkillConfig = z.infer<typeof AgentJsonSkillConfigSchema>;
export type AgentJsonConfigRef = AgentJsonToolConfig | AgentJsonSkillConfig;
export type AgentJsonMemoryConfig = z.infer<typeof MemoryConfigSchema>;
export type NodeToolConfig = z.infer<typeof NodeConfigSchema>;
export interface ConfigValidationError {
path: string;
@ -163,13 +165,6 @@ export function formatZodErrors(error: ZodError): ConfigValidationError[] {
}));
}
/**
* Returns whether the built-in node tool chain (search_nodes, get_node_types,
* list_credentials, run_node_tool) should be attached to an agent runtime.
*
* Absent or partial config defaults to disabled only an explicit
* `nodeTools: { enabled: true }` opts an agent in.
*/
export function isNodeToolsEnabled(config: AgentJsonConfig['config']): boolean {
return config?.nodeTools?.enabled === true;
}

View File

@ -0,0 +1,57 @@
import { z } from 'zod';
import { interactiveResumeDataSchema } from '../agent-builder-interactive';
import { Z } from '../zod-class';
export class CreateAgentDto extends Z.class({
name: z.string().min(1),
}) {}
export class UpdateAgentDto extends Z.class({
name: z.string().optional(),
updatedAt: z.string().optional(),
description: z.string().optional(),
}) {}
export class UpdateAgentConfigDto extends Z.class({
config: z.record(z.unknown()),
}) {}
export class UpdateAgentScheduleDto extends Z.class({
cronExpression: z.string(),
wakeUpPrompt: z.string().optional(),
}) {}
export const AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH = 10_000;
export const agentSkillSchema = z.object({
name: z.string().min(1).max(128),
description: z.string().min(1).max(512),
instructions: z.string().min(1).max(AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH),
});
export class CreateAgentSkillDto extends Z.class({
...agentSkillSchema.shape,
}) {}
export class UpdateAgentSkillDto extends Z.class({
name: agentSkillSchema.shape.name.optional(),
description: agentSkillSchema.shape.description.optional(),
instructions: agentSkillSchema.shape.instructions.optional(),
}) {}
export class AgentChatMessageDto extends Z.class({
message: z.string().min(1),
sessionId: z.string().min(1).optional(),
}) {}
export class AgentBuildResumeDto extends Z.class({
runId: z.string().min(1),
toolCallId: z.string().min(1),
resumeData: interactiveResumeDataSchema,
}) {}
export class AgentDisconnectIntegrationDto extends Z.class({
type: z.string().min(1),
credentialId: z.string().min(1),
}) {}

View File

@ -0,0 +1,28 @@
export * from './agent-integration.schema';
export * from './agent-json-config.schema';
export * from './dto';
export * from './types';
export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from '../agent-sse';
export {
ASK_LLM_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
interactiveToolNameSchema,
askLlmInputSchema,
askLlmResumeSchema,
askCredentialInputSchema,
askCredentialResumeSchema,
askQuestionOptionSchema,
askQuestionInputSchema,
askQuestionResumeSchema,
interactiveResumeDataSchema,
type InteractiveToolName,
type AskLlmInput,
type AskLlmResume,
type AskCredentialInput,
type AskCredentialResume,
type AskQuestionOption,
type AskQuestionInput,
type AskQuestionResume,
type InteractiveResumeData,
} from '../agent-builder-interactive';

View File

@ -0,0 +1,135 @@
import {
CHAT_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
} from 'n8n-workflow';
import { z } from 'zod';
import type { AgentIntegrationSettings } from './agent-integration.schema';
import type { AgentJsonConfig } from './agent-json-config.schema';
export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [
MANUAL_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
] as const;
export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [
'n8n-nodes-base.wait',
'n8n-nodes-base.form',
'n8n-nodes-base.respondToWebhook',
] as const;
export const AGENT_WORKFLOW_TRIGGER_TYPE = 'workflow';
export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT =
'Automated message: you were triggered on schedule.';
export interface ChatIntegrationDescriptor {
type: string;
label: string;
icon: string;
credentialTypes: string[];
}
export interface AgentScheduleConfig {
active: boolean;
cronExpression: string;
wakeUpPrompt: string;
}
export interface AgentIntegrationStatusEntry {
type: string;
credentialId?: string;
settings?: AgentIntegrationSettings;
}
export interface AgentIntegrationStatusResponse {
status: 'connected' | 'disconnected';
integrations: AgentIntegrationStatusEntry[];
}
export interface AgentSkill {
name: string;
description: string;
instructions: string;
}
export interface AgentSkillMutationResponse {
id: string;
skill: AgentSkill;
versionId: string | null;
}
export interface AgentPublishedVersionDto {
schema: AgentJsonConfig | null;
skills: Record<string, AgentSkill> | null;
publishedFromVersionId: string;
model: string | null;
provider: string | null;
credentialId: string | null;
publishedById: string | null;
}
export interface AgentPersistedMessageContentPart {
type: 'text' | 'reasoning' | 'tool-call' | (string & {});
text?: string;
toolName?: string;
toolCallId?: string;
input?: unknown;
state?: string;
output?: unknown;
error?: string;
}
export interface AgentPersistedMessageDto {
id: string;
role: 'user' | 'assistant' | (string & {});
content: AgentPersistedMessageContentPart[];
}
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-5' as const;
export const agentBuilderModeSchema = z.enum(['default', 'custom']);
export type AgentBuilderMode = z.infer<typeof agentBuilderModeSchema>;
export const agentBuilderAdminSettingsSchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('default') }),
z.object({
mode: z.literal('custom'),
provider: z.string().min(1),
credentialId: z.string().min(1),
modelName: z.string().min(1),
}),
]);
export type AgentBuilderAdminSettings = z.infer<typeof agentBuilderAdminSettingsSchema>;
export const agentBuilderAdminSettingsResponseSchema = z.object({
settings: agentBuilderAdminSettingsSchema,
isConfigured: z.boolean(),
});
export type AgentBuilderAdminSettingsResponse = z.infer<
typeof agentBuilderAdminSettingsResponseSchema
>;
export const AgentBuilderAdminSettingsUpdateDto = agentBuilderAdminSettingsSchema;
export type AgentBuilderAdminSettingsUpdateRequest = AgentBuilderAdminSettings;
export const agentBuilderStatusResponseSchema = z.object({
isConfigured: z.boolean(),
});
export type AgentBuilderStatusResponse = z.infer<typeof agentBuilderStatusResponseSchema>;
export interface AgentBuilderOpenSuspension {
toolCallId: string;
runId: string;
}
export interface AgentBuilderMessagesResponse {
messages: AgentPersistedMessageDto[];
openSuspensions: AgentBuilderOpenSuspension[];
}

View File

@ -1,17 +0,0 @@
import { z } from 'zod';
import { interactiveResumeDataSchema } from '../../agent-builder-interactive';
import { Z } from '../../zod-class';
/**
* Body of `POST /:agentId/build/resume`.
*
* `runId` is sent by the frontend; it originates from the
* `tool-call-suspended` chunk (live) or the `openSuspensions` sidecar
* returned by `GET /build/messages` (history reload).
*/
export class AgentBuildResumeDto extends Z.class({
runId: z.string().min(1),
toolCallId: z.string().min(1),
resumeData: interactiveResumeDataSchema,
}) {}

View File

@ -1,8 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class AgentChatMessageDto extends Z.class({
message: z.string().min(1),
sessionId: z.string().min(1).optional(),
}) {}

View File

@ -1,50 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const;
export const agentTelegramSettingsSchema = z
.object({
accessMode: z.enum(AGENT_TELEGRAM_ACCESS_MODES),
// allowedUsers holds both Telegram user IDs (numeric strings, e.g. "487257961")
// and usernames (alphanumeric + underscore, e.g. "@yokano" or "yokano"). Values
// are stored verbatim — the leading "@" is NOT stripped here so user intent is
// preserved. Normalization (stripping "@") happens only at access-check time in
// TelegramIntegration.isUserAllowed().
allowedUsers: z
.array(
z
.string()
.trim()
.regex(
/^@?[a-zA-Z0-9_]+$/,
'Enter a valid Telegram user ID (numbers only) or username (letters, numbers, underscores)',
),
)
.default([])
.transform((items) => [...new Set(items)]),
})
.strict()
.superRefine((settings, ctx) => {
if (settings.accessMode === 'private' && settings.allowedUsers.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['allowedUsers'],
message: 'Add at least one Telegram user ID or username',
});
}
});
export type AgentTelegramIntegrationSettings = z.infer<typeof agentTelegramSettingsSchema>;
export const agentIntegrationSettingsSchema = z.union([agentTelegramSettingsSchema, z.undefined()]);
export type AgentIntegrationSettings = z.infer<typeof agentIntegrationSettingsSchema>;
// TODO: discriminate settings by type of integration
export class AgentIntegrationDto extends Z.class({
type: z.string().min(1),
credentialId: z.string().min(1),
settings: agentIntegrationSettingsSchema.optional(),
}) {}

View File

@ -1,17 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
/** Hard cap on a skill body. Large enough for serious playbooks, small enough
* to keep a single skill from blowing past the LLM's context window when loaded. */
export const AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH = 10_000;
export const agentSkillSchema = z.object({
name: z.string().min(1).max(128),
description: z.string().min(1).max(512),
instructions: z.string().min(1).max(AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH),
});
export class CreateAgentSkillDto extends Z.class({
...agentSkillSchema.shape,
}) {}

View File

@ -1,7 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class CreateAgentDto extends Z.class({
name: z.string().min(1),
}) {}

View File

@ -1,7 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class UpdateAgentConfigDto extends Z.class({
config: z.record(z.unknown()),
}) {}

View File

@ -1,8 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class UpdateAgentScheduleDto extends Z.class({
cronExpression: z.string(),
wakeUpPrompt: z.string().optional(),
}) {}

View File

@ -1,8 +0,0 @@
import { agentSkillSchema } from './create-agent-skill.dto';
import { Z } from '../../zod-class';
export class UpdateAgentSkillDto extends Z.class({
name: agentSkillSchema.shape.name.optional(),
description: agentSkillSchema.shape.description.optional(),
instructions: agentSkillSchema.shape.instructions.optional(),
}) {}

View File

@ -1,9 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class UpdateAgentDto extends Z.class({
name: z.string().optional(),
updatedAt: z.string().optional(),
description: z.string().optional(),
}) {}

View File

@ -245,27 +245,6 @@ export {
export { VersionSinceDateQueryDto } from './instance-version-history/version-since-date-query.dto';
export { VersionQueryDto } from './instance-version-history/version-query.dto';
export { CreateAgentDto } from './agents/create-agent.dto';
export { UpdateAgentDto } from './agents/update-agent.dto';
export { UpdateAgentConfigDto } from './agents/update-agent-config.dto';
export { UpdateAgentScheduleDto } from './agents/update-agent-schedule.dto';
export {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
CreateAgentSkillDto,
agentSkillSchema,
} from './agents/create-agent-skill.dto';
export { UpdateAgentSkillDto } from './agents/update-agent-skill.dto';
export {
AGENT_TELEGRAM_ACCESS_MODES,
AgentIntegrationDto,
agentIntegrationSettingsSchema,
agentTelegramSettingsSchema,
type AgentIntegrationSettings,
type AgentTelegramIntegrationSettings,
} from './agents/agent-integration.dto';
export { AgentChatMessageDto } from './agents/agent-chat-message.dto';
export { AgentBuildResumeDto } from './agents/agent-build-resume.dto';
export { CreateEncryptionKeyDto } from './encryption/create-encryption-key.dto';
export {
ListEncryptionKeysQueryDto,

View File

@ -9,30 +9,6 @@ export type * from './api-keys';
export type * from './community-node-types';
export type * from './quick-connect';
export * from './agents';
export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from './agent-sse';
export {
ASK_LLM_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
interactiveToolNameSchema,
askLlmInputSchema,
askLlmResumeSchema,
askCredentialInputSchema,
askCredentialResumeSchema,
askQuestionOptionSchema,
askQuestionInputSchema,
askQuestionResumeSchema,
interactiveResumeDataSchema,
type InteractiveToolName,
type AskLlmInput,
type AskLlmResume,
type AskCredentialInput,
type AskCredentialResume,
type AskQuestionOption,
type AskQuestionInput,
type AskQuestionResume,
type InteractiveResumeData,
} from './agent-builder-interactive';
export * from './instance-registry-types';
export * from './redaction-enforcement';
export {

View File

@ -1,6 +1,6 @@
import type { Agent } from '../entities/agent.entity';
import { composeJsonConfig, decomposeJsonConfig } from '../json-config/agent-config-composition';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentJsonConfig } from '@n8n/api-types';
describe('composeJsonConfig', () => {
it('returns the schema with empty integrations when none are stored', () => {
@ -19,11 +19,9 @@ describe('composeJsonConfig', () => {
it('merges integrations from the storage column into the JSON config', () => {
const agent = {
schema: { name: 'A', model: 'anthropic/claude', instructions: 'x' },
integrations: [{ type: 'slack', credentialId: 'c1', credentialName: 'Acme' }],
integrations: [{ type: 'slack', credentialId: 'c1' }],
} as unknown as Agent;
expect(composeJsonConfig(agent)?.integrations).toEqual([
{ type: 'slack', credentialId: 'c1', credentialName: 'Acme' },
]);
expect(composeJsonConfig(agent)?.integrations).toEqual([{ type: 'slack', credentialId: 'c1' }]);
});
it('returns null when schema is null', () => {

View File

@ -1,5 +1,4 @@
import { AgentJsonConfigSchema, isNodeToolsEnabled } from '../json-config/agent-json-config';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import { AgentJsonConfigSchema, isNodeToolsEnabled, type AgentJsonConfig } from '@n8n/api-types';
const baseConfig: AgentJsonConfig = {
name: 'Test Agent',

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import type { AgentIntegration } from '@n8n/api-types';
import type { AgentIntegrationConfig } from '@n8n/api-types';
import { mock } from 'jest-mock-extended';
import { mockEntityManager } from '@test/mocking';
@ -66,23 +66,15 @@ describe('AgentRepository', () => {
});
describe('findByIntegrationCredential', () => {
const makeAgent = (id: string, integrations: AgentIntegration[]) =>
const makeAgent = (id: string, integrations: AgentIntegrationConfig[]) =>
({ id, integrations }) as Agent;
it('returns agents that have a matching type + credentialId, excluding the given agentId', async () => {
const agents = [
makeAgent('agent-self', [
{ type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' },
]),
makeAgent('agent-other', [
{ type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' },
]),
makeAgent('agent-slack', [
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' },
]),
makeAgent('agent-unrelated', [
{ type: 'telegram', credentialId: 'cred-2', credentialName: 'Telegram cred 2' },
]),
makeAgent('agent-self', [{ type: 'telegram', credentialId: 'cred-1' }]),
makeAgent('agent-other', [{ type: 'telegram', credentialId: 'cred-1' }]),
makeAgent('agent-slack', [{ type: 'slack', credentialId: 'cred-1' }]),
makeAgent('agent-unrelated', [{ type: 'telegram', credentialId: 'cred-2' }]),
makeAgent('agent-empty', []),
];
jest.spyOn(repository, 'find').mockResolvedValue(agents);
@ -101,9 +93,7 @@ describe('AgentRepository', () => {
jest
.spyOn(repository, 'find')
.mockResolvedValue([
makeAgent('agent-self', [
{ type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' },
]),
makeAgent('agent-self', [{ type: 'telegram', credentialId: 'cred-1' }]),
]);
const result = await repository.findByIntegrationCredential(
@ -118,9 +108,7 @@ describe('AgentRepository', () => {
it('handles agents whose integrations column is null / undefined without crashing', async () => {
const agents = [
makeAgent('agent-a', [
{ type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' },
]),
makeAgent('agent-a', [{ type: 'telegram', credentialId: 'cred-1' }]),
{ id: 'agent-null', integrations: null } as unknown as Agent,
{ id: 'agent-undef' } as unknown as Agent,
];
@ -146,9 +134,7 @@ describe('AgentRepository', () => {
wakeUpPrompt: 'Automated message',
},
]),
makeAgent('agent-match', [
{ type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' },
]),
makeAgent('agent-match', [{ type: 'telegram', credentialId: 'cred-1' }]),
];
jest.spyOn(repository, 'find').mockResolvedValue(agents);

View File

@ -1,5 +1,5 @@
import type { CredentialProvider } from '@n8n/agents';
import { AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH } from '@n8n/api-types';
import { AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH, type AgentJsonConfig } from '@n8n/api-types';
import type { User, WorkflowRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
@ -12,7 +12,6 @@ import {
import type { BuilderModelLookupService } from '../builder/builder-model-lookup.service';
import { BUILDER_TOOLS } from '../builder/builder-tool-names';
import type { Agent } from '../entities/agent.entity';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
const ctx = {

View File

@ -26,7 +26,7 @@ import { AgentsService } from '../agents.service';
import type { Agent } from '../entities/agent.entity';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import type { N8nMemory } from '../integrations/n8n-memory';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentJsonConfig } from '@n8n/api-types';
import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository';
import type { AgentRepository } from '../repositories/agent.repository';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
@ -71,6 +71,7 @@ function makeService(
{ modules } as unknown as AgentsConfig,
mock(),
mock<Telemetry>(),
mock(),
);
}

View File

@ -22,9 +22,10 @@ import { AgentsService } from '../agents.service';
import type { Agent } from '../entities/agent.entity';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import type { N8nMemory } from '../integrations/n8n-memory';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentJsonConfig } from '@n8n/api-types';
import type { AgentRepository } from '../repositories/agent.repository';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import type { ChatIntegrationService } from '../integrations/chat-integration.service';
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
@ -80,6 +81,7 @@ describe('AgentsService — updateName / updateDescription schema sync', () => {
{ modules: [] } as unknown as AgentsConfig,
mock(),
mock<Telemetry>(),
mock<ChatIntegrationService>(),
);
});

View File

@ -31,18 +31,20 @@ const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, rout
}));
function makeController({
agentsService = mock<AgentsService>(),
credentialsService = mock<CredentialsService>(),
chatIntegrationService = mock<ChatIntegrationService>(),
agentScheduleService = mock<AgentScheduleService>(),
agentRepository = mock<AgentRepository>(),
}: {
agentsService?: jest.Mocked<AgentsService>;
credentialsService?: jest.Mocked<CredentialsService>;
chatIntegrationService?: jest.Mocked<ChatIntegrationService>;
agentScheduleService?: jest.Mocked<AgentScheduleService>;
agentRepository?: jest.Mocked<AgentRepository>;
} = {}) {
const controller = new AgentsController(
mock<AgentsService>(),
agentsService,
mock<AgentsBuilderService>(),
credentialsService,
chatIntegrationService,
@ -54,6 +56,7 @@ function makeController({
return {
controller,
agentsService,
credentialsService,
chatIntegrationService,
agentScheduleService,
@ -129,10 +132,10 @@ describe('AgentsController integration credentials', () => {
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'slack', credentialId: 'cred-outside-project' },
} as never,
undefined as never,
'agent-1',
{ type: 'slack', credentialId: 'cred-outside-project' },
),
).rejects.toThrow(NotFoundError);
@ -151,10 +154,10 @@ describe('AgentsController integration credentials', () => {
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'telegram', credentialId: 'cred-telegram' },
} as never,
undefined as never,
'agent-1',
{ type: 'telegram', credentialId: 'cred-telegram' },
),
).rejects.toThrow(BadRequestError);
@ -185,13 +188,14 @@ describe('AgentsController integration credentials', () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const chatIntegrationService = mock<ChatIntegrationService>();
const agentsService = mock<AgentsService>();
const { controller } = makeController({
agentsService,
credentialsService,
chatIntegrationService,
agentRepository,
});
const settings = {
type: 'telegram' as const,
accessMode: 'private' as const,
allowedUsers: ['123'],
};
@ -201,44 +205,36 @@ describe('AgentsController integration credentials', () => {
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: {
type: 'telegram',
credentialId: 'cred-telegram',
settings,
},
} as never,
undefined as never,
'agent-1',
{ type: 'telegram', credentialId: 'cred-telegram', settings },
),
).resolves.toEqual({ status: 'connected' });
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
'agent-1',
'cred-telegram',
'telegram',
{
type: 'telegram',
credentialId: 'cred-telegram',
settings,
},
'user-1',
'project-1',
{ settings },
);
expect(agentRepository.save).toHaveBeenCalledWith({
...agent,
integrations: [
{
type: 'telegram',
credentialId: 'cred-telegram',
credentialName: 'Telegram Bot',
settings,
},
],
});
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
'agent-1',
'telegram',
'cred-telegram',
'connect',
expect(agentsService.saveCredentialIntegration).toHaveBeenCalledWith(agent, {
type: 'telegram',
credentialId: 'cred-telegram',
settings,
);
});
});
it('returns Telegram integrations from the persisted agent entry even when the live bridge is empty', async () => {
const settings = {
type: 'telegram' as const,
accessMode: 'private' as const,
allowedUsers: ['123'],
};
@ -250,7 +246,6 @@ describe('AgentsController integration credentials', () => {
{
type: 'telegram',
credentialId: 'cred-telegram',
credentialName: 'Telegram Bot',
settings,
},
],
@ -287,7 +282,13 @@ describe('AgentsController integration credentials', () => {
),
).resolves.toEqual({
status: 'connected',
integrations: [{ type: 'telegram', credentialId: 'cred-telegram', settings }],
integrations: [
{
type: 'telegram',
credentialId: 'cred-telegram',
settings,
},
],
});
});
});

View File

@ -1,7 +1,11 @@
/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/unbound-method, id-denylist -- async mock stubs, unbound-method references and short `cb` names are acceptable test idioms */
import type { AgentsConfig, GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegration } from '@n8n/api-types';
import {
DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT,
type AgentIntegrationConfig,
type AgentJsonConfig,
} from '@n8n/api-types';
import { mockLogger } from '@n8n/backend-test-utils';
import { mock } from 'jest-mock-extended';
@ -25,7 +29,6 @@ import {
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import type { N8nMemory } from '../integrations/n8n-memory';
import type { AgentExecutionService } from '../agent-execution.service';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository';
import type { AgentRepository } from '../repositories/agent.repository';
@ -76,6 +79,7 @@ describe('AgentsService', () => {
let n8nCheckpointStorage: jest.Mocked<N8NCheckpointStorage>;
let agentExecutionService: jest.Mocked<AgentExecutionService>;
let scheduleService: jest.Mocked<AgentScheduleService>;
let chatIntegrationService: jest.Mocked<ChatIntegrationService>;
let publisher: jest.Mocked<Publisher>;
let agentsConfig: AgentsConfig;
let globalConfig: jest.Mocked<GlobalConfig>;
@ -90,6 +94,7 @@ describe('AgentsService', () => {
agentExecutionService = mock<AgentExecutionService>();
agentExecutionService.recordMessage.mockResolvedValue('exec-id');
scheduleService = mock<AgentScheduleService>();
chatIntegrationService = mock<ChatIntegrationService>();
publisher = mock<Publisher>();
publisher.publishCommand.mockResolvedValue();
agentsConfig = { modules: [] } as unknown as AgentsConfig;
@ -121,6 +126,7 @@ describe('AgentsService', () => {
agentsConfig,
globalConfig,
mock<Telemetry>(),
chatIntegrationService,
);
});
@ -282,7 +288,6 @@ describe('AgentsService', () => {
const slackIntegration = {
type: 'slack',
credentialId: 'cred-slack',
credentialName: 'Slack workspace',
} as const;
const agent = makeAgent({
integrations: [slackIntegration],
@ -309,7 +314,6 @@ describe('AgentsService', () => {
const slackIntegration = {
type: 'slack',
credentialId: 'cred-slack',
credentialName: 'Slack workspace',
} as const;
const agent = makeAgent({
integrations: [slackIntegration],
@ -445,7 +449,7 @@ describe('AgentsService', () => {
await service.updateConfig(agentId, projectId, minimalUpdate);
const savedEntity = agentRepository.save.mock.calls[0][0] as Agent;
const savedSchema = savedEntity.schema as Record<string, unknown>;
const savedSchema = savedEntity.schema as unknown as Record<string, unknown>;
expect(savedSchema.instructions).toBe('Updated instructions');
expect(savedSchema.description).toBe('previously stored description');
expect(savedSchema.credential).toBe('cred-anthropic');
@ -638,8 +642,8 @@ describe('AgentsService', () => {
});
it('connects persisted credential integrations after publishing', async () => {
const integrations: AgentIntegration[] = [
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' },
const integrations: AgentIntegrationConfig[] = [
{ type: 'slack', credentialId: 'cred-1' },
{
type: 'schedule',
active: false,
@ -662,7 +666,7 @@ describe('AgentsService', () => {
expect(chatIntegrationService.syncToConfig).toHaveBeenCalledWith(
agent,
[],
[{ type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' }],
[{ type: 'slack', credentialId: 'cred-1' }],
);
});
@ -821,7 +825,7 @@ describe('AgentsService', () => {
});
it('deactivates the persisted schedule and stops the local cron job when unpublishing', async () => {
const integrations: AgentIntegration[] = [
const integrations: AgentIntegrationConfig[] = [
{
type: 'schedule',
active: true,
@ -1377,4 +1381,135 @@ describe('AgentsService', () => {
await expect(service.unpublishAgent(agentId, projectId)).resolves.toBeDefined();
});
});
describe('saveCredentialIntegration', () => {
it('appends a new credential integration to an empty list', async () => {
const agent = makeAgent({ integrations: [] });
agentRepository.save.mockImplementation(async (a) => a as Agent);
const integration = {
type: 'slack' as const,
credentialId: 'cred-1',
};
await service.saveCredentialIntegration(agent, integration);
expect(agentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
integrations: [integration],
}),
);
});
it('replaces an existing integration with the same type+credentialId', async () => {
const existing = {
type: 'slack' as const,
credentialId: 'cred-1',
};
const agent = makeAgent({ integrations: [existing] });
agentRepository.save.mockImplementation(async (a) => a as Agent);
const updated = {
type: 'slack' as const,
credentialId: 'cred-1',
};
await service.saveCredentialIntegration(agent, updated);
expect(agentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
integrations: [updated],
}),
);
});
it('preserves schedule integrations when saving credential integrations', async () => {
const schedule = {
type: 'schedule' as const,
active: false,
cronExpression: '0 9 * * *',
wakeUpPrompt: 'wake up',
};
const agent = makeAgent({ integrations: [schedule] });
agentRepository.save.mockImplementation(async (a) => a as Agent);
const slack = {
type: 'slack' as const,
credentialId: 'cred-1',
};
await service.saveCredentialIntegration(agent, slack);
expect(agentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
integrations: [schedule, slack],
}),
);
});
it('rejects an integration missing credentialId', async () => {
const agent = makeAgent({ integrations: [] });
await expect(
service.saveCredentialIntegration(agent, {
type: 'slack',
} as never),
).rejects.toThrow(/Invalid credential integration/);
});
});
describe('removeCredentialIntegration', () => {
it('removes the matching credential integration', async () => {
const slack = {
type: 'slack' as const,
credentialId: 'cred-1',
};
const schedule = {
type: 'schedule' as const,
active: false,
cronExpression: '0 9 * * *',
wakeUpPrompt: 'wake up',
};
const agent = makeAgent({ integrations: [slack, schedule] });
agentRepository.save.mockImplementation(async (a) => a as Agent);
await service.removeCredentialIntegration(agent, 'slack', 'cred-1');
expect(agentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
integrations: [schedule],
}),
);
});
it('no-ops when integration does not exist', async () => {
const agent = makeAgent({ integrations: [] });
const result = await service.removeCredentialIntegration(agent, 'slack', 'cred-1');
expect(agentRepository.save).not.toHaveBeenCalled();
expect(result).toBe(agent);
});
it('preserves other credential integrations', async () => {
const slack = {
type: 'slack' as const,
credentialId: 'cred-1',
};
const linear = {
type: 'linear' as const,
credentialId: 'cred-2',
};
const agent = makeAgent({ integrations: [slack, linear] });
agentRepository.save.mockImplementation(async (a) => a as Agent);
await service.removeCredentialIntegration(agent, 'slack', 'cred-1');
expect(agentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
integrations: [linear],
}),
);
});
});
});

View File

@ -1,11 +1,11 @@
import type { AgentSnapshot, ToolDescriptor } from '@n8n/agents';
import type { JSONSchema7 } from 'json-schema';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import {
AgentJsonConfigSchema,
RunnableAgentJsonConfigSchema,
} from '../json-config/agent-json-config';
type AgentJsonConfig,
} from '@n8n/api-types';
import { buildFromJson } from '../json-config/from-json-config';
import type { ToolExecutor } from '../json-config/from-json-config';
@ -710,7 +710,7 @@ describe('AgentJsonConfigSchema', () => {
cronExpression: '0 0 * * *',
wakeUpPrompt: 'tick',
},
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' },
{ type: 'slack', credentialId: 'cred-1' },
],
};
const parsed = AgentJsonConfigSchema.parse(config);
@ -719,21 +719,9 @@ describe('AgentJsonConfigSchema', () => {
expect(parsed.integrations?.[1]).toMatchObject({
type: 'slack',
credentialId: 'cred-1',
credentialName: 'Acme Slack',
});
});
it('rejects a chat integration missing credentialName at the schema level', () => {
const config = {
name: 'test',
model: 'anthropic/claude-sonnet-4-5',
credential: 'my-key',
instructions: '',
integrations: [{ type: 'slack', credentialId: 'cred-1' }],
};
expect(() => AgentJsonConfigSchema.parse(config)).toThrow();
});
it('validates Telegram private integration settings', () => {
const config = {
name: 'test',
@ -744,7 +732,6 @@ describe('AgentJsonConfigSchema', () => {
{
type: 'telegram',
credentialId: 'cred-1',
credentialName: 'Telegram Bot',
settings: {
accessMode: 'private',
allowedUsers: ['123', '123', '456', 'john_doe123'],
@ -774,7 +761,6 @@ describe('AgentJsonConfigSchema', () => {
{
type: 'telegram',
credentialId: 'cred-1',
credentialName: 'Telegram Bot',
settings: { accessMode: 'private', allowedUsers: [] },
},
],
@ -793,7 +779,6 @@ describe('AgentJsonConfigSchema', () => {
{
type: 'telegram',
credentialId: 'cred-1',
credentialName: 'Telegram Bot',
settings: { accessMode: 'private', allowedUsers: ['user name'] },
},
],
@ -815,7 +800,7 @@ describe('AgentJsonConfigSchema', () => {
cronExpression: '0 0 * * *',
wakeUpPrompt: 'tick',
},
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' },
{ type: 'slack', credentialId: 'cred-1' },
],
};
const parsed = AgentJsonConfigSchema.parse(config);
@ -824,18 +809,6 @@ describe('AgentJsonConfigSchema', () => {
expect(parsed.integrations?.[1]).toMatchObject({
type: 'slack',
credentialId: 'cred-1',
credentialName: 'Acme Slack',
});
});
it('rejects a chat integration missing credentialName at the schema level', () => {
const config = {
name: 'test',
model: 'anthropic/claude-sonnet-4-5',
credential: 'my-key',
instructions: '',
integrations: [{ type: 'slack', credentialId: 'cred-1' }],
};
expect(() => AgentJsonConfigSchema.parse(config)).toThrow();
});
});

View File

@ -1,4 +1,9 @@
import { agentSkillSchema, type AgentSkill, type AgentSkillMutationResponse } from '@n8n/api-types';
import {
agentSkillSchema,
type AgentJsonConfig,
type AgentSkill,
type AgentSkillMutationResponse,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
@ -7,7 +12,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { markAgentDraftDirty } from './utils/agent-draft.utils';
import { Agent } from './entities/agent.entity';
import type { AgentJsonConfig } from './json-config/agent-json-config';
import { AgentRepository } from './repositories/agent.repository';
import { generateAgentResourceId } from './utils/agent-resource-id';

View File

@ -1,24 +1,23 @@
import {
AGENT_SCHEDULE_TRIGGER_TYPE,
type AgentBuilderMessagesResponse,
type AgentCredentialIntegration,
type AgentIntegrationStatusResponse,
type AgentPersistedMessageDto,
type AgentSkill,
type AgentScheduleConfig,
type AgentSseEvent,
type AgentIntegrationSettings,
type ChatIntegrationDescriptor,
AgentBuildResumeDto,
AgentChatMessageDto,
CreateAgentSkillDto,
AgentIntegrationDto,
AgentCredentialIntegrationSchema,
type AgentBuilderMessagesResponse,
type AgentIntegrationStatusResponse,
type AgentPersistedMessageDto,
type AgentScheduleConfig,
type AgentSkill,
type AgentSseEvent,
type ChatIntegrationDescriptor,
CreateAgentDto,
UpdateAgentSkillDto,
UpdateAgentConfigDto,
UpdateAgentScheduleDto,
UpdateAgentDto,
CreateAgentSkillDto,
isAgentCredentialIntegration,
UpdateAgentConfigDto,
UpdateAgentDto,
UpdateAgentScheduleDto,
UpdateAgentSkillDto,
AgentDisconnectIntegrationDto,
} from '@n8n/api-types';
import type { AuthenticatedRequest, User } from '@n8n/db';
import {
@ -107,6 +106,18 @@ export class AgentsController {
private readonly chatIntegrationRegistry: ChatIntegrationRegistry,
) {}
private async validateIntegration(dto: unknown) {
const integrationParseResult = await AgentCredentialIntegrationSchema.safeParseAsync(dto);
if (!integrationParseResult.success) {
throw new BadRequestError(integrationParseResult.error.message);
}
const integration = integrationParseResult.data;
if (integration.type === 'telegram' && !integration.settings) {
throw new BadRequestError('Telegram integration settings are required');
}
return integration;
}
private async withRunnableState(
agent: Agent,
projectId: string,
@ -126,19 +137,6 @@ export class AgentsController {
return Object.assign(agent, { isRunnable: missing.length === 0 });
}
private settingsForConnect(
integrationType: string,
settings: AgentIntegrationSettings | undefined,
): AgentIntegrationSettings | undefined {
if (!settings) {
if (integrationType === 'telegram') {
throw new BadRequestError('Integration settings are required for telegram');
}
return undefined;
}
return settings;
}
@Post('/')
@ProjectScope('agent:create')
async create(
@ -697,10 +695,9 @@ export class AgentsController {
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('agentId') agentId: string,
@Body payload: AgentIntegrationDto,
) {
const { type, credentialId } = payload;
const settings = this.settingsForConnect(type, payload.settings);
const integration = await this.validateIntegration(req.body);
const { credentialId } = integration;
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
if (!agent.publishedVersion)
@ -715,48 +712,9 @@ export class AgentsController {
const credential = usableCredentials.find((c) => c.id === credentialId);
if (!credential) throw new NotFoundError(`Credential "${credentialId}" not found`);
await this.chatIntegrationService.connect(
agentId,
credentialId,
type,
req.user.id,
agent.projectId,
settings ? { settings } : {},
);
await this.chatIntegrationService.connect(agentId, integration, req.user.id, agent.projectId);
// Persist the integration reference on the agent
const existing = agent.integrations ?? [];
const alreadyExists = existing.some(
(i) => isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId,
);
const integration: AgentCredentialIntegration = {
type,
credentialId,
credentialName: credential.name,
...(settings ? { settings } : {}),
};
// Replace existing integration or append a new one
agent.integrations = alreadyExists
? existing.map((existingIntegration) =>
isAgentCredentialIntegration(existingIntegration) &&
existingIntegration.type === type &&
existingIntegration.credentialId === credentialId
? integration
: existingIntegration,
)
: [...existing, integration];
await this.agentRepository.save(agent);
// Notify peer mains so they connect the integration too — without this
// step inbound webhooks load-balanced to a follower would 404.
await this.chatIntegrationService.broadcastIntegrationChange(
agentId,
type,
credentialId,
'connect',
settings,
);
await this.agentsService.saveCredentialIntegration(agent, integration);
return { status: 'connected' };
}
@ -767,26 +725,14 @@ export class AgentsController {
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('agentId') agentId: string,
@Body payload: AgentIntegrationDto,
@Body payload: AgentDisconnectIntegrationDto,
) {
const { type, credentialId } = payload;
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
await this.chatIntegrationService.disconnect(agentId, { type, credentialId });
await this.chatIntegrationService.disconnect(agentId, type, credentialId);
// Remove the integration reference from the agent
agent.integrations = (agent.integrations ?? []).filter(
(i) => !isAgentCredentialIntegration(i) || i.type !== type || i.credentialId !== credentialId,
);
await this.agentRepository.save(agent);
await this.chatIntegrationService.broadcastIntegrationChange(
agentId,
type,
credentialId,
'disconnect',
);
await this.agentsService.removeCredentialIntegration(agent, type, credentialId);
return { status: 'disconnected' };
}
@ -863,7 +809,7 @@ export class AgentsController {
.map((i) => ({
type: i.type,
credentialId: i.credentialId,
...(i.settings ? { settings: i.settings } : {}),
...('settings' in i ? { settings: i.settings } : {}),
}));
const schedule = this.agentScheduleService.getConfig(agent);
const scheduleIntegrations = schedule.active ? [{ type: AGENT_SCHEDULE_TRIGGER_TYPE }] : [];

View File

@ -8,12 +8,21 @@ import type {
import {
AGENT_SCHEDULE_TRIGGER_TYPE,
AGENT_WORKFLOW_TRIGGER_TYPE,
type AgentPersistedMessageDto,
AgentCredentialIntegrationSchema,
AgentJsonConfigSchema,
isAgentCredentialIntegration,
isAgentScheduleIntegration,
isNodeToolsEnabled,
AgentModelSchema,
type AgentCredentialIntegrationConfig,
type AgentIntegrationConfig,
type AgentJsonConfig,
type AgentJsonMemoryConfig,
type AgentJsonToolConfig,
type AgentSkill,
type AgentSkillMutationResponse,
type ChatIntegrationDescriptor,
AgentPersistedMessageDto,
} from '@n8n/api-types';
import * as agents from '@n8n/agents';
import { extractFromAIParameters } from '@n8n/ai-utilities';
@ -21,7 +30,6 @@ import { Logger } from '@n8n/backend-common';
import { AgentsConfig, GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import {
CredentialsRepository,
ExecutionRepository,
In,
ProjectRelationRepository,
@ -68,17 +76,6 @@ import { syncAgentIntegrations } from './integrations/integrations-sync';
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
import { N8nMemory } from './integrations/n8n-memory';
import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition';
import {
AgentJsonConfigSchema,
AgentModelSchema,
isNodeToolsEnabled,
} from './json-config/agent-json-config';
import type {
AgentJsonConfig,
AgentJsonConfigRef,
AgentJsonMemoryConfig,
AgentJsonToolConfig,
} from './json-config/agent-json-config';
import {
buildFromJson,
type MemoryFactory,
@ -88,6 +85,7 @@ import { AgentPublishedVersionRepository } from './repositories/agent-published-
import { AgentRepository } from './repositories/agent.repository';
import { AgentSecureRuntime } from './runtime/agent-secure-runtime';
import { buildToolRegistry, type ToolRegistry } from './tool-registry';
import { ChatIntegrationService } from './integrations/chat-integration.service';
type AgentToolEntries = Agent['tools'];
@ -268,6 +266,7 @@ export class AgentsService {
private readonly agentsConfig: AgentsConfig,
private readonly globalConfig: GlobalConfig,
private readonly telemetry: Telemetry,
private readonly chatIntegrationService: ChatIntegrationService,
) {}
private isNodeToolsModuleEnabled(): boolean {
@ -1329,51 +1328,6 @@ export class AgentsService {
}
}
/**
* Backfill `credentialName` on credential integrations that were created
* before the field was required. Looks up the name by `credentialId` and
* splices it into the config; integrations that already have a name, or
* aren't credential integrations at all, pass through untouched.
*
* If a credential id no longer resolves, the integration is left as-is
* validation will then fail with a clear "credentialName required" error
* pointing at the orphaned integration, which is the correct outcome.
*/
private async healIntegrationCredentialNames(rawConfig: unknown): Promise<unknown> {
if (!rawConfig || typeof rawConfig !== 'object') return rawConfig;
const cfg = rawConfig as { integrations?: unknown };
if (!Array.isArray(cfg.integrations)) return rawConfig;
const missingIds = new Set<string>();
for (const integration of cfg.integrations) {
if (!integration || typeof integration !== 'object') continue;
const i = integration as { credentialId?: unknown; credentialName?: unknown };
if (typeof i.credentialId === 'string' && i.credentialName === undefined) {
missingIds.add(i.credentialId);
}
}
if (missingIds.size === 0) return rawConfig;
const credentials = await Container.get(CredentialsRepository).findBy({
id: In(Array.from(missingIds)),
});
const namesById = new Map(credentials.map((c) => [c.id, c.name]));
const integrations: unknown[] = cfg.integrations;
return {
...cfg,
integrations: integrations.map((integration: unknown): unknown => {
if (!integration || typeof integration !== 'object') return integration;
const i = integration as { credentialId?: unknown; credentialName?: unknown };
if (typeof i.credentialId !== 'string' || i.credentialName !== undefined) {
return integration;
}
const name = namesById.get(i.credentialId);
return name ? { ...integration, credentialName: name } : integration;
}),
};
}
/**
* Persist a new AgentJsonConfig (full replace).
*/
@ -1385,14 +1339,7 @@ export class AgentsService {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
// Repair integrations missing `credentialName`. Agents created before
// the field was added to the schema return integrations of the form
// `{ type, credentialId }` from `findById`; the UI sends them back
// unchanged on save and validation rejects them as invalid. Look the
// names up by id once here so the next save persists the full shape.
const healedConfig = await this.healIntegrationCredentialNames(config);
const result = await this.validateConfig(healedConfig);
const result = await this.validateConfig(config);
if (!result.valid) {
throw new UserError(`Invalid agent config: ${result.error}`);
}
@ -1458,7 +1405,7 @@ export class AgentsService {
if (toolsProvided) {
const referencedIds = new Set(
(result.config.tools ?? [])
.filter((t): t is Extract<AgentJsonConfigRef, { type: 'custom' }> => t.type === 'custom')
.filter((t): t is Extract<AgentJsonToolConfig, { type: 'custom' }> => t.type === 'custom')
.map((t) => t.id),
);
const orphanIds = Object.keys(entity.tools).filter((id) => !referencedIds.has(id));
@ -1498,6 +1445,81 @@ export class AgentsService {
};
}
/**
* Persist a credential integration on the agent after validation.
* Replaces an existing entry with the same type+credentialId or appends a new one.
*/
async saveCredentialIntegration(
agent: Agent,
integration: AgentCredentialIntegrationConfig,
): Promise<Agent> {
const parseResult = AgentCredentialIntegrationSchema.safeParse(integration);
if (!parseResult.success) {
throw new UserError(`Invalid credential integration: ${parseResult.error.message}`);
}
const validated = parseResult.data;
const { type, credentialId } = validated;
const existing = agent.integrations ?? [];
const alreadyExists = existing.some(
(i) => isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId,
);
agent.integrations = alreadyExists
? existing.map((existingIntegration) =>
isAgentCredentialIntegration(existingIntegration) &&
existingIntegration.type === type &&
existingIntegration.credentialId === credentialId
? validated
: existingIntegration,
)
: [...existing, validated];
const result = await this.agentRepository.save(agent);
await this.chatIntegrationService.broadcastIntegrationChange(agent.id, integration, 'connect');
return result;
}
/**
* Remove a credential integration from the agent.
*/
async removeCredentialIntegration(
agent: Agent,
type: string,
credentialId: string,
): Promise<Agent> {
if (!agent.integrations?.length) return agent;
const integration = agent.integrations.find(
(i) => isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId,
);
if (!integration) return agent;
// filter by ref
agent.integrations = agent.integrations.filter((i) => i !== integration);
const result = await this.agentRepository.save(agent);
await this.chatIntegrationService.broadcastIntegrationChange(
agent.id,
integration as AgentCredentialIntegrationConfig,
'disconnect',
);
return result;
}
/**
* Validate and persist the full integrations array on an agent.
* Used internally by updateConfig and exposed for direct schedule/credential writes.
*/
private validateIntegrationRefs(integrations: AgentIntegrationConfig[], agent: Agent): void {
const activeUnpublishedSchedule = integrations.some(
(integration) => isAgentScheduleIntegration(integration) && integration.active,
);
if (activeUnpublishedSchedule && !agent.publishedVersion) {
throw new UserError(
'Invalid agent config: schedule integration cannot be active until the agent is published',
);
}
}
/**
* Validate and persist a custom tool for an agent.
* The tool code is described in an isolate, and the descriptor + code
@ -1585,7 +1607,7 @@ export class AgentsService {
// Remove from config tools array
if (entity.schema?.tools) {
entity.schema.tools = entity.schema.tools.filter(
(t: AgentJsonConfigRef) => !(t.type === 'custom' && 'id' in t && t.id === toolId),
(t: AgentJsonToolConfig) => !(t.type === 'custom' && 'id' in t && t.id === toolId),
);
}
@ -1663,18 +1685,7 @@ export class AgentsService {
);
}
// Mirror AgentScheduleService.activate(): a schedule integration cannot be
// active until the agent has a published version. Otherwise the persisted
// config can claim active=true while the cron registration silently
// refuses to register.
const activeUnpublishedSchedule = (config.integrations ?? []).some(
(integration) => isAgentScheduleIntegration(integration) && integration.active,
);
if (activeUnpublishedSchedule && !entity.publishedVersion) {
throw new UserError(
'Invalid agent config: schedule integration cannot be active until the agent is published',
);
}
this.validateIntegrationRefs(config.integrations ?? [], entity);
}
private getMissingCustomToolIds(
@ -1682,7 +1693,7 @@ export class AgentsService {
tools: AgentToolEntries,
): string[] {
const refs = (config?.tools ?? []).filter(
(ref): ref is Extract<AgentJsonConfigRef, { type: 'custom' }> => ref.type === 'custom',
(ref): ref is Extract<AgentJsonToolConfig, { type: 'custom' }> => ref.type === 'custom',
);
const seen = new Set<string>();
const missing: string[] = [];

View File

@ -2,7 +2,7 @@ import type { JSONSchema7 } from 'json-schema';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { RunnableAgentJsonConfigSchema } from '../json-config/agent-json-config';
import { RunnableAgentJsonConfigSchema } from '@n8n/api-types';
import { jsonSchemaToCompactText } from '../json-config/schema-text-serializer';
const BuilderPromptMemoryConfigSchema = z.object({
@ -354,14 +354,14 @@ Two kinds:
2. **Chat integrations** connect the agent to a messaging platform. Multiple allowed.
Shape:
\`\`\`json
{ "type": "slack", "credentialId": "<id>", "credentialName": "<name>" }
{ "type": "slack", "credentialId": "<id>" }
\`\`\`
### Workflow for adding integrations
1. Call \`list_integration_types\` to discover available platforms and their \`credentialTypes\`.
2. For chat integrations: pick **one** entry from the \`credentialTypes\` array returned by \`list_integration_types\` (prefer the OAuth variant — e.g. \`slackOAuth2Api\` over \`slackApi\`) and pass it to \`ask_credential\` as the singular \`credentialType\` arg. It returns \`{ credentialId, credentialName }\`.
3. Use \`patch_config\` (or \`write_config\`) to add an entry to \`integrations\`. For chat integrations, both \`credentialId\` and \`credentialName\` are required and must come from the \`ask_credential\` result. For schedule, write the cron expression directly.
3. Use \`patch_config\` (or \`write_config\`) to add an entry to \`integrations\`. For chat integrations, only persist \`type\` and \`credentialId\`. For schedule, write the cron expression directly.
Never invent credential IDs or names. Always go through \`ask_credential\`.`;

View File

@ -1,6 +1,13 @@
import { Tool } from '@n8n/agents';
import type { BuiltTool, CredentialProvider } from '@n8n/agents';
import { agentSkillSchema } from '@n8n/api-types';
import {
agentSkillSchema,
formatZodErrors,
RunnableAgentJsonConfigSchema,
tryParseConfigJson,
type AgentJsonConfig,
type ConfigValidationError,
} from '@n8n/api-types';
import type { User } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
@ -11,12 +18,6 @@ import { z } from 'zod';
import { AgentsToolsService } from '../agents-tools.service';
import { AgentsService } from '../agents.service';
import { composeJsonConfig } from '../json-config/agent-config-composition';
import type { AgentJsonConfig, ConfigValidationError } from '../json-config/agent-json-config';
import {
formatZodErrors,
RunnableAgentJsonConfigSchema,
tryParseConfigJson,
} from '../json-config/agent-json-config';
import { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { BuilderModelLookupService } from './builder-model-lookup.service';
import {

View File

@ -14,7 +14,7 @@ import { AgentsService } from '../agents.service';
import { composeJsonConfig } from '../json-config/agent-config-composition';
import { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import { N8nMemory } from '../integrations/n8n-memory';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentJsonConfig } from '@n8n/api-types';
import { AgentCheckpointRepository } from '../repositories/agent-checkpoint.repository';
import { buildBuilderPrompt } from './agents-builder-prompts';
import { AgentsBuilderToolsService, getAgentConfigHash } from './agents-builder-tools.service';

View File

@ -1,5 +1,5 @@
import type { ToolDescriptor } from '@n8n/agents';
import type { AgentSkill } from '@n8n/api-types';
import { type AgentJsonConfig, type AgentSkill } from '@n8n/api-types';
import { JsonColumn, User, WithTimestamps } from '@n8n/db';
import {
Column,
@ -12,7 +12,6 @@ import {
} from '@n8n/typeorm';
import type { Agent } from './agent.entity';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
@Entity({ name: 'agent_published_version' })
export class AgentPublishedVersion extends WithTimestamps {

View File

@ -1,10 +1,9 @@
import type { AgentIntegration, AgentSkill } from '@n8n/api-types';
import type { AgentIntegrationConfig, AgentJsonConfig, AgentSkill } from '@n8n/api-types';
import type { ToolDescriptor } from '@n8n/agents';
import { JsonColumn, Project, WithTimestampsAndStringId } from '@n8n/db';
import { Column, Entity, ManyToOne, JoinColumn, OneToOne, type Relation } from '@n8n/typeorm';
import type { AgentPublishedVersion } from './agent-published-version.entity';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
@Entity({ name: 'agents' })
export class Agent extends WithTimestampsAndStringId {
@ -34,7 +33,7 @@ export class Agent extends WithTimestampsAndStringId {
schema: AgentJsonConfig | null;
@JsonColumn({ default: '[]' })
integrations: AgentIntegration[];
integrations: AgentIntegrationConfig[];
@JsonColumn({ default: '{}' })
tools: Record<

View File

@ -1,9 +1,7 @@
import { agentTelegramSettingsSchema, type AgentIntegrationSettings } from '@n8n/api-types';
import type { StreamChunk } from '@n8n/agents';
import type { Author } from 'chat';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { UnexpectedError, type Logger } from 'n8n-workflow';
import { type Logger } from 'n8n-workflow';
import { AgentChatBridge } from '../agent-chat-bridge';
import {
@ -12,6 +10,7 @@ import {
type AgentChatIntegrationContext,
} from '../agent-chat-integration';
import type { ComponentMapper } from '../component-mapper';
import type { AgentCredentialIntegrationConfig } from '@n8n/api-types';
type ChatBotLike = ConstructorParameters<typeof AgentChatBridge>[0];
@ -93,41 +92,25 @@ class StreamingTestIntegration extends AgentChatIntegration {
}
// TODO: use real Telegram integration for testing
class TelegramTestIntegration extends AgentChatIntegration {
readonly type = 'telegram';
readonly credentialTypes: string[] = [];
readonly supportedComponents: string[] = [];
readonly displayLabel = 'Telegram';
readonly displayIcon = 'telegram';
async createAdapter(_ctx: AgentChatIntegrationContext): Promise<unknown> {
return {};
}
isUserAllowed(author: Author, settings: AgentIntegrationSettings | undefined): boolean {
if (!settings) return true;
const validConfig = agentTelegramSettingsSchema.safeParse(settings);
if (!validConfig.success) {
throw new UnexpectedError(
`Invalid Telegram integration settings: ${validConfig.error.message}`,
);
}
if (settings.accessMode === 'public') return true;
return settings.allowedUsers.some((allowed) => {
const normalized = allowed.startsWith('@') ? allowed.slice(1) : allowed;
return normalized === author.userId || normalized === author.userName;
});
}
}
describe('AgentChatBridge — consumeStream', () => {
let registry: ChatIntegrationRegistry;
const componentMapper = mock<ComponentMapper>();
const logger = mock<Logger>();
const bufferedIntegration = {
type: 'test-buffered',
credentialId: 'cred-1',
} as unknown as AgentCredentialIntegrationConfig;
const streamingIntegration = {
type: 'test-streaming',
credentialId: 'cred-1',
} as unknown as AgentCredentialIntegrationConfig;
beforeEach(() => {
registry = new ChatIntegrationRegistry();
registry.register(new BufferingTestIntegration());
registry.register(new StreamingTestIntegration());
registry.register(new TelegramTestIntegration());
Container.set(ChatIntegrationRegistry, registry);
});
@ -160,7 +143,7 @@ describe('AgentChatBridge — consumeStream', () => {
componentMapper,
logger,
'project-1',
'test-buffered',
bufferedIntegration,
);
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
@ -194,7 +177,7 @@ describe('AgentChatBridge — consumeStream', () => {
componentMapper,
logger,
'project-1',
'test-buffered',
bufferedIntegration,
);
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
@ -220,7 +203,7 @@ describe('AgentChatBridge — consumeStream', () => {
componentMapper,
logger,
'project-1',
'test-buffered',
bufferedIntegration,
);
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
@ -246,7 +229,7 @@ describe('AgentChatBridge — consumeStream', () => {
componentMapper,
logger,
'project-1',
'test-streaming',
streamingIntegration,
);
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
@ -256,126 +239,4 @@ describe('AgentChatBridge — consumeStream', () => {
expect(received).toBe('Hello world');
});
});
describe('Telegram access settings', () => {
it('silently ignores Telegram messages from users outside the private whitelist', async () => {
const { bot, handlers } = makeBot();
const thread = makeThread();
const agentExecutor = {
executeForChatPublished: jest.fn(() =>
toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]),
),
resumeForChat: jest.fn(() => toStream([])),
};
new AgentChatBridge(
bot as unknown as ChatBotLike,
'agent-1',
agentExecutor as never,
componentMapper,
logger,
'project-1',
'telegram',
{ accessMode: 'private', allowedUsers: ['123'] },
);
await handlers.mention!(thread, {
text: 'hi',
author: { userId: '999', userName: 'stranger' },
});
expect(thread.subscribe).not.toHaveBeenCalled();
expect(thread.post).not.toHaveBeenCalled();
expect(agentExecutor.executeForChatPublished).not.toHaveBeenCalled();
});
it('allows Telegram messages from users in the private whitelist', async () => {
const { bot, handlers } = makeBot();
const thread = makeThread();
const agentExecutor = {
executeForChatPublished: jest.fn(() =>
toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]),
),
resumeForChat: jest.fn(() => toStream([])),
};
new AgentChatBridge(
bot as unknown as ChatBotLike,
'agent-1',
agentExecutor as never,
componentMapper,
logger,
'project-1',
'telegram',
{ accessMode: 'private', allowedUsers: ['123'] },
);
await handlers.mention!(thread, {
text: 'hi',
author: { userId: '123', userName: 'alloweduser' },
});
expect(thread.subscribe).toHaveBeenCalledTimes(1);
expect(agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
});
it('allows legacy Telegram integrations without settings', async () => {
const { bot, handlers } = makeBot();
const thread = makeThread();
const agentExecutor = {
executeForChatPublished: jest.fn(() =>
toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]),
),
resumeForChat: jest.fn(() => toStream([])),
};
new AgentChatBridge(
bot as unknown as ChatBotLike,
'agent-1',
agentExecutor as never,
componentMapper,
logger,
'project-1',
'telegram',
);
await handlers.mention!(thread, {
text: 'hi',
author: { userId: '999', userName: 'anyuser' },
});
expect(agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
});
it('silently ignores Telegram actions from users outside the private whitelist', async () => {
const { bot, handlers } = makeBot();
const thread = makeThread();
const agentExecutor = {
executeForChatPublished: jest.fn(() => toStream([])),
resumeForChat: jest.fn(() => toStream([])),
};
new AgentChatBridge(
bot as unknown as ChatBotLike,
'agent-1',
agentExecutor as never,
componentMapper,
logger,
'project-1',
'telegram',
{ accessMode: 'private', allowedUsers: ['123'] },
);
await handlers.action!({
actionId: 'run-1:tool-1',
value: JSON.stringify({ response: 'yes' }),
thread,
threadId: thread.id,
user: { userId: '999', userName: 'stranger' },
});
expect(agentExecutor.resumeForChat).not.toHaveBeenCalled();
expect(thread.post).not.toHaveBeenCalled();
});
});
});

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegration } from '@n8n/api-types';
import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegrationConfig } from '@n8n/api-types';
import { mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
@ -12,7 +12,7 @@ import type { Agent } from '../../entities/agent.entity';
import { AgentScheduleService } from '../agent-schedule.service';
function makePublishedAgent(
integrations: AgentIntegration[] = [],
integrations: AgentIntegrationConfig[] = [],
overrides: Partial<Agent> = {},
): Agent {
return {
@ -72,16 +72,14 @@ describe('AgentScheduleService', () => {
});
it('saveConfig upserts the schedule integration alongside credential-backed integrations', async () => {
const agent = makePublishedAgent([
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' },
]);
const agent = makePublishedAgent([{ type: 'slack', credentialId: 'cred-1' }]);
const result = await service.saveConfig(agent, '* * * * *');
expect(agentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
integrations: [
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' },
{ type: 'slack', credentialId: 'cred-1' },
{
type: 'schedule',
active: false,
@ -116,6 +114,44 @@ describe('AgentScheduleService', () => {
).rejects.toBeInstanceOf(BadRequestError);
});
it('rejects various malformed cron patterns before saving', async () => {
const malformed = ['* * *', '99 99 * * *', 'every-day', '0 0 0 0'];
for (const cron of malformed) {
await expect(service.saveConfig(makePublishedAgent(), cron)).rejects.toBeInstanceOf(
BadRequestError,
);
}
});
it('saveConfig rejects an empty cron when the schedule is active', async () => {
const agent = makePublishedAgent([
{
type: 'schedule',
active: true,
cronExpression: '* * * * *',
wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT,
},
]);
await expect(service.saveConfig(agent, '')).rejects.toBeInstanceOf(BadRequestError);
});
it('saveConfig accepts empty cron when the schedule is inactive', async () => {
const agent = makePublishedAgent([
{
type: 'schedule',
active: false,
cronExpression: '* * * * *',
wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT,
},
]);
agentRepository.save.mockImplementation(async (a: Agent) => a);
const result = await service.saveConfig(agent, '');
expect(result.cronExpression).toBe('');
});
it('activate rejects unpublished agents', async () => {
const agent = makePublishedAgent(
[

View File

@ -1,4 +1,3 @@
import type { AgentCredentialIntegration } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import { mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
@ -17,6 +16,7 @@ import {
type AgentChatIntegrationContext,
} from '../agent-chat-integration';
import { ChatIntegrationService } from '../chat-integration.service';
import type { AgentCredentialIntegrationConfig } from '@n8n/api-types';
/**
* Test double exposes the registry without invoking the real Chat SDK
@ -54,10 +54,9 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
} as unknown as Agent;
}
const slackIntegration: AgentCredentialIntegration = {
const slackIntegration: AgentCredentialIntegrationConfig = {
type: 'slack',
credentialId: 'cred-1',
credentialName: 'Acme Slack',
};
function buildServiceWith(
@ -122,7 +121,7 @@ describe('ChatIntegrationService.syncToConfig — publish gate', () => {
await service.syncToConfig(agent, [slackIntegration], []);
expect(disconnectSpy).toHaveBeenCalledWith('agent-1', 'slack', 'cred-1');
expect(disconnectSpy).toHaveBeenCalledWith('agent-1', slackIntegration);
expect(connectSpy).not.toHaveBeenCalled();
});
});
@ -250,8 +249,8 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
agentRepository.findPublished.mockResolvedValue([
makeAgent({
integrations: [
{ type: 'telegram', credentialId: 'c1', credentialName: 'Tg' },
{ type: 'linear', credentialId: 'c2', credentialName: 'Ln' },
{ type: 'telegram', credentialId: 'c1' },
{ type: 'linear', credentialId: 'c2' },
],
}),
]);
@ -274,9 +273,13 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
// Followers must not run external hooks during startup reconnect — the
// leader owns Telegram setWebhook etc., so a follower racing it would
// just trip Telegram's 1/sec rate limit.
expect(connectSpy).toHaveBeenCalledWith('agent-1', 'c2', 'linear', 'u1', 'project-1', {
skipExternalHooks: true,
});
expect(connectSpy).toHaveBeenCalledWith(
'agent-1',
{ type: 'linear', credentialId: 'c2' },
'u1',
'project-1',
{ skipExternalHooks: true },
);
});
it('connects every integration when this main is the leader and runs external hooks', async () => {
@ -288,8 +291,8 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
agentRepository.findPublished.mockResolvedValue([
makeAgent({
integrations: [
{ type: 'telegram', credentialId: 'c1', credentialName: 'Tg' },
{ type: 'linear', credentialId: 'c2', credentialName: 'Ln' },
{ type: 'telegram', credentialId: 'c1' },
{ type: 'linear', credentialId: 'c2' },
],
}),
]);
@ -310,7 +313,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
expect(connectSpy).toHaveBeenCalledTimes(2);
for (const call of connectSpy.mock.calls) {
expect(call[5]).toEqual({ skipExternalHooks: false });
expect(call[4]).toEqual({ skipExternalHooks: false });
}
});
@ -321,7 +324,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
const agentRepository = mock<AgentRepository>();
agentRepository.findPublished.mockResolvedValue([
makeAgent({
integrations: [{ type: 'linear', credentialId: 'c1', credentialName: 'Ln' }],
integrations: [{ type: 'linear', credentialId: 'c1' }],
}),
]);
@ -384,12 +387,14 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
await service.handleIntegrationChanged({
agentId: 'a1',
type: 'linear',
credentialId: 'c1',
integration: { type: 'linear', credentialId: 'c1' },
action: 'disconnect',
});
expect(disconnectSpy).toHaveBeenCalledWith('a1', 'linear', 'c1');
expect(disconnectSpy).toHaveBeenCalledWith('a1', {
type: 'linear',
credentialId: 'c1',
});
});
it('skips connect for a leader-only integration on a follower', async () => {
@ -402,8 +407,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
await service.handleIntegrationChanged({
agentId: 'a1',
type: 'telegram',
credentialId: 'c1',
integration: { type: 'telegram', credentialId: 'c1' },
action: 'connect',
});
@ -431,17 +435,22 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
await service.handleIntegrationChanged({
agentId: 'a1',
type: 'linear',
credentialId: 'c1',
integration: { type: 'linear', credentialId: 'c1' },
action: 'connect',
});
// External hooks (Telegram setWebhook, DB validation) already ran on
// the originator — the peer must skip them to avoid duplicate API
// calls and the resulting 429 rate-limit failure.
expect(connectSpy).toHaveBeenCalledWith('a1', 'c1', 'linear', 'u1', 'p1', {
skipExternalHooks: true,
});
expect(connectSpy).toHaveBeenCalledWith(
'a1',
{ type: 'linear', credentialId: 'c1' },
'u1',
'p1',
{
skipExternalHooks: true,
},
);
});
it('falls through user list until one succeeds', async () => {
@ -472,15 +481,20 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
await service.handleIntegrationChanged({
agentId: 'a1',
type: 'linear',
credentialId: 'c1',
integration: { type: 'linear', credentialId: 'c1' },
action: 'connect',
});
expect(connectSpy).toHaveBeenCalledTimes(2);
expect(connectSpy).toHaveBeenLastCalledWith('a1', 'c1', 'linear', 'u-with-access', 'p1', {
skipExternalHooks: true,
});
expect(connectSpy).toHaveBeenLastCalledWith(
'a1',
{ type: 'linear', credentialId: 'c1' },
'u-with-access',
'p1',
{
skipExternalHooks: true,
},
);
});
it('no-ops when the agent has been deleted', async () => {
@ -496,8 +510,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
await service.handleIntegrationChanged({
agentId: 'gone',
type: 'linear',
credentialId: 'c1',
integration: { type: 'linear', credentialId: 'c1' },
action: 'connect',
});
@ -518,8 +531,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
await service.handleIntegrationChanged({
agentId: 'a1',
type: 'linear',
credentialId: 'c1',
integration: { type: 'linear', credentialId: 'c1' },
action: 'connect',
});
@ -532,7 +544,11 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
const publisher = mock<Publisher>();
const { service } = buildServiceWith({ multiMainEnabled: false, publisher });
await service.broadcastIntegrationChange('a1', 'linear', 'c1', 'connect');
await service.broadcastIntegrationChange(
'a1',
{ type: 'linear', credentialId: 'c1' },
'connect',
);
expect(publisher.publishCommand).not.toHaveBeenCalled();
});
@ -541,14 +557,17 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
const publisher = mock<Publisher>();
const { service } = buildServiceWith({ multiMainEnabled: true, publisher });
await service.broadcastIntegrationChange('a1', 'linear', 'c1', 'disconnect');
await service.broadcastIntegrationChange(
'a1',
{ type: 'linear', credentialId: 'c1' },
'disconnect',
);
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'agent-chat-integration-changed',
payload: {
agentId: 'a1',
type: 'linear',
credentialId: 'c1',
integration: { type: 'linear', credentialId: 'c1' },
action: 'disconnect',
},
});
@ -557,22 +576,23 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
it('publishes settings alongside a connect broadcast', async () => {
const publisher = mock<Publisher>();
const { service } = buildServiceWith({ multiMainEnabled: true, publisher });
const settings = {
type: 'telegram' as const,
accessMode: 'private' as const,
allowedUsers: ['123'],
const integration: AgentCredentialIntegrationConfig = {
type: 'telegram',
credentialId: 'c1',
settings: {
accessMode: 'private' as const,
allowedUsers: ['123'],
},
};
await service.broadcastIntegrationChange('a1', 'telegram', 'c1', 'connect', settings);
await service.broadcastIntegrationChange('a1', integration, 'connect');
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'agent-chat-integration-changed',
payload: {
agentId: 'a1',
type: 'telegram',
credentialId: 'c1',
integration,
action: 'connect',
settings,
},
});
});
@ -584,7 +604,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
const { service } = buildServiceWith({ multiMainEnabled: true, publisher });
await expect(
service.broadcastIntegrationChange('a1', 'linear', 'c1', 'connect'),
service.broadcastIntegrationChange('a1', { type: 'linear', credentialId: 'c1' }, 'connect'),
).resolves.toBeUndefined();
});
});

View File

@ -1,8 +1,6 @@
import type { AgentMessage, StreamChunk } from '@n8n/agents';
import type { AgentIntegrationSettings } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { ActionEvent, Chat, Message, Thread, Author } from 'chat';
import { UnexpectedError } from 'n8n-workflow';
import type { ActionEvent, Author, Chat, Message, Thread } from 'chat';
import type { Logger } from 'n8n-workflow';
import type { AgentsService } from '../agents.service';
@ -11,7 +9,8 @@ import type { AgentChatIntegration } from './agent-chat-integration';
import { ChatIntegrationRegistry } from './agent-chat-integration';
import { CallbackStore } from './callback-store';
import type { ComponentMapper } from './component-mapper';
import { type TextEndFn, type TextYieldFn, type InternalThread, toInternalThreadId } from './types';
import { type InternalThread, type TextEndFn, type TextYieldFn, toInternalThreadId } from './types';
import type { AgentCredentialIntegrationConfig } from '@n8n/api-types';
interface AgentExecutor {
executeForChatPublished(config: {
@ -63,7 +62,7 @@ export class AgentChatBridge {
private readonly disableStreaming: boolean;
/** Resolved integration for this platform (may be undefined for unknown types). */
private readonly integration: AgentChatIntegration | undefined;
private readonly integrationImpl: AgentChatIntegration | undefined;
/**
* In-flight `rich_interaction` tool inputs keyed by toolCallId. Populated on
@ -86,18 +85,13 @@ export class AgentChatBridge {
private readonly componentMapper: ComponentMapper,
private readonly logger: Logger,
private readonly n8nProjectId: string,
private readonly integrationType: string,
private readonly integrationSettings?: AgentIntegrationSettings,
private readonly integration: AgentCredentialIntegrationConfig,
) {
const integration = Container.get(ChatIntegrationRegistry).get(integrationType);
if (!integration) {
throw new UnexpectedError(`Unknown integration type: ${integrationType}`);
}
this.integration = integration;
if (this.integration?.needsShortCallbackData) {
this.integrationImpl = Container.get(ChatIntegrationRegistry).get(integration.type);
if (this.integrationImpl?.needsShortCallbackData) {
this.callbackStore = new CallbackStore();
}
this.disableStreaming = this.integration?.disableStreaming ?? false;
this.disableStreaming = this.integrationImpl?.disableStreaming ?? false;
this.registerHandlers();
}
@ -112,8 +106,7 @@ export class AgentChatBridge {
componentMapper: ComponentMapper,
logger: Logger,
n8nProjectId: string,
integrationType: string,
integrationSettings?: AgentIntegrationSettings,
integration: AgentCredentialIntegrationConfig,
): AgentChatBridge {
const agentExecutor: AgentExecutor = {
async *executeForChatPublished({ memory, agentId: aid, message, integrationType }) {
@ -136,8 +129,7 @@ export class AgentChatBridge {
componentMapper,
logger,
n8nProjectId,
integrationType,
integrationSettings,
integration,
);
}
@ -181,7 +173,7 @@ export class AgentChatBridge {
}
private canUserAccess(author: Author): boolean {
return this.integration?.isUserAllowed?.(author, this.integrationSettings) ?? true;
return this.integrationImpl?.isUserAllowed?.(author, this.integration) ?? true;
}
// ---------------------------------------------------------------------------
@ -199,7 +191,7 @@ export class AgentChatBridge {
* helper so platform-specific formatting is never accidentally skipped.
*/
private resolveThreadId(thread: Thread<unknown, unknown>) {
return toInternalThreadId(this.integration?.formatThreadId?.fromSdk(thread) ?? thread.id);
return toInternalThreadId(this.integrationImpl?.formatThreadId?.fromSdk(thread) ?? thread.id);
}
/**
@ -235,7 +227,7 @@ export class AgentChatBridge {
projectId: this.n8nProjectId,
message: text,
memory: { threadId, resourceId: message.author.userId },
integrationType: this.integrationType,
integrationType: this.integration.type,
});
await this.consumeStream(stream, thread);
@ -521,7 +513,7 @@ export class AgentChatBridge {
toolCallId,
chunk.resumeSchema,
this.getShortenCallback(),
this.integrationType,
this.integration.type,
);
await thread.post({ card });
} catch (error) {
@ -576,7 +568,7 @@ export class AgentChatBridge {
toolCallId,
riResumeSchema,
this.getShortenCallback(),
this.integrationType,
this.integration.type,
);
await thread.post(card);
} catch (error) {
@ -657,7 +649,7 @@ export class AgentChatBridge {
toolCallId,
displayResumeSchema,
this.getShortenCallback(),
this.integrationType,
this.integration.type,
);
await thread.post({ card });
} catch (error) {
@ -832,7 +824,7 @@ export class AgentChatBridge {
runId,
toolCallId,
resumeData,
integrationType: this.integrationType,
integrationType: this.integration.type,
});
await this.consumeStream(stream, thread as Thread);
} finally {

View File

@ -1,7 +1,7 @@
import type { AgentIntegrationSettings } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { Thread, Author } from 'chat';
import { AgentCredentialIntegrationConfig } from '@n8n/api-types';
import type { SuspendComponent } from './component-mapper';
/** Per-connection context handed to AgentChatIntegration hooks. */
@ -133,7 +133,7 @@ export abstract class AgentChatIntegration {
* Default (no implementation): allow. Telegram uses this to enforce the
* Private-mode allowlist.
*/
isUserAllowed?(author: Author, settings: AgentIntegrationSettings | undefined): boolean;
isUserAllowed?(author: Author, settings: AgentCredentialIntegrationConfig | undefined): boolean;
}
/**

View File

@ -1,9 +1,9 @@
import {
AGENT_SCHEDULE_TRIGGER_TYPE,
DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT,
isAgentScheduleIntegration,
type AgentScheduleConfig,
type AgentScheduleIntegration,
isAgentScheduleIntegration,
} from '@n8n/api-types';
import { ProjectRelationRepository } from '@n8n/db';
import { Logger } from '@n8n/backend-common';

View File

@ -1,8 +1,8 @@
import {
AgentCredentialIntegrationConfig,
isAgentCredentialIntegration,
type AgentCredentialIntegration,
type AgentIntegrationStatusResponse,
type AgentIntegrationSettings,
type AgentIntegrationStatusResponse,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
@ -92,23 +92,19 @@ export class ChatIntegrationService {
*/
async broadcastIntegrationChange(
agentId: string,
type: string,
credentialId: string,
integration: AgentCredentialIntegrationConfig,
action: 'connect' | 'disconnect',
settings?: AgentIntegrationSettings,
): Promise<void> {
if (!this.globalConfig.multiMainSetup.enabled) return;
try {
const payload = settings
? { agentId, type, credentialId, action, settings }
: { agentId, type, credentialId, action };
const payload = { agentId, integration, action };
await this.publisher.publishCommand({
command: 'agent-chat-integration-changed',
payload,
});
} catch (error) {
this.logger.warn(
`[ChatIntegrationService] Failed to publish ${action} for ${type} on agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
`[ChatIntegrationService] Failed to publish ${action} for ${integration.type} on agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@ -139,30 +135,29 @@ export class ChatIntegrationService {
*/
async connect(
agentId: string,
credentialId: string,
integrationType: string,
integration: AgentCredentialIntegrationConfig,
userId: string,
projectId: string,
options: ConnectOptions = {},
): Promise<void> {
const key = this.connectionKey(agentId, integrationType, credentialId);
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
// Tear down existing connection if reconnecting
if (this.connections.has(key)) {
await this.disconnectOne(key);
}
const integration = this.integrationRegistry.require(integrationType);
const integrationImpl = this.integrationRegistry.require(integration.type);
const user = await this.resolveUser(userId);
// Decrypt the integration credential to get platform tokens
const decryptedData = await this.decryptCredential(credentialId, user);
const decryptedData = await this.decryptCredential(integration.credentialId, user);
const ctx: AgentChatIntegrationContext = {
agentId,
projectId,
credentialId,
credentialId: integration.credentialId,
credential: decryptedData,
webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform),
};
@ -170,12 +165,12 @@ export class ChatIntegrationService {
// Pre-connect hook — webhook-based platforms use this to detect
// credential conflicts (e.g. a Telegram bot token already in use) and
// abort the connect before we touch any external API.
if (integration.onBeforeConnect && !options.skipExternalHooks) {
await integration.onBeforeConnect(ctx);
if (integrationImpl.onBeforeConnect && !options.skipExternalHooks) {
await integrationImpl.onBeforeConnect(ctx);
}
// Delegate adapter construction to the platform implementation.
const adapter = await integration.createAdapter(ctx);
const adapter = await integrationImpl.createAdapter(ctx);
// Dynamic imports — chat packages are ESM-only, use loader to bypass CJS transform
const { Chat } = await loadChatSdk();
@ -185,7 +180,7 @@ export class ChatIntegrationService {
// bot.webhooks.slack maps correctly to the handler.
const chat = new Chat({
userName: `n8n-agent-${agentId}`,
adapters: { [integrationType]: adapter } as Record<string, never>,
adapters: { [integration.type]: adapter } as Record<string, never>,
state: createMemoryState(),
});
@ -204,8 +199,7 @@ export class ChatIntegrationService {
componentMapper,
this.logger,
projectId,
integrationType,
options.settings,
integration,
);
// Initialize the Chat instance (connects adapters, state adapter, etc.)
@ -213,9 +207,9 @@ export class ChatIntegrationService {
// Post-initialize hooks (e.g. Telegram setWebhook) run AFTER chat is live.
// If one throws we must shut the chat down, otherwise adapters/timers leak.
if (integration.onAfterConnect && !options.skipExternalHooks) {
if (integrationImpl.onAfterConnect && !options.skipExternalHooks) {
try {
await integration.onAfterConnect(ctx);
await integrationImpl.onAfterConnect(ctx);
} catch (error) {
await chat.shutdown().catch((shutdownError: unknown) => {
this.logger.warn(
@ -244,9 +238,14 @@ export class ChatIntegrationService {
* If `type` and `credentialId` are provided, disconnects only that integration.
* Otherwise disconnects all integrations for the agent.
*/
async disconnect(agentId: string, type?: string, credentialId?: string): Promise<void> {
if (type && credentialId) {
await this.disconnectOne(this.connectionKey(agentId, type, credentialId));
async disconnect(
agentId: string,
integration?: { credentialId: string; type: string },
): Promise<void> {
if (integration) {
await this.disconnectOne(
this.connectionKey(agentId, integration.type, integration.credentialId),
);
} else {
const keysToRemove = [...this.connections.keys()].filter((k) => k.startsWith(`${agentId}:`));
for (const k of keysToRemove) {
@ -306,29 +305,22 @@ export class ChatIntegrationService {
*/
async syncToConfig(
agent: Agent,
previous: AgentCredentialIntegration[],
next: AgentCredentialIntegration[],
previous: AgentCredentialIntegrationConfig[],
next: AgentCredentialIntegrationConfig[],
): Promise<void> {
const key = (i: AgentCredentialIntegration) => `${i.type}:${i.credentialId}`;
const key = (i: AgentCredentialIntegrationConfig) => `${i.type}:${i.credentialId}`;
const previousKeys = new Set(previous.map(key));
const nextKeys = new Set(next.map(key));
for (const integration of previous) {
if (!nextKeys.has(key(integration))) {
try {
await this.disconnect(agent.id, integration.type, integration.credentialId);
await this.broadcastIntegrationChange(
agent.id,
integration.type,
integration.credentialId,
'disconnect',
);
await this.disconnect(agent.id, integration);
await this.broadcastIntegrationChange(agent.id, integration, 'disconnect');
} catch (error) {
this.logger.warn('[ChatIntegrationService] Disconnect during sync failed', {
agentId: agent.id,
type: integration.type,
error,
});
this.logger.warn(
`[ChatIntegrationService] Disconnect during sync failed for ${integration.type} on agent ${agent.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@ -356,14 +348,7 @@ export class ChatIntegrationService {
let connected = false;
for (const userId of userIds) {
try {
await this.connect(
agent.id,
integration.credentialId,
integration.type,
userId,
agent.projectId,
integration.settings ? { settings: integration.settings } : undefined,
);
await this.connect(agent.id, integration, userId, agent.projectId);
connected = true;
break;
@ -377,13 +362,7 @@ export class ChatIntegrationService {
}
}
if (connected) {
await this.broadcastIntegrationChange(
agent.id,
integration.type,
integration.credentialId,
'connect',
integration.settings,
);
await this.broadcastIntegrationChange(agent.id, integration, 'connect');
} else {
this.logger.warn(
'[ChatIntegrationService] Could not connect integration during sync — no project member had credential access',
@ -499,14 +478,7 @@ export class ChatIntegrationService {
let connected = false;
for (const userId of userIds) {
try {
await this.connect(
agent.id,
integration.credentialId,
integration.type,
userId,
agent.projectId,
options,
);
await this.connect(agent.id, integration, userId, agent.projectId, options);
connected = true;
break;
} catch (error) {
@ -539,10 +511,11 @@ export class ChatIntegrationService {
async handleIntegrationChanged(
payload: PubSubCommandMap['agent-chat-integration-changed'],
): Promise<void> {
const { agentId, type, credentialId, action, settings: integrationSettings } = payload;
const { agentId, integration, action } = payload;
const { type, credentialId } = integration;
if (action === 'disconnect') {
await this.disconnect(agentId, type, credentialId);
await this.disconnect(agentId, integration);
return;
}
@ -574,10 +547,8 @@ export class ChatIntegrationService {
// Telegram setWebhook). We only need local state here — skipping
// the hooks also avoids racing the originator into Telegram's 1/sec
// rate limit.
const options: ConnectOptions = integrationSettings
? { skipExternalHooks: true, settings: integrationSettings }
: { skipExternalHooks: true };
await this.connect(agentId, credentialId, type, userId, agent.projectId, options);
const options: ConnectOptions = { skipExternalHooks: true };
await this.connect(agentId, integration, userId, agent.projectId, options);
return;
} catch (error) {
this.logger.debug(
@ -647,10 +618,10 @@ export class ChatIntegrationService {
}
private connectOptionsFor(
integration: AgentCredentialIntegration,
integration: AgentCredentialIntegrationConfig,
skipExternalHooks: boolean,
): ConnectOptions {
return integration.settings
return 'settings' in integration
? { skipExternalHooks, settings: integration.settings }
: { skipExternalHooks };
}

View File

@ -1,16 +1,16 @@
import {
isAgentScheduleIntegration,
type AgentCredentialIntegration,
type AgentIntegration,
} from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import type { Agent } from '../entities/agent.entity';
import {
type AgentCredentialIntegrationConfig,
type AgentIntegrationConfig,
isAgentScheduleIntegration,
} from '@n8n/api-types';
function scheduleConfigsEqual(
a: AgentIntegration | undefined,
b: AgentIntegration | undefined,
a: AgentIntegrationConfig | undefined,
b: AgentIntegrationConfig | undefined,
): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
@ -37,8 +37,8 @@ function scheduleConfigsEqual(
*/
export async function syncAgentIntegrations(
agent: Agent,
previous: AgentIntegration[],
next: AgentIntegration[],
previous: AgentIntegrationConfig[],
next: AgentIntegrationConfig[],
logger: Logger,
): Promise<void> {
const prevSchedule = previous.find(isAgentScheduleIntegration);
@ -56,10 +56,10 @@ export async function syncAgentIntegrations(
}
const prevChat = previous.filter(
(i): i is AgentCredentialIntegration => !isAgentScheduleIntegration(i),
(i): i is AgentCredentialIntegrationConfig => !isAgentScheduleIntegration(i),
);
const nextChat = next.filter(
(i): i is AgentCredentialIntegration => !isAgentScheduleIntegration(i),
(i): i is AgentCredentialIntegrationConfig => !isAgentScheduleIntegration(i),
);
try {
// eslint-disable-next-line import-x/no-cycle

View File

@ -156,8 +156,12 @@ describe('TelegramIntegration.isUserAllowed', () => {
it('allows everyone in public mode', () => {
expect(
integration.isUserAllowed({ userId: '999', userName: 'someuser' } as Author, {
accessMode: 'public',
allowedUsers: [],
type: 'telegram',
credentialId: 'cred-1',
settings: {
accessMode: 'public',
allowedUsers: [],
},
}),
).toBe(true);
});
@ -171,8 +175,12 @@ describe('TelegramIntegration.isUserAllowed', () => {
it('accepts a whitelisted user by numeric ID in private mode', () => {
expect(
integration.isUserAllowed({ userId: '123', userName: 'someuser' } as Author, {
accessMode: 'private',
allowedUsers: ['123', '456'],
type: 'telegram',
credentialId: 'cred-1',
settings: {
accessMode: 'private',
allowedUsers: ['123', '456'],
},
}),
).toBe(true);
});
@ -180,8 +188,12 @@ describe('TelegramIntegration.isUserAllowed', () => {
it('accepts a whitelisted user by username in private mode', () => {
expect(
integration.isUserAllowed({ userId: '999', userName: 'john_doe123' } as Author, {
accessMode: 'private',
allowedUsers: ['john_doe123', '456'],
type: 'telegram',
credentialId: 'cred-1',
settings: {
accessMode: 'private',
allowedUsers: ['john_doe123', '456'],
},
}),
).toBe(true);
});
@ -189,8 +201,12 @@ describe('TelegramIntegration.isUserAllowed', () => {
it('rejects a user whose ID and username are both absent from the allowlist', () => {
expect(
integration.isUserAllowed({ userId: '999', userName: 'stranger' } as Author, {
accessMode: 'private',
allowedUsers: ['123', 'john_doe123'],
type: 'telegram',
credentialId: 'cred-1',
settings: {
accessMode: 'private',
allowedUsers: ['123', 'john_doe123'],
},
}),
).toBe(false);
});

View File

@ -1,4 +1,3 @@
import { agentTelegramSettingsSchema, type AgentIntegrationSettings } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type { Thread, Author } from 'chat';
@ -9,6 +8,7 @@ import { UnexpectedError } from 'n8n-workflow';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { UrlService } from '@/services/url.service';
import { AgentCredentialIntegrationConfig } from '@n8n/api-types';
import { AgentRepository } from '../../repositories/agent.repository';
import { AgentChatIntegration, type AgentChatIntegrationContext } from '../agent-chat-integration';
import type { SuspendComponent } from '../component-mapper';
@ -133,16 +133,20 @@ export class TelegramIntegration extends AgentChatIntegration {
* they are normalized by stripping "@" before comparison. The SDK delivers
* both userId and userName without "@".
*/
isUserAllowed(author: Author, settings: AgentIntegrationSettings | undefined): boolean {
if (!settings) return true;
const validConfig = agentTelegramSettingsSchema.safeParse(settings);
if (!validConfig.success) {
isUserAllowed(
author: Author,
integration: AgentCredentialIntegrationConfig | undefined,
): boolean {
if (!integration) return true;
if (integration?.type !== 'telegram') {
throw new UnexpectedError(
`Invalid Telegram integration settings: ${validConfig.error.message}`,
`TelegramIntegration received settings with type "${integration?.type}"`,
);
}
if (settings.accessMode === 'public') return true;
return settings.allowedUsers.some((allowed) => {
if (!integration.settings) return true;
if (integration.settings.accessMode === 'public') return true;
return integration.settings.allowedUsers.some((allowed) => {
const normalized = allowed.startsWith('@') ? allowed.slice(1) : allowed;
return normalized === author.userId || normalized === author.userName;
});

View File

@ -1,7 +1,6 @@
import type { AgentIntegration } from '@n8n/api-types';
import type { AgentIntegrationConfig, AgentJsonConfig } from '@n8n/api-types';
import type { Agent } from '../entities/agent.entity';
import type { AgentJsonConfig } from './agent-json-config';
/**
* Build the unified `AgentJsonConfig` view from an agent entity. The schema
@ -22,7 +21,7 @@ export function composeJsonConfig(agent: Agent): AgentJsonConfig | null {
*/
export function decomposeJsonConfig(config: AgentJsonConfig): {
schemaConfig: Omit<AgentJsonConfig, 'integrations'>;
integrations: AgentIntegration[];
integrations: AgentIntegrationConfig[];
} {
const { integrations, ...schemaConfig } = config;
return { schemaConfig, integrations: integrations ?? [] };

View File

@ -8,15 +8,15 @@ import type {
JSONObject,
} from '@n8n/agents';
import { Agent, Memory, Tool, wrapToolForApproval } from '@n8n/agents';
import type { AgentSkill } from '@n8n/api-types';
import { z } from 'zod';
import type {
AgentSkill,
AgentJsonConfig,
AgentJsonConfigRef,
AgentJsonMemoryConfig,
AgentJsonToolConfig,
} from './agent-json-config';
AgentJsonSkillConfig,
} from '@n8n/api-types';
import { mapCredentialForProvider } from './credential-field-mapping';
import { resolveProviderToolName } from './provider-tool-aliases';
@ -138,7 +138,7 @@ export async function buildFromJson(
type ConfiguredSkill = { id: string; skill: AgentSkill };
function getConfiguredSkills(
refs: Array<Extract<AgentJsonConfigRef, { type: 'skill' }>>,
refs: AgentJsonSkillConfig[],
skills: Record<string, AgentSkill>,
): ConfiguredSkill[] {
const seen = new Set<string>();

View File

@ -1,39 +0,0 @@
import { agentIntegrationSettingsSchema } from '@n8n/api-types';
import { z } from 'zod';
import { isValidCronExpression } from '../integrations/cron-validation';
export const AgentScheduleIntegrationSchema = z
.object({
type: z.literal('schedule'),
active: z.boolean(),
cronExpression: z
.string()
.min(1, 'cronExpression is required')
.refine(isValidCronExpression, { message: 'Invalid cron expression' }),
wakeUpPrompt: z.string().min(1, 'wakeUpPrompt is required'),
})
.strict();
export const AgentCredentialIntegrationSchema = z
.object({
type: z
.string()
.min(1)
.refine((value) => value !== 'schedule', {
message: 'Type "schedule" is reserved for the schedule trigger',
}),
credentialId: z.string().min(1),
credentialName: z.string().min(1),
settings: agentIntegrationSettingsSchema.optional(),
})
.strict();
export const AgentIntegrationSchema = z.union([
AgentScheduleIntegrationSchema,
AgentCredentialIntegrationSchema,
]);
export type AgentScheduleIntegrationConfig = z.infer<typeof AgentScheduleIntegrationSchema>;
export type AgentCredentialIntegrationConfig = z.infer<typeof AgentCredentialIntegrationSchema>;
export type AgentIntegrationConfig = z.infer<typeof AgentIntegrationSchema>;

View File

@ -5,7 +5,7 @@ import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPar
import { AgentPublishedVersion } from '../entities/agent-published-version.entity';
import type { Agent } from '../entities/agent.entity';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentJsonConfig } from '@n8n/api-types';
@Service()
export class AgentPublishedVersionRepository extends Repository<AgentPublishedVersion> {

View File

@ -1,7 +1,7 @@
import { isAgentCredentialIntegration } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { isAgentCredentialIntegration } from '@n8n/api-types';
import { Agent } from '../entities/agent.entity';
@Service()

View File

@ -10,7 +10,7 @@ import type { EphemeralNodeExecutor } from '@/node-execution';
import { NodeTypes } from '@/node-types';
import { Container } from '@n8n/di';
import type { AgentJsonToolConfig } from '../json-config/agent-json-config';
import type { AgentJsonToolConfig } from '@n8n/api-types';
type NodeToolInputSchema = JSONSchema7 | z.ZodType;

View File

@ -1,7 +1,10 @@
import type { BuiltTool } from '@n8n/agents';
import { Tool } from '@n8n/agents';
import { INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES } from '@n8n/api-types';
import type { SUPPORTED_WORKFLOW_TOOL_TRIGGERS } from '@n8n/api-types';
import {
INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES,
type AgentJsonToolConfig,
type SUPPORTED_WORKFLOW_TOOL_TRIGGERS,
} from '@n8n/api-types';
import type {
ExecutionRepository,
UserRepository,
@ -31,7 +34,6 @@ import type { WorkflowRunner } from '@/workflow-runner';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { sanitizeToolName } from '../json-config/agent-config-composition';
import type { AgentJsonToolConfig } from '../json-config/agent-json-config';
// ---------------------------------------------------------------------------
// Constants

View File

@ -1,5 +1,5 @@
import type {
AgentIntegrationSettings,
AgentCredentialIntegrationConfig,
ChatHubMessageStatus,
PushMessage,
WorkerStatus,
@ -200,10 +200,8 @@ export type PubSubCommandMap = {
*/
'agent-chat-integration-changed': {
agentId: string;
type: string;
credentialId: string;
integration: AgentCredentialIntegrationConfig;
action: 'connect' | 'disconnect';
settings?: AgentIntegrationSettings;
};
/**

View File

@ -104,7 +104,9 @@ const ElDialogStub = {
const MODAL_NAME = 'AgentToolConfigModal';
function toolRef(overrides: Partial<AgentJsonToolRef['node']> = {}): AgentJsonToolRef {
function toolRef(
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>> = {},
): Extract<AgentJsonToolRef, { type: 'node' }> {
return {
type: 'node',
name: 'Slack',

View File

@ -252,8 +252,8 @@ describe('AgentToolsModal', () => {
function toolRef(
nodeType: string,
overrides: Partial<AgentJsonToolRef['node']> = {},
): AgentJsonToolRef {
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>['node']> = {},
): Extract<AgentJsonToolRef, { type: 'node' }> {
return {
type: 'node',
name: nodeType,
@ -261,6 +261,7 @@ describe('AgentToolsModal', () => {
nodeType,
nodeTypeVersion: 1,
credentials: { slackApi: { id: 'c', name: 'cred' } },
nodeParameters: {},
...overrides,
},
};
@ -483,7 +484,7 @@ describe('AgentToolsModal', () => {
await fireEvent.click(gearButton!);
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
const editedRef: AgentJsonToolRef = {
const editedRef: Extract<AgentJsonToolRef, { type: 'node' }> = {
...tool,
name: 'Slack renamed',
};

View File

@ -3,8 +3,8 @@ import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
type AgentPersistedMessageDto,
} from '@n8n/api-types';
import type { AgentPersistedMessageDto } from '@n8n/api-types';
import {
applyOpenSuspensions,

View File

@ -11,7 +11,16 @@ describe('buildAgentConfigFingerprint', () => {
model: 'gpt-4',
instructions: 'do things',
tools: [
{ type: 'node', name: 'zulu' },
{
type: 'node',
name: 'zulu',
node: {
nodeType: 'n8n-nodes-base.zulu',
nodeTypeVersion: 1,
nodeParameters: {},
credentials: {},
},
},
{ type: 'custom', id: 'alpha' },
],
skills: [{ type: 'skill', id: 'summarize_notes' }],
@ -47,7 +56,19 @@ describe('buildAgentConfigFingerprint', () => {
const a = await buildAgentConfigFingerprint(baseConfig, []);
const withExtra: AgentJsonConfig = {
...baseConfig,
tools: [...(baseConfig.tools ?? []), { type: 'node', name: 'new-tool' }],
tools: [
...(baseConfig.tools ?? []),
{
type: 'node',
name: 'new-tool',
node: {
nodeType: 'n8n-nodes-base.new-tool',
nodeTypeVersion: 1,
nodeParameters: {},
credentials: {},
},
},
],
};
expect((await buildAgentConfigFingerprint(withExtra, [])).config_version).not.toBe(
a.config_version,

View File

@ -62,8 +62,9 @@ describe('useAgentToolRefAdapter', () => {
it('falls back to nodeType as name when ref.name is missing', () => {
const ref: AgentJsonToolRef = {
name: undefined as unknown as string,
type: 'node',
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1 },
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1, nodeParameters: {} },
};
expect(toolRefToNode(ref)?.name).toBe('n8n-nodes-base.slack');
});
@ -75,7 +76,7 @@ describe('useAgentToolRefAdapter', () => {
const ref: AgentJsonToolRef = {
type: 'node',
name: 'Slack',
node: { nodeType: 'n8n-nodes-base.slackTool', nodeTypeVersion: 1 },
node: { nodeType: 'n8n-nodes-base.slackTool', nodeTypeVersion: 1, nodeParameters: {} },
};
expect(toolRefToNode(ref)?.type).toBe('n8n-nodes-base.slackTool');
});
@ -87,6 +88,7 @@ describe('useAgentToolRefAdapter', () => {
node: {
nodeType: 'n8n-nodes-base.slack',
nodeTypeVersion: 1,
nodeParameters: {},
credentials: { slackApi: { id: 'cred-1', name: 'Prod Slack' } },
},
};
@ -116,7 +118,7 @@ describe('useAgentToolRefAdapter', () => {
it('seeds empty parameters without persisting an input schema', () => {
const ref = nodeTypeToNewToolRef(makeNodeType());
expect(ref.node?.nodeParameters).toEqual({});
expect(ref.id).toBeUndefined();
expect((ref as any).id).toBeUndefined();
expect(ref).not.toHaveProperty('inputSchema');
});
@ -209,7 +211,7 @@ describe('useAgentToolRefAdapter', () => {
const original: AgentJsonToolRef = {
type: 'node',
name: 'Slack',
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1 },
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1, nodeParameters: {} },
};
const node: INode = {
id: 'n-1',
@ -223,7 +225,10 @@ describe('useAgentToolRefAdapter', () => {
},
};
const updated = updateToolRefFromNode(original, node);
const updated = updateToolRefFromNode(original, node) as Extract<
AgentJsonToolRef,
{ type: 'node' }
>;
expect(updated.node?.credentials).toBeUndefined();
});
@ -231,7 +236,7 @@ describe('useAgentToolRefAdapter', () => {
const original: AgentJsonToolRef = {
type: 'node',
name: 'Slack',
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1 },
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1, nodeParameters: {} },
};
const node: INode = {
id: 'n-1',
@ -245,7 +250,10 @@ describe('useAgentToolRefAdapter', () => {
},
};
const updated = updateToolRefFromNode(original, node);
const updated = updateToolRefFromNode(original, node) as Extract<
AgentJsonToolRef,
{ type: 'node' }
>;
expect(updated.node?.credentials).toEqual({
slackApi: { id: 'cred-1', name: 'Prod' },
});
@ -287,7 +295,10 @@ describe('useAgentToolRefAdapter', () => {
},
};
const updated = updateToolRefFromNode(original, node);
const updated = updateToolRefFromNode(original, node) as Extract<
AgentJsonToolRef,
{ type: 'node' }
>;
expect(updated).not.toHaveProperty('inputSchema');
expect(updated.node?.nodeParameters).toEqual(node.parameters);
});
@ -320,7 +331,7 @@ describe('useAgentToolRefAdapter', () => {
description: 'Ship a daily summary',
allOutputs: false,
});
expect(ref.id).toBeUndefined();
expect((ref as any).id).toBeUndefined();
});
it('defaults description to empty when the workflow has none', () => {
@ -356,7 +367,7 @@ describe('useAgentToolRefAdapter', () => {
const nodeRef: AgentJsonToolRef = {
type: 'node',
name: 'Slack',
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1 },
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1, nodeParameters: {} },
};
expect(
updateWorkflowToolRef(nodeRef, { name: 'x', description: 'y', allOutputs: true }),

View File

@ -9,12 +9,19 @@ vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: trackMock }),
}));
function nodeRef(overrides: Partial<AgentJsonToolRef['node']> = {}): AgentJsonToolRef {
function nodeRef(
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>['node']> = {},
): Extract<AgentJsonToolRef, { type: 'node' }> {
return {
type: 'node',
name: 'Slack',
requireApproval: false,
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1, ...overrides },
node: {
nodeType: 'n8n-nodes-base.slack',
nodeTypeVersion: 1,
nodeParameters: {},
...overrides,
},
};
}

View File

@ -79,7 +79,6 @@ function toolLabel(tool: AgentJsonToolRef, index: number) {
if (tool.type === 'custom') {
return formatToolNameForDisplay(
(tool.id ? props.customTools?.[tool.id]?.descriptor.name : undefined) ??
tool.name ??
tool.id ??
`${tool.type}-${index + 1}`,
);

View File

@ -66,14 +66,17 @@ const activeView = ref<'config' | 'raw'>('config');
const initialNode = computed<INode | null>(() =>
isWorkflowTool.value || isCustomTool.value ? null : toolRefToNode(props.data.toolRef),
);
const initialName = computed(() => props.data.toolRef.name ?? initialNode.value?.name ?? '');
const initialName = computed(() => {
const toolName = props.data.toolRef.type === 'node' ? props.data.toolRef.name : undefined;
return toolName ?? initialNode.value?.name ?? '';
});
const nodeName = ref(initialName.value);
const customToolCode = computed(() => props.data.customTool?.code ?? '');
const customToolTitle = computed(
() =>
props.data.customTool?.descriptor.name ??
props.data.toolRef.name ??
props.data.toolRef.id ??
('name' in props.data.toolRef ? props.data.toolRef.name : undefined) ??
('id' in props.data.toolRef ? props.data.toolRef.id : undefined) ??
i18n.baseText('agents.builder.tree.customBadge'),
);
@ -211,7 +214,7 @@ function handleNodeNameUpdate(name: string) {
/>
<div v-show="activeView === 'config'" :class="$style.configureTab">
<WorkflowToolConfigContent
v-if="isWorkflowTool"
v-if="data.toolRef.type === 'workflow'"
ref="workflowContentRef"
:initial-ref="data.toolRef"
@update:valid="handleValidUpdate"

View File

@ -125,8 +125,7 @@ const customRows = computed<CustomRow[]>(() =>
.filter((item): item is { ref: CustomToolRef; index: number } => item.ref.type === 'custom')
.map(({ ref, index }) => ({
index,
label: ref.name?.trim() || ref.id || `Custom tool ${index + 1}`,
description: ref.description,
label: ref.id || `Custom tool ${index + 1}`,
})),
);

View File

@ -342,13 +342,19 @@ function handleAddTool(nodeType: INodeTypeDescription) {
return;
}
addToolRef({
...newRef,
name: makeUniqueName(
newRef.name ?? nodeType.displayName,
getExistingToolNames(workingTools.value),
),
});
if (newRef.type === 'node') {
addToolRef({
...newRef,
name: makeUniqueName(
newRef.name ?? nodeType.displayName,
getExistingToolNames(workingTools.value),
),
});
} else {
addToolRef({
...newRef,
});
}
}
async function handleAddWorkflow(workflow: IWorkflowDb) {

View File

@ -17,10 +17,10 @@ import { ref, watch } from 'vue';
import { N8nCheckbox, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AgentJsonToolRef } from '../types';
import type { WorkflowToolRef } from '../types';
const props = defineProps<{
initialRef: AgentJsonToolRef;
initialRef: WorkflowToolRef;
}>();
const emit = defineEmits<{

View File

@ -1,12 +1,8 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { N8nCard, N8nText, N8nIcon } from '@n8n/design-system';
import type {
AskLlmResume,
ChatHubConversationModel,
ChatHubProvider,
ChatModelsResponse,
} from '@n8n/api-types';
import type { AskLlmResume } from '@n8n/api-types';
import type { ChatHubConversationModel, ChatHubProvider, ChatModelsResponse } from '@n8n/api-types';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useChatStore } from '@/features/ai/chatHub/chat.store';

View File

@ -1,10 +1,6 @@
import { ref, type Ref } from 'vue';
import isEqual from 'lodash/isEqual';
import {
isAgentCredentialIntegration,
isAgentScheduleIntegration,
type AgentIntegrationStatusEntry,
} from '@n8n/api-types';
import { type AgentIntegrationStatusEntry, isAgentScheduleIntegration } from '@n8n/api-types';
import {
buildAgentConfigFingerprint,
deriveAgentStatus,
@ -92,7 +88,7 @@ function integrationStatusEntriesFromConfig(
continue;
}
if (isAgentCredentialIntegration(integration)) {
if (!isAgentScheduleIntegration(integration)) {
entries.push({ type: integration.type, credentialId: integration.credentialId });
}
}

View File

@ -72,7 +72,9 @@ export function toolRefToNode(ref: AgentJsonToolRef): INode | null {
}
/** Build a new `AgentJsonToolRef` for a node type the user just connected. */
export function nodeTypeToNewToolRef(nodeType: INodeTypeDescription): AgentJsonToolRef {
export function nodeTypeToNewToolRef(
nodeType: INodeTypeDescription,
): Extract<AgentJsonToolRef, { type: 'node' }> {
const version = pickLatestVersion(nodeType.version);
return {
type: 'node',
@ -116,7 +118,9 @@ export function updateToolRefFromNode(original: AgentJsonToolRef, node: INode):
* because the backend's `buildWorkflowTool` looks workflows up by name scoped
* to the project see `cli/src/modules/agents/tools/workflow-tool-factory.ts`.
*/
export function workflowToNewToolRef(workflow: IWorkflowDb): AgentJsonToolRef {
export function workflowToNewToolRef(
workflow: IWorkflowDb,
): Extract<AgentJsonToolRef, { type: 'workflow' }> {
return {
type: 'workflow',
workflow: workflow.name,
@ -136,7 +140,9 @@ export function getExistingToolNames(
tools: AgentJsonToolRef[],
exclude?: AgentJsonToolRef,
): string[] {
return tools.filter((t) => t !== exclude && Boolean(t.name)).map((t) => t.name as string);
return tools
.filter((t) => t !== exclude && t.type !== 'custom' && Boolean(t.name))
.map((t) => (t as Extract<AgentJsonToolRef, { type: 'workflow' | 'node' }>).name!);
}
/** Merge edits from the workflow config form back into the ref. */

View File

@ -1,5 +1,5 @@
import type { BaseResource } from '@/Interface';
import type { AgentJsonToolRef as ApiAgentJsonToolRef, AgentSkill } from '@n8n/api-types';
import type { AgentJsonToolConfig, AgentSkill } from '@n8n/api-types';
import type { Agent, ToolDescriptor, CustomToolEntry } from './agent.types';
export type { ToolDescriptor, CustomToolEntry, AgentSkill };
@ -119,12 +119,13 @@ export interface ThinkingSchema {
reasoningEffort?: string;
}
export type WorkflowToolRef = ApiAgentJsonToolRef & { type: 'workflow' };
export type WorkflowToolRef = AgentJsonToolConfig & { type: 'workflow' };
export type {
NodeToolConfig,
AgentJsonToolRef,
AgentJsonSkillRef,
AgentJsonConfigRef,
AgentJsonToolConfig,
AgentJsonToolConfig as AgentJsonToolRef,
AgentJsonSkillConfig as AgentJsonSkillRef,
AgentJsonConfig as AgentJsonConfigRef,
AgentJsonConfig,
} from '@n8n/api-types';

View File

@ -21,7 +21,7 @@ import {
createAgentSkill,
} from '../composables/useAgentApi';
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
import type { AgentResource, AgentJsonConfig, AgentJsonToolRef, AgentSkill } from '../types';
import type { AgentResource, AgentJsonConfig, AgentJsonToolConfig, AgentSkill } from '../types';
import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetry';
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { useAgentConfig } from '../composables/useAgentConfig';
@ -636,7 +636,7 @@ function onOpenAddToolModal() {
tools: localConfig.value?.tools ?? [],
projectId: projectId.value,
agentId: agentId.value,
onConfirm: (tools: AgentJsonToolRef[]) => onConfigFieldUpdate({ tools }),
onConfirm: (tools: AgentJsonToolConfig[]) => onConfigFieldUpdate({ tools }),
},
});
}
@ -672,9 +672,9 @@ function onOpenToolFromList(index: number) {
projectId: projectId.value,
agentId: agentId.value,
existingToolNames: tools
.map((toolRef, i) => (i === index ? null : toolRef.name))
.map((toolRef, i) => (i === index || toolRef.type === 'custom' ? null : toolRef.name))
.filter((name): name is string => !!name),
onConfirm: (updatedTool: AgentJsonToolRef) => {
onConfirm: (updatedTool: AgentJsonToolConfig) => {
const nextTools = [...(localConfig.value?.tools ?? [])];
nextTools[index] = updatedTool;
onConfigFieldUpdate({ tools: nextTools });
@ -797,7 +797,7 @@ function onOpenAddSkillModal() {
});
}
function onQuickActionAddTool(tools: AgentJsonToolRef[]) {
function onQuickActionAddTool(tools: AgentJsonToolConfig[]) {
onConfigFieldUpdate({ tools });
}