From 148bc89be97022f1f084064659496a5edad33daa Mon Sep 17 00:00:00 2001 From: yehorkardash Date: Fri, 15 May 2026 14:15:07 +0300 Subject: [PATCH] fix: Move agent types to api-types package (no-changelog) (#30484) --- packages/@n8n/api-types/src/agents.ts | 321 ------------------ .../agent-integration.schema.test.ts} | 41 ++- .../agents/__tests__/agent-skill.dto.test.ts | 3 +- .../src/agents/agent-integration.schema.ts | 109 ++++++ .../src/agents/agent-json-config.schema.ts} | 15 +- packages/@n8n/api-types/src/agents/dto.ts | 57 ++++ packages/@n8n/api-types/src/agents/index.ts | 28 ++ packages/@n8n/api-types/src/agents/types.ts | 135 ++++++++ .../src/dto/agents/agent-build-resume.dto.ts | 17 - .../src/dto/agents/agent-chat-message.dto.ts | 8 - .../src/dto/agents/agent-integration.dto.ts | 50 --- .../src/dto/agents/create-agent-skill.dto.ts | 17 - .../src/dto/agents/create-agent.dto.ts | 7 - .../src/dto/agents/update-agent-config.dto.ts | 7 - .../dto/agents/update-agent-schedule.dto.ts | 8 - .../src/dto/agents/update-agent-skill.dto.ts | 8 - .../src/dto/agents/update-agent.dto.ts | 9 - packages/@n8n/api-types/src/dto/index.ts | 21 -- packages/@n8n/api-types/src/index.ts | 24 -- .../agent-config-composition.test.ts | 8 +- .../__tests__/agent-json-config.test.ts | 3 +- .../agents/__tests__/agent.repository.test.ts | 32 +- .../agents-builder-tools.service.test.ts | 3 +- .../agents-service-reconstruct-gating.test.ts | 3 +- .../__tests__/agents-service-sync.test.ts | 4 +- .../__tests__/agents.controller.test.ts | 57 ++-- .../agents/__tests__/agents.service.test.ts | 153 ++++++++- .../agents/__tests__/from-json-config.test.ts | 35 +- .../modules/agents/agent-skills.service.ts | 8 +- .../src/modules/agents/agents.controller.ts | 122 ++----- .../cli/src/modules/agents/agents.service.ts | 173 +++++----- .../agents/builder/agents-builder-prompts.ts | 6 +- .../builder/agents-builder-tools.service.ts | 15 +- .../agents/builder/agents-builder.service.ts | 2 +- .../agent-published-version.entity.ts | 3 +- .../modules/agents/entities/agent.entity.ts | 5 +- .../__tests__/agent-chat-bridge.test.ts | 169 +-------- .../__tests__/agent-schedule.service.test.ts | 48 ++- .../chat-integration.service.test.ts | 110 +++--- .../agents/integrations/agent-chat-bridge.ts | 42 +-- .../integrations/agent-chat-integration.ts | 4 +- .../integrations/agent-schedule.service.ts | 2 +- .../integrations/chat-integration.service.ts | 115 +++---- .../agents/integrations/integrations-sync.ts | 22 +- .../__tests__/telegram-integration.test.ts | 32 +- .../platforms/telegram-integration.ts | 20 +- .../json-config/agent-config-composition.ts | 5 +- .../agents/json-config/from-json-config.ts | 10 +- .../agents/json-config/integration-config.ts | 39 --- .../agent-published-version.repository.ts | 2 +- .../agents/repositories/agent.repository.ts | 2 +- .../modules/agents/tools/node-tool-factory.ts | 2 +- .../agents/tools/workflow-tool-factory.ts | 8 +- .../src/scaling/pubsub/pubsub.event-map.ts | 6 +- .../__tests__/AgentToolConfigModal.test.ts | 4 +- .../agents/__tests__/AgentToolsModal.test.ts | 7 +- .../__tests__/agentChatMessages.test.ts | 2 +- .../__tests__/agentTelemetry.utils.test.ts | 25 +- .../__tests__/useAgentToolRefAdapter.test.ts | 31 +- .../__tests__/useAgentToolTelemetry.test.ts | 11 +- .../components/AgentCapabilitiesSection.vue | 1 - .../components/AgentToolConfigModal.vue | 11 +- .../agents/components/AgentToolsListPanel.vue | 3 +- .../agents/components/AgentToolsModal.vue | 20 +- .../components/WorkflowToolConfigContent.vue | 4 +- .../components/interactive/AskLlmCard.vue | 8 +- .../composables/useAgentBuilderTelemetry.ts | 8 +- .../composables/useAgentToolRefAdapter.ts | 12 +- .../editor-ui/src/features/agents/types.ts | 11 +- .../agents/views/AgentBuilderView.vue | 10 +- 70 files changed, 1058 insertions(+), 1265 deletions(-) delete mode 100644 packages/@n8n/api-types/src/agents.ts rename packages/{cli/src/modules/agents/__tests__/integration-config.test.ts => @n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts} (62%) rename packages/@n8n/api-types/src/{dto => }/agents/__tests__/agent-skill.dto.test.ts (77%) create mode 100644 packages/@n8n/api-types/src/agents/agent-integration.schema.ts rename packages/{cli/src/modules/agents/json-config/agent-json-config.ts => @n8n/api-types/src/agents/agent-json-config.schema.ts} (90%) create mode 100644 packages/@n8n/api-types/src/agents/dto.ts create mode 100644 packages/@n8n/api-types/src/agents/index.ts create mode 100644 packages/@n8n/api-types/src/agents/types.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts delete mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts delete mode 100644 packages/cli/src/modules/agents/json-config/integration-config.ts diff --git a/packages/@n8n/api-types/src/agents.ts b/packages/@n8n/api-types/src/agents.ts deleted file mode 100644 index 4b309e669b9..00000000000 --- a/packages/@n8n/api-types/src/agents.ts +++ /dev/null @@ -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; - credentials?: Record; -} - -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; - lastMessages?: number; - semanticRecall?: { - topK: number; - scope?: 'thread' | 'resource'; - messageRange?: { before: number; after: number }; - embedder?: string; - }; - }; - tools?: AgentJsonToolRef[]; - skills?: AgentJsonSkillRef[]; - providerTools?: Record>; - /** - * 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 | 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; - -/** - * 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; - -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; - -/** - * 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[]; -} diff --git a/packages/cli/src/modules/agents/__tests__/integration-config.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts similarity index 62% rename from packages/cli/src/modules/agents/__tests__/integration-config.test.ts rename to packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts index 17a1dc3992b..5d26237cfaf 100644 --- a/packages/cli/src/modules/agents/__tests__/integration-config.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts @@ -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); }); }); diff --git a/packages/@n8n/api-types/src/dto/agents/__tests__/agent-skill.dto.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-skill.dto.test.ts similarity index 77% rename from packages/@n8n/api-types/src/dto/agents/__tests__/agent-skill.dto.test.ts rename to packages/@n8n/api-types/src/agents/__tests__/agent-skill.dto.test.ts index b6f565e55ac..12e208b8994 100644 --- a/packages/@n8n/api-types/src/dto/agents/__tests__/agent-skill.dto.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-skill.dto.test.ts @@ -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 = { diff --git a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts new file mode 100644 index 00000000000..ac97bcd4fcc --- /dev/null +++ b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts @@ -0,0 +1,109 @@ +import { z } from 'zod'; + +const createCredIntegrationSchema = < + Value extends string, + Settings extends z.ZodTypeAny | z.ZodEffects, +>( + typeName: Value, + settingsSchema: Settings, +) => + z.object({ + type: z.literal(typeName), + credentialId: z.string().min(1), + settings: settingsSchema, + }); + +const createSimpleIntegrationSchema = (typeName: Value) => + z.object({ + type: z.literal(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; + +export const AgentIntegrationSettingsSchema = z.union([AgentTelegramSettingsSchema, z.undefined()]); +export type AgentIntegrationSettings = z.infer; + +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; +export type AgentScheduleIntegrationConfig = z.infer; +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) + ); +} diff --git a/packages/cli/src/modules/agents/json-config/agent-json-config.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts similarity index 90% rename from packages/cli/src/modules/agents/json-config/agent-json-config.ts rename to packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 0e6731b182d..f7b9cc053cb 100644 --- a/packages/cli/src/modules/agents/json-config/agent-json-config.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -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; export type AgentJsonToolConfig = z.infer; +export type AgentJsonWorkflowToolConfig = Extract; +export type AgentJsonNodeToolConfig = Extract; +export type AgentJsonCustomToolConfig = Extract; export type AgentJsonSkillConfig = z.infer; -export type AgentJsonConfigRef = AgentJsonToolConfig | AgentJsonSkillConfig; export type AgentJsonMemoryConfig = z.infer; +export type NodeToolConfig = z.infer; 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; } diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts new file mode 100644 index 00000000000..703c7991a1b --- /dev/null +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -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), +}) {} diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts new file mode 100644 index 00000000000..0161fea6790 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts new file mode 100644 index 00000000000..ec1450624a7 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -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 | 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; + +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; + +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; + +export interface AgentBuilderOpenSuspension { + toolCallId: string; + runId: string; +} + +export interface AgentBuilderMessagesResponse { + messages: AgentPersistedMessageDto[]; + openSuspensions: AgentBuilderOpenSuspension[]; +} diff --git a/packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts b/packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts deleted file mode 100644 index e1f49e5150c..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts +++ /dev/null @@ -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, -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts b/packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts deleted file mode 100644 index 41a2006a029..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts +++ /dev/null @@ -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(), -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts b/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts deleted file mode 100644 index 0680894d362..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts +++ /dev/null @@ -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; - -export const agentIntegrationSettingsSchema = z.union([agentTelegramSettingsSchema, z.undefined()]); - -export type AgentIntegrationSettings = z.infer; - -// 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(), -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts b/packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts deleted file mode 100644 index d60bf7fa4cb..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts +++ /dev/null @@ -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, -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts b/packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts deleted file mode 100644 index 5fb7236d844..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -import { Z } from '../../zod-class'; - -export class CreateAgentDto extends Z.class({ - name: z.string().min(1), -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts deleted file mode 100644 index 0e008869f2e..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -import { Z } from '../../zod-class'; - -export class UpdateAgentConfigDto extends Z.class({ - config: z.record(z.unknown()), -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts deleted file mode 100644 index 825880f3ab2..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts +++ /dev/null @@ -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(), -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts deleted file mode 100644 index 605734512b4..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts +++ /dev/null @@ -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(), -}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts deleted file mode 100644 index 4847a9d9e1e..00000000000 --- a/packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts +++ /dev/null @@ -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(), -}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index c76330d42aa..ee083025249 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 85e3aa56bd2..7d2a15f2e78 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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 { diff --git a/packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts index a874c602c45..c6e106874a1 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts index c2f0997fcbc..fc1562a7511 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts @@ -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', diff --git a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts index 101d20bbdb3..a73fbc0852b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts @@ -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); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 5ce26eae5f0..1df569e04a6 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -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 = { diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index 472db290c8a..ffc4c27101b 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -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(), + mock(), ); } diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts index bba844a5362..885be114459 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts @@ -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 { return { @@ -80,6 +81,7 @@ describe('AgentsService — updateName / updateDescription schema sync', () => { { modules: [] } as unknown as AgentsConfig, mock(), mock(), + mock(), ); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts index 7565f388f50..a9096d3414c 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts @@ -31,18 +31,20 @@ const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, rout })); function makeController({ + agentsService = mock(), credentialsService = mock(), chatIntegrationService = mock(), agentScheduleService = mock(), agentRepository = mock(), }: { + agentsService?: jest.Mocked; credentialsService?: jest.Mocked; chatIntegrationService?: jest.Mocked; agentScheduleService?: jest.Mocked; agentRepository?: jest.Mocked; } = {}) { const controller = new AgentsController( - mock(), + agentsService, mock(), 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(); + const agentsService = mock(); 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, + }, + ], }); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 7ac057c6990..5d29ae1def5 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -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; let agentExecutionService: jest.Mocked; let scheduleService: jest.Mocked; + let chatIntegrationService: jest.Mocked; let publisher: jest.Mocked; let agentsConfig: AgentsConfig; let globalConfig: jest.Mocked; @@ -90,6 +94,7 @@ describe('AgentsService', () => { agentExecutionService = mock(); agentExecutionService.recordMessage.mockResolvedValue('exec-id'); scheduleService = mock(); + chatIntegrationService = mock(); publisher = mock(); publisher.publishCommand.mockResolvedValue(); agentsConfig = { modules: [] } as unknown as AgentsConfig; @@ -121,6 +126,7 @@ describe('AgentsService', () => { agentsConfig, globalConfig, mock(), + 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; + const savedSchema = savedEntity.schema as unknown as Record; 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], + }), + ); + }); + }); }); diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index 47cc299b9a6..9eb232615ef 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -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(); - }); }); diff --git a/packages/cli/src/modules/agents/agent-skills.service.ts b/packages/cli/src/modules/agents/agent-skills.service.ts index 3aa48407dce..9e77a31cac4 100644 --- a/packages/cli/src/modules/agents/agent-skills.service.ts +++ b/packages/cli/src/modules/agents/agent-skills.service.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/agents.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts index 35e8f69c06a..be5f45c53a2 100644 --- a/packages/cli/src/modules/agents/agents.controller.ts +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -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 }] : []; diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index b5841c68e38..4532d0c1e5a 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -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 { - if (!rawConfig || typeof rawConfig !== 'object') return rawConfig; - const cfg = rawConfig as { integrations?: unknown }; - if (!Array.isArray(cfg.integrations)) return rawConfig; - - const missingIds = new Set(); - 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 => t.type === 'custom') + .filter((t): t is Extract => 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 { + 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 { + 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 => ref.type === 'custom', + (ref): ref is Extract => ref.type === 'custom', ); const seen = new Set(); const missing: string[] = []; diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index 1aeb303aa73..3418b24e9ad 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -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": "", "credentialName": "" } + { "type": "slack", "credentialId": "" } \`\`\` ### 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\`.`; diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index fc4da0fde3d..82aa17b284c 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -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 { diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts index 86468d1f89b..b8128795611 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/entities/agent-published-version.entity.ts b/packages/cli/src/modules/agents/entities/agent-published-version.entity.ts index 1e0e9dbdd99..47f9dac62aa 100644 --- a/packages/cli/src/modules/agents/entities/agent-published-version.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-published-version.entity.ts @@ -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 { diff --git a/packages/cli/src/modules/agents/entities/agent.entity.ts b/packages/cli/src/modules/agents/entities/agent.entity.ts index 11e052f2f5c..81c39596bb1 100644 --- a/packages/cli/src/modules/agents/entities/agent.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent.entity.ts @@ -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< diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index eb4f7225553..7c0c4a77805 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -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[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 { - 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(); const logger = mock(); + 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(); - }); - }); }); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts index 4da6b7fa9db..1c7be033ee1 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts @@ -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 { 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( [ diff --git a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts index a484a73a3e2..be5409aa96a 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts @@ -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 { } 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.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(); 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(); 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(); 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(); }); }); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index 9ebe5577c35..6fd33e8f7b0 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -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) { - 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 { diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts index 2f7f64efa35..c5d7581530d 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -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; } /** diff --git a/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts b/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts index f772e97a5a7..7ed4f874f84 100644 --- a/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts +++ b/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts index 9aec1404865..12d1f42750c 100644 --- a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts +++ b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts @@ -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 { 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 { - 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, + adapters: { [integration.type]: adapter } as Record, 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 { - if (type && credentialId) { - await this.disconnectOne(this.connectionKey(agentId, type, credentialId)); + async disconnect( + agentId: string, + integration?: { credentialId: string; type: string }, + ): Promise { + 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 { - 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 { - 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 }; } diff --git a/packages/cli/src/modules/agents/integrations/integrations-sync.ts b/packages/cli/src/modules/agents/integrations/integrations-sync.ts index d715a700b85..eabd2200cb8 100644 --- a/packages/cli/src/modules/agents/integrations/integrations-sync.ts +++ b/packages/cli/src/modules/agents/integrations/integrations-sync.ts @@ -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 { 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 diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts index 83d5fc21bd8..b0dbe78b7aa 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts @@ -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); }); diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts index 953fb2c6e7b..185178f8c9b 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -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; }); diff --git a/packages/cli/src/modules/agents/json-config/agent-config-composition.ts b/packages/cli/src/modules/agents/json-config/agent-config-composition.ts index 948c37c5ff2..dd84344321b 100644 --- a/packages/cli/src/modules/agents/json-config/agent-config-composition.ts +++ b/packages/cli/src/modules/agents/json-config/agent-config-composition.ts @@ -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; - integrations: AgentIntegration[]; + integrations: AgentIntegrationConfig[]; } { const { integrations, ...schemaConfig } = config; return { schemaConfig, integrations: integrations ?? [] }; diff --git a/packages/cli/src/modules/agents/json-config/from-json-config.ts b/packages/cli/src/modules/agents/json-config/from-json-config.ts index 68ddde96149..e603b121bc7 100644 --- a/packages/cli/src/modules/agents/json-config/from-json-config.ts +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -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>, + refs: AgentJsonSkillConfig[], skills: Record, ): ConfiguredSkill[] { const seen = new Set(); diff --git a/packages/cli/src/modules/agents/json-config/integration-config.ts b/packages/cli/src/modules/agents/json-config/integration-config.ts deleted file mode 100644 index 90f135738bd..00000000000 --- a/packages/cli/src/modules/agents/json-config/integration-config.ts +++ /dev/null @@ -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; -export type AgentCredentialIntegrationConfig = z.infer; -export type AgentIntegrationConfig = z.infer; diff --git a/packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts b/packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts index 7ec5181d265..55a0040b52a 100644 --- a/packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts @@ -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 { diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts index 8ae5159ee4c..3a93d2ebe53 100644 --- a/packages/cli/src/modules/agents/repositories/agent.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -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() diff --git a/packages/cli/src/modules/agents/tools/node-tool-factory.ts b/packages/cli/src/modules/agents/tools/node-tool-factory.ts index bf76269aafe..e3a0ee95867 100644 --- a/packages/cli/src/modules/agents/tools/node-tool-factory.ts +++ b/packages/cli/src/modules/agents/tools/node-tool-factory.ts @@ -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; diff --git a/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts index bb226df1366..eebb617c7f4 100644 --- a/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts +++ b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts @@ -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 diff --git a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts index 32e0b356448..0e29a7ea4eb 100644 --- a/packages/cli/src/scaling/pubsub/pubsub.event-map.ts +++ b/packages/cli/src/scaling/pubsub/pubsub.event-map.ts @@ -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; }; /** diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts index 02495237ffe..a8e659730f4 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts @@ -104,7 +104,9 @@ const ElDialogStub = { const MODAL_NAME = 'AgentToolConfigModal'; -function toolRef(overrides: Partial = {}): AgentJsonToolRef { +function toolRef( + overrides: Partial> = {}, +): Extract { return { type: 'node', name: 'Slack', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsModal.test.ts index 42fa9298f09..8a0a1ed3c92 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsModal.test.ts @@ -252,8 +252,8 @@ describe('AgentToolsModal', () => { function toolRef( nodeType: string, - overrides: Partial = {}, - ): AgentJsonToolRef { + overrides: Partial['node']> = {}, + ): Extract { 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).mock.calls[0]; - const editedRef: AgentJsonToolRef = { + const editedRef: Extract = { ...tool, name: 'Slack renamed', }; diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts index 705b0be6cb1..c93f0ac1fe7 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/agentTelemetry.utils.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/agentTelemetry.utils.test.ts index 2ca64295372..a4f8718e16b 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/agentTelemetry.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/agentTelemetry.utils.test.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolRefAdapter.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolRefAdapter.test.ts index 76d98342dec..13f9fc11ec5 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolRefAdapter.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolRefAdapter.test.ts @@ -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 }), diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts index 1b3aa10edc7..80a3fee5ec1 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts @@ -9,12 +9,19 @@ vi.mock('@/app/composables/useTelemetry', () => ({ useTelemetry: () => ({ track: trackMock }), })); -function nodeRef(overrides: Partial = {}): AgentJsonToolRef { +function nodeRef( + overrides: Partial['node']> = {}, +): Extract { 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, + }, }; } diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue index e4f4b61bb73..357094b3d33 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue @@ -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}`, ); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentToolConfigModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentToolConfigModal.vue index 2aed2954bff..0d9987b2304 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentToolConfigModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentToolConfigModal.vue @@ -66,14 +66,17 @@ const activeView = ref<'config' | 'raw'>('config'); const initialNode = computed(() => 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) { />
(() => .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}`, })), ); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentToolsModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentToolsModal.vue index 90a73abd2b2..d388911f817 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentToolsModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentToolsModal.vue @@ -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) { diff --git a/packages/frontend/editor-ui/src/features/agents/components/WorkflowToolConfigContent.vue b/packages/frontend/editor-ui/src/features/agents/components/WorkflowToolConfigContent.vue index d88b48a255d..083eae6ba09 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/WorkflowToolConfigContent.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/WorkflowToolConfigContent.vue @@ -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<{ diff --git a/packages/frontend/editor-ui/src/features/agents/components/interactive/AskLlmCard.vue b/packages/frontend/editor-ui/src/features/agents/components/interactive/AskLlmCard.vue index 362fb010ec2..6bdd37f1bc2 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/interactive/AskLlmCard.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/interactive/AskLlmCard.vue @@ -1,12 +1,8 @@