mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
fix: Move agent types to api-types package (no-changelog) (#30484)
This commit is contained in:
parent
4336fffab8
commit
148bc89be9
|
|
@ -1,321 +0,0 @@
|
|||
import {
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AgentIntegrationSettings } from './dto/agents/agent-integration.dto';
|
||||
|
||||
/**
|
||||
* Describes a chat platform integration that agents can connect to.
|
||||
* Source of truth: the backend `ChatIntegrationRegistry`.
|
||||
*/
|
||||
export interface ChatIntegrationDescriptor {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
credentialTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node types a workflow can use as its trigger to be eligible as an agent
|
||||
* tool. Single source of truth for both the backend compatibility check
|
||||
* (`workflow-tool-factory.ts:SUPPORTED_TRIGGERS`) and the frontend Available
|
||||
* list's pre-filter. Body-node incompatibility (Wait / RespondToWebhook) is
|
||||
* enforced separately at save time.
|
||||
*/
|
||||
export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Node types in a workflow's body that disqualify it from being used as an
|
||||
* agent tool (execution model can't handle pause/respond-style nodes). Single
|
||||
* source of truth for the backend `validateCompatibility` check in
|
||||
* `workflow-tool-factory.ts` and the frontend pre-check in
|
||||
* `AgentToolsModal.vue` so the two sides can't drift.
|
||||
*/
|
||||
export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [
|
||||
'n8n-nodes-base.wait',
|
||||
'n8n-nodes-base.form',
|
||||
'n8n-nodes-base.respondToWebhook',
|
||||
] as const;
|
||||
|
||||
export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule';
|
||||
|
||||
/**
|
||||
* Source string recorded on agent executions invoked from a workflow via the
|
||||
* MessageAnAgent node. Mirrors the pattern set by chat/slack/schedule sources
|
||||
* so the session detail view can attribute thread origin uniformly.
|
||||
*/
|
||||
export const AGENT_WORKFLOW_TRIGGER_TYPE = 'workflow';
|
||||
|
||||
export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT =
|
||||
'Automated message: you were triggered on schedule.';
|
||||
|
||||
export interface AgentCredentialIntegration {
|
||||
type: string;
|
||||
credentialId: string;
|
||||
credentialName: string;
|
||||
settings?: AgentIntegrationSettings;
|
||||
}
|
||||
|
||||
export interface AgentScheduleIntegration {
|
||||
type: typeof AGENT_SCHEDULE_TRIGGER_TYPE;
|
||||
active: boolean;
|
||||
cronExpression: string;
|
||||
wakeUpPrompt: string;
|
||||
}
|
||||
|
||||
export type AgentIntegration = AgentCredentialIntegration | AgentScheduleIntegration;
|
||||
|
||||
export interface AgentScheduleConfig {
|
||||
active: boolean;
|
||||
cronExpression: string;
|
||||
wakeUpPrompt: string;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationStatusEntry {
|
||||
type: string;
|
||||
credentialId?: string;
|
||||
settings?: AgentIntegrationSettings;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationStatusResponse {
|
||||
status: 'connected' | 'disconnected';
|
||||
integrations: AgentIntegrationStatusEntry[];
|
||||
}
|
||||
|
||||
export function isAgentScheduleIntegration(
|
||||
integration: AgentIntegration | null | undefined,
|
||||
): integration is AgentScheduleIntegration {
|
||||
return integration?.type === AGENT_SCHEDULE_TRIGGER_TYPE;
|
||||
}
|
||||
|
||||
export function isAgentCredentialIntegration(
|
||||
integration: AgentIntegration | null | undefined,
|
||||
): integration is AgentCredentialIntegration {
|
||||
return (
|
||||
integration !== null &&
|
||||
integration !== undefined &&
|
||||
integration.type !== AGENT_SCHEDULE_TRIGGER_TYPE &&
|
||||
'credentialId' in integration &&
|
||||
typeof integration.credentialId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export interface NodeToolConfig {
|
||||
nodeType: string;
|
||||
nodeTypeVersion: number;
|
||||
nodeParameters?: Record<string, unknown>;
|
||||
credentials?: Record<string, { id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface BaseAgentJsonToolRef {
|
||||
name?: string;
|
||||
description?: string;
|
||||
workflow?: string;
|
||||
node?: NodeToolConfig;
|
||||
requireApproval?: boolean;
|
||||
allOutputs?: boolean;
|
||||
}
|
||||
|
||||
export type AgentJsonToolRef =
|
||||
| (BaseAgentJsonToolRef & {
|
||||
type: 'custom';
|
||||
id: string;
|
||||
})
|
||||
| (BaseAgentJsonToolRef & {
|
||||
type: 'workflow';
|
||||
id?: never;
|
||||
})
|
||||
| (BaseAgentJsonToolRef & {
|
||||
type: 'node';
|
||||
id?: never;
|
||||
});
|
||||
|
||||
export interface AgentJsonSkillRef {
|
||||
type: 'skill';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type AgentJsonConfigRef = AgentJsonToolRef | AgentJsonSkillRef;
|
||||
|
||||
export interface AgentSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export interface AgentSkillMutationResponse {
|
||||
id: string;
|
||||
skill: AgentSkill;
|
||||
versionId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentJsonConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Optional icon/emoji shown in the agent builder header. */
|
||||
icon?: { type: 'icon' | 'emoji'; value: string };
|
||||
model: string;
|
||||
credential?: string;
|
||||
instructions: string;
|
||||
memory?: {
|
||||
enabled: boolean;
|
||||
storage: 'n8n' | 'sqlite' | 'postgres';
|
||||
connection?: Record<string, unknown>;
|
||||
lastMessages?: number;
|
||||
semanticRecall?: {
|
||||
topK: number;
|
||||
scope?: 'thread' | 'resource';
|
||||
messageRange?: { before: number; after: number };
|
||||
embedder?: string;
|
||||
};
|
||||
};
|
||||
tools?: AgentJsonToolRef[];
|
||||
skills?: AgentJsonSkillRef[];
|
||||
providerTools?: Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* Triggers (scheduled execution + chat integrations) attached to this agent.
|
||||
* Mirrors the contents of `agent.integrations` storage column so the builder
|
||||
* can read and modify triggers through the same JSON config flow as tools.
|
||||
*/
|
||||
integrations?: AgentIntegration[];
|
||||
config?: {
|
||||
thinking?: {
|
||||
provider: 'anthropic' | 'openai';
|
||||
budgetTokens?: number;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
toolCallConcurrency?: number;
|
||||
nodeTools?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot of an agent at publish time. Returned by publish/unpublish
|
||||
* endpoints as part of the agent payload so the UI can derive publish state
|
||||
* (`not-published` / `published-no-changes` / `published-with-changes`) from
|
||||
* `agent.versionId` vs `publishedVersion.publishedFromVersionId`.
|
||||
*/
|
||||
export interface AgentPublishedVersionDto {
|
||||
schema: AgentJsonConfig | null;
|
||||
skills: Record<string, AgentSkill> | null;
|
||||
publishedFromVersionId: string;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
credentialId: string | null;
|
||||
publishedById: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single part inside a persisted chat/builder message. Mirrors the content
|
||||
* parts emitted by the agents SDK; known `type` values are enumerated for
|
||||
* autocomplete but the field is left open because new SDK versions may
|
||||
* introduce additional kinds.
|
||||
*/
|
||||
export interface AgentPersistedMessageContentPart {
|
||||
type: 'text' | 'reasoning' | 'tool-call' | (string & {});
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
toolCallId?: string;
|
||||
input?: unknown;
|
||||
state?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted chat/builder message shape returned by
|
||||
* `GET /projects/:projectId/agents/v2/:agentId/chat/messages` and
|
||||
* `GET /projects/:projectId/agents/v2/:agentId/build/messages`. The UI
|
||||
* converts these into its own display-oriented representation.
|
||||
*
|
||||
* Distinct from the request-body `AgentChatMessageDto` (a single outbound
|
||||
* message) — this is the history shape, one entry per persisted turn.
|
||||
*/
|
||||
export interface AgentPersistedMessageDto {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | (string & {});
|
||||
content: AgentPersistedMessageContentPart[];
|
||||
}
|
||||
// ─── Agent builder admin settings ─────────────────────────────────────────
|
||||
// The agent builder uses a model picked by the instance admin. By default it
|
||||
// runs through the n8n AI assistant proxy; admins can switch to a custom
|
||||
// provider + credential at any time.
|
||||
|
||||
/** Default model name used when the builder runs through the proxy or the env-var backstop. */
|
||||
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-5' as const;
|
||||
|
||||
export const agentBuilderModeSchema = z.enum(['default', 'custom']);
|
||||
export type AgentBuilderMode = z.infer<typeof agentBuilderModeSchema>;
|
||||
|
||||
/**
|
||||
* Discriminated union of the persisted admin settings.
|
||||
*
|
||||
* The builder defaults to the n8n AI assistant proxy. An admin can switch to
|
||||
* a custom provider/credential at any time. Provider id values must come from
|
||||
* the agent runtime's supported list (see `mapCredentialForProvider` on the
|
||||
* backend) — the schema accepts any non-empty string here so the api-types
|
||||
* package doesn't need to know the runtime list; the backend validates the
|
||||
* provider against the runtime mapper.
|
||||
*/
|
||||
export const agentBuilderAdminSettingsSchema = z.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('default') }),
|
||||
z.object({
|
||||
mode: z.literal('custom'),
|
||||
provider: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
modelName: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
export type AgentBuilderAdminSettings = z.infer<typeof agentBuilderAdminSettingsSchema>;
|
||||
|
||||
export const agentBuilderAdminSettingsResponseSchema = z.object({
|
||||
settings: agentBuilderAdminSettingsSchema,
|
||||
isConfigured: z.boolean(),
|
||||
});
|
||||
export type AgentBuilderAdminSettingsResponse = z.infer<
|
||||
typeof agentBuilderAdminSettingsResponseSchema
|
||||
>;
|
||||
|
||||
/** Body schema for the PATCH /agent-builder/settings endpoint. */
|
||||
export const AgentBuilderAdminSettingsUpdateDto = agentBuilderAdminSettingsSchema;
|
||||
export type AgentBuilderAdminSettingsUpdateRequest = AgentBuilderAdminSettings;
|
||||
|
||||
export const agentBuilderStatusResponseSchema = z.object({
|
||||
isConfigured: z.boolean(),
|
||||
});
|
||||
export type AgentBuilderStatusResponse = z.infer<typeof agentBuilderStatusResponseSchema>;
|
||||
|
||||
/**
|
||||
* One still-open interactive tool call, surfaced alongside persisted messages
|
||||
* so the FE can re-attach a `runId` to suspended interactive cards after a
|
||||
* page refresh.
|
||||
*/
|
||||
export interface AgentBuilderOpenSuspension {
|
||||
toolCallId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response body of `GET /projects/:projectId/agents/v2/:agentId/build/messages`.
|
||||
*
|
||||
* `messages` is the merged history (persisted memory + any in-flight checkpoint
|
||||
* messages). `openSuspensions` carries the runIds for every still-open
|
||||
* interactive tool call so the FE can resume them.
|
||||
*/
|
||||
export interface AgentBuilderMessagesResponse {
|
||||
messages: AgentPersistedMessageDto[];
|
||||
openSuspensions: AgentBuilderOpenSuspension[];
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
109
packages/@n8n/api-types/src/agents/agent-integration.schema.ts
Normal file
109
packages/@n8n/api-types/src/agents/agent-integration.schema.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const createCredIntegrationSchema = <
|
||||
Value extends string,
|
||||
Settings extends z.ZodTypeAny | z.ZodEffects<z.ZodTypeAny>,
|
||||
>(
|
||||
typeName: Value,
|
||||
settingsSchema: Settings,
|
||||
) =>
|
||||
z.object({
|
||||
type: z.literal<Value>(typeName),
|
||||
credentialId: z.string().min(1),
|
||||
settings: settingsSchema,
|
||||
});
|
||||
|
||||
const createSimpleIntegrationSchema = <Value extends string>(typeName: Value) =>
|
||||
z.object({
|
||||
type: z.literal<Value>(typeName),
|
||||
credentialId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const;
|
||||
|
||||
export const AgentTelegramSettingsSchema = z
|
||||
.object({
|
||||
accessMode: z.enum(AGENT_TELEGRAM_ACCESS_MODES),
|
||||
allowedUsers: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^@?[a-zA-Z0-9_]+$/,
|
||||
'Enter a valid Telegram user ID (numbers only) or username (letters, numbers, underscores)',
|
||||
),
|
||||
)
|
||||
.default([])
|
||||
.transform((items) => [...new Set(items)]),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((settings, ctx) => {
|
||||
if (settings.accessMode === 'private' && settings.allowedUsers.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['allowedUsers'],
|
||||
message: 'Add at least one Telegram user ID or username',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type AgentTelegramIntegrationSettings = z.infer<typeof AgentTelegramSettingsSchema>;
|
||||
|
||||
export const AgentIntegrationSettingsSchema = z.union([AgentTelegramSettingsSchema, z.undefined()]);
|
||||
export type AgentIntegrationSettings = z.infer<typeof AgentIntegrationSettingsSchema>;
|
||||
|
||||
export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule';
|
||||
|
||||
export const AgentScheduleIntegrationSchema = z
|
||||
.object({
|
||||
type: z.literal(AGENT_SCHEDULE_TRIGGER_TYPE),
|
||||
active: z.boolean(),
|
||||
cronExpression: z.string().min(1, 'cronExpression is required'),
|
||||
wakeUpPrompt: z.string().min(1, 'wakeUpPrompt is required'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const credentialIntegrations = [
|
||||
createCredIntegrationSchema('telegram', AgentTelegramSettingsSchema).extend({
|
||||
// keep optional for older agents
|
||||
settings: AgentTelegramSettingsSchema.optional(),
|
||||
}),
|
||||
createSimpleIntegrationSchema('slack'),
|
||||
createSimpleIntegrationSchema('linear'),
|
||||
] as const;
|
||||
|
||||
export const AgentCredentialIntegrationSchema = z.discriminatedUnion(
|
||||
'type',
|
||||
credentialIntegrations,
|
||||
);
|
||||
|
||||
export const AgentIntegrationSchema = z.discriminatedUnion('type', [
|
||||
...credentialIntegrations,
|
||||
AgentScheduleIntegrationSchema,
|
||||
]);
|
||||
|
||||
export type AgentIntegrationConfig = z.infer<typeof AgentIntegrationSchema>;
|
||||
export type AgentScheduleIntegrationConfig = z.infer<typeof AgentScheduleIntegrationSchema>;
|
||||
export type AgentCredentialIntegrationConfig = Exclude<
|
||||
AgentIntegrationConfig,
|
||||
{ type: typeof AGENT_SCHEDULE_TRIGGER_TYPE }
|
||||
>;
|
||||
|
||||
export type AgentScheduleIntegration = AgentScheduleIntegrationConfig;
|
||||
export type AgentCredentialIntegrationDto = AgentCredentialIntegrationConfig;
|
||||
export type AgentIntegration = AgentIntegrationConfig;
|
||||
|
||||
export function isAgentScheduleIntegration(
|
||||
integration: AgentIntegrationConfig | null | undefined,
|
||||
): integration is AgentScheduleIntegrationConfig {
|
||||
return integration?.type === AGENT_SCHEDULE_TRIGGER_TYPE;
|
||||
}
|
||||
|
||||
export function isAgentCredentialIntegration(
|
||||
integration: AgentIntegrationConfig | null | undefined,
|
||||
): integration is AgentCredentialIntegrationConfig {
|
||||
return (
|
||||
integration !== null && integration !== undefined && !isAgentScheduleIntegration(integration)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { z, type ZodError } from 'zod';
|
||||
|
||||
import { AgentIntegrationSchema } from './integration-config';
|
||||
import { AgentIntegrationSchema } from './agent-integration.schema';
|
||||
|
||||
const SemanticRecallSchema = z.object({
|
||||
topK: z.number().int().min(1).max(100),
|
||||
|
|
@ -14,7 +14,6 @@ const SemanticRecallSchema = z.object({
|
|||
embedder: z.string().optional(),
|
||||
});
|
||||
|
||||
// TODO: Create a list of all supported memory storages, define connection params for each storage
|
||||
const MemoryConfigSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
storage: z.enum(['n8n', 'sqlite', 'postgres']),
|
||||
|
|
@ -132,9 +131,12 @@ export const AgentJsonConfigPartialSchema = AgentJsonConfigSchema.partial();
|
|||
|
||||
export type AgentJsonConfig = z.infer<typeof AgentJsonConfigSchema>;
|
||||
export type AgentJsonToolConfig = z.infer<typeof AgentJsonToolConfigSchema>;
|
||||
export type AgentJsonWorkflowToolConfig = Extract<AgentJsonToolConfig, { type: 'workflow' }>;
|
||||
export type AgentJsonNodeToolConfig = Extract<AgentJsonToolConfig, { type: 'node' }>;
|
||||
export type AgentJsonCustomToolConfig = Extract<AgentJsonToolConfig, { type: 'custom' }>;
|
||||
export type AgentJsonSkillConfig = z.infer<typeof AgentJsonSkillConfigSchema>;
|
||||
export type AgentJsonConfigRef = AgentJsonToolConfig | AgentJsonSkillConfig;
|
||||
export type AgentJsonMemoryConfig = z.infer<typeof MemoryConfigSchema>;
|
||||
export type NodeToolConfig = z.infer<typeof NodeConfigSchema>;
|
||||
|
||||
export interface ConfigValidationError {
|
||||
path: string;
|
||||
|
|
@ -163,13 +165,6 @@ export function formatZodErrors(error: ZodError): ConfigValidationError[] {
|
|||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the built-in node tool chain (search_nodes, get_node_types,
|
||||
* list_credentials, run_node_tool) should be attached to an agent runtime.
|
||||
*
|
||||
* Absent or partial config defaults to disabled — only an explicit
|
||||
* `nodeTools: { enabled: true }` opts an agent in.
|
||||
*/
|
||||
export function isNodeToolsEnabled(config: AgentJsonConfig['config']): boolean {
|
||||
return config?.nodeTools?.enabled === true;
|
||||
}
|
||||
57
packages/@n8n/api-types/src/agents/dto.ts
Normal file
57
packages/@n8n/api-types/src/agents/dto.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { interactiveResumeDataSchema } from '../agent-builder-interactive';
|
||||
import { Z } from '../zod-class';
|
||||
|
||||
export class CreateAgentDto extends Z.class({
|
||||
name: z.string().min(1),
|
||||
}) {}
|
||||
|
||||
export class UpdateAgentDto extends Z.class({
|
||||
name: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
export class UpdateAgentConfigDto extends Z.class({
|
||||
config: z.record(z.unknown()),
|
||||
}) {}
|
||||
|
||||
export class UpdateAgentScheduleDto extends Z.class({
|
||||
cronExpression: z.string(),
|
||||
wakeUpPrompt: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
export const AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH = 10_000;
|
||||
|
||||
export const agentSkillSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
description: z.string().min(1).max(512),
|
||||
instructions: z.string().min(1).max(AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH),
|
||||
});
|
||||
|
||||
export class CreateAgentSkillDto extends Z.class({
|
||||
...agentSkillSchema.shape,
|
||||
}) {}
|
||||
|
||||
export class UpdateAgentSkillDto extends Z.class({
|
||||
name: agentSkillSchema.shape.name.optional(),
|
||||
description: agentSkillSchema.shape.description.optional(),
|
||||
instructions: agentSkillSchema.shape.instructions.optional(),
|
||||
}) {}
|
||||
|
||||
export class AgentChatMessageDto extends Z.class({
|
||||
message: z.string().min(1),
|
||||
sessionId: z.string().min(1).optional(),
|
||||
}) {}
|
||||
|
||||
export class AgentBuildResumeDto extends Z.class({
|
||||
runId: z.string().min(1),
|
||||
toolCallId: z.string().min(1),
|
||||
resumeData: interactiveResumeDataSchema,
|
||||
}) {}
|
||||
|
||||
export class AgentDisconnectIntegrationDto extends Z.class({
|
||||
type: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
}) {}
|
||||
28
packages/@n8n/api-types/src/agents/index.ts
Normal file
28
packages/@n8n/api-types/src/agents/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export * from './agent-integration.schema';
|
||||
export * from './agent-json-config.schema';
|
||||
export * from './dto';
|
||||
export * from './types';
|
||||
export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from '../agent-sse';
|
||||
export {
|
||||
ASK_LLM_TOOL_NAME,
|
||||
ASK_CREDENTIAL_TOOL_NAME,
|
||||
ASK_QUESTION_TOOL_NAME,
|
||||
interactiveToolNameSchema,
|
||||
askLlmInputSchema,
|
||||
askLlmResumeSchema,
|
||||
askCredentialInputSchema,
|
||||
askCredentialResumeSchema,
|
||||
askQuestionOptionSchema,
|
||||
askQuestionInputSchema,
|
||||
askQuestionResumeSchema,
|
||||
interactiveResumeDataSchema,
|
||||
type InteractiveToolName,
|
||||
type AskLlmInput,
|
||||
type AskLlmResume,
|
||||
type AskCredentialInput,
|
||||
type AskCredentialResume,
|
||||
type AskQuestionOption,
|
||||
type AskQuestionInput,
|
||||
type AskQuestionResume,
|
||||
type InteractiveResumeData,
|
||||
} from '../agent-builder-interactive';
|
||||
135
packages/@n8n/api-types/src/agents/types.ts
Normal file
135
packages/@n8n/api-types/src/agents/types.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import {
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AgentIntegrationSettings } from './agent-integration.schema';
|
||||
import type { AgentJsonConfig } from './agent-json-config.schema';
|
||||
|
||||
export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
] as const;
|
||||
|
||||
export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [
|
||||
'n8n-nodes-base.wait',
|
||||
'n8n-nodes-base.form',
|
||||
'n8n-nodes-base.respondToWebhook',
|
||||
] as const;
|
||||
|
||||
export const AGENT_WORKFLOW_TRIGGER_TYPE = 'workflow';
|
||||
|
||||
export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT =
|
||||
'Automated message: you were triggered on schedule.';
|
||||
|
||||
export interface ChatIntegrationDescriptor {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
credentialTypes: string[];
|
||||
}
|
||||
|
||||
export interface AgentScheduleConfig {
|
||||
active: boolean;
|
||||
cronExpression: string;
|
||||
wakeUpPrompt: string;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationStatusEntry {
|
||||
type: string;
|
||||
credentialId?: string;
|
||||
settings?: AgentIntegrationSettings;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationStatusResponse {
|
||||
status: 'connected' | 'disconnected';
|
||||
integrations: AgentIntegrationStatusEntry[];
|
||||
}
|
||||
|
||||
export interface AgentSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export interface AgentSkillMutationResponse {
|
||||
id: string;
|
||||
skill: AgentSkill;
|
||||
versionId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentPublishedVersionDto {
|
||||
schema: AgentJsonConfig | null;
|
||||
skills: Record<string, AgentSkill> | null;
|
||||
publishedFromVersionId: string;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
credentialId: string | null;
|
||||
publishedById: string | null;
|
||||
}
|
||||
|
||||
export interface AgentPersistedMessageContentPart {
|
||||
type: 'text' | 'reasoning' | 'tool-call' | (string & {});
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
toolCallId?: string;
|
||||
input?: unknown;
|
||||
state?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentPersistedMessageDto {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | (string & {});
|
||||
content: AgentPersistedMessageContentPart[];
|
||||
}
|
||||
|
||||
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-5' as const;
|
||||
|
||||
export const agentBuilderModeSchema = z.enum(['default', 'custom']);
|
||||
export type AgentBuilderMode = z.infer<typeof agentBuilderModeSchema>;
|
||||
|
||||
export const agentBuilderAdminSettingsSchema = z.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('default') }),
|
||||
z.object({
|
||||
mode: z.literal('custom'),
|
||||
provider: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
modelName: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
export type AgentBuilderAdminSettings = z.infer<typeof agentBuilderAdminSettingsSchema>;
|
||||
|
||||
export const agentBuilderAdminSettingsResponseSchema = z.object({
|
||||
settings: agentBuilderAdminSettingsSchema,
|
||||
isConfigured: z.boolean(),
|
||||
});
|
||||
export type AgentBuilderAdminSettingsResponse = z.infer<
|
||||
typeof agentBuilderAdminSettingsResponseSchema
|
||||
>;
|
||||
|
||||
export const AgentBuilderAdminSettingsUpdateDto = agentBuilderAdminSettingsSchema;
|
||||
export type AgentBuilderAdminSettingsUpdateRequest = AgentBuilderAdminSettings;
|
||||
|
||||
export const agentBuilderStatusResponseSchema = z.object({
|
||||
isConfigured: z.boolean(),
|
||||
});
|
||||
export type AgentBuilderStatusResponse = z.infer<typeof agentBuilderStatusResponseSchema>;
|
||||
|
||||
export interface AgentBuilderOpenSuspension {
|
||||
toolCallId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
export interface AgentBuilderMessagesResponse {
|
||||
messages: AgentPersistedMessageDto[];
|
||||
openSuspensions: AgentBuilderOpenSuspension[];
|
||||
}
|
||||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export const AGENT_TELEGRAM_ACCESS_MODES = ['private', 'public'] as const;
|
||||
|
||||
export const agentTelegramSettingsSchema = z
|
||||
.object({
|
||||
accessMode: z.enum(AGENT_TELEGRAM_ACCESS_MODES),
|
||||
// allowedUsers holds both Telegram user IDs (numeric strings, e.g. "487257961")
|
||||
// and usernames (alphanumeric + underscore, e.g. "@yokano" or "yokano"). Values
|
||||
// are stored verbatim — the leading "@" is NOT stripped here so user intent is
|
||||
// preserved. Normalization (stripping "@") happens only at access-check time in
|
||||
// TelegramIntegration.isUserAllowed().
|
||||
allowedUsers: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^@?[a-zA-Z0-9_]+$/,
|
||||
'Enter a valid Telegram user ID (numbers only) or username (letters, numbers, underscores)',
|
||||
),
|
||||
)
|
||||
.default([])
|
||||
.transform((items) => [...new Set(items)]),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((settings, ctx) => {
|
||||
if (settings.accessMode === 'private' && settings.allowedUsers.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['allowedUsers'],
|
||||
message: 'Add at least one Telegram user ID or username',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type AgentTelegramIntegrationSettings = z.infer<typeof agentTelegramSettingsSchema>;
|
||||
|
||||
export const agentIntegrationSettingsSchema = z.union([agentTelegramSettingsSchema, z.undefined()]);
|
||||
|
||||
export type AgentIntegrationSettings = z.infer<typeof agentIntegrationSettingsSchema>;
|
||||
|
||||
// TODO: discriminate settings by type of integration
|
||||
export class AgentIntegrationDto extends Z.class({
|
||||
type: z.string().min(1),
|
||||
credentialId: z.string().min(1),
|
||||
settings: agentIntegrationSettingsSchema.optional(),
|
||||
}) {}
|
||||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class CreateAgentDto extends Z.class({
|
||||
name: z.string().min(1),
|
||||
}) {}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class UpdateAgentConfigDto extends Z.class({
|
||||
config: z.record(z.unknown()),
|
||||
}) {}
|
||||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { AgentsService } from '../agents.service';
|
|||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
|
||||
import type { N8nMemory } from '../integrations/n8n-memory';
|
||||
import type { AgentJsonConfig } from '../json-config/agent-json-config';
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository';
|
||||
import type { AgentRepository } from '../repositories/agent.repository';
|
||||
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
|
||||
|
|
@ -71,6 +71,7 @@ function makeService(
|
|||
{ modules } as unknown as AgentsConfig,
|
||||
mock(),
|
||||
mock<Telemetry>(),
|
||||
mock(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ import { AgentsService } from '../agents.service';
|
|||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
|
||||
import type { N8nMemory } from '../integrations/n8n-memory';
|
||||
import type { AgentJsonConfig } from '../json-config/agent-json-config';
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import type { AgentRepository } from '../repositories/agent.repository';
|
||||
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
|
||||
import type { ChatIntegrationService } from '../integrations/chat-integration.service';
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
|
|
@ -80,6 +81,7 @@ describe('AgentsService — updateName / updateDescription schema sync', () => {
|
|||
{ modules: [] } as unknown as AgentsConfig,
|
||||
mock(),
|
||||
mock<Telemetry>(),
|
||||
mock<ChatIntegrationService>(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -31,18 +31,20 @@ const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, rout
|
|||
}));
|
||||
|
||||
function makeController({
|
||||
agentsService = mock<AgentsService>(),
|
||||
credentialsService = mock<CredentialsService>(),
|
||||
chatIntegrationService = mock<ChatIntegrationService>(),
|
||||
agentScheduleService = mock<AgentScheduleService>(),
|
||||
agentRepository = mock<AgentRepository>(),
|
||||
}: {
|
||||
agentsService?: jest.Mocked<AgentsService>;
|
||||
credentialsService?: jest.Mocked<CredentialsService>;
|
||||
chatIntegrationService?: jest.Mocked<ChatIntegrationService>;
|
||||
agentScheduleService?: jest.Mocked<AgentScheduleService>;
|
||||
agentRepository?: jest.Mocked<AgentRepository>;
|
||||
} = {}) {
|
||||
const controller = new AgentsController(
|
||||
mock<AgentsService>(),
|
||||
agentsService,
|
||||
mock<AgentsBuilderService>(),
|
||||
credentialsService,
|
||||
chatIntegrationService,
|
||||
|
|
@ -54,6 +56,7 @@ function makeController({
|
|||
|
||||
return {
|
||||
controller,
|
||||
agentsService,
|
||||
credentialsService,
|
||||
chatIntegrationService,
|
||||
agentScheduleService,
|
||||
|
|
@ -129,10 +132,10 @@ describe('AgentsController integration credentials', () => {
|
|||
{
|
||||
params: { projectId: 'project-1' },
|
||||
user: { id: 'user-1' },
|
||||
body: { type: 'slack', credentialId: 'cred-outside-project' },
|
||||
} as never,
|
||||
undefined as never,
|
||||
'agent-1',
|
||||
{ type: 'slack', credentialId: 'cred-outside-project' },
|
||||
),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
|
||||
|
|
@ -151,10 +154,10 @@ describe('AgentsController integration credentials', () => {
|
|||
{
|
||||
params: { projectId: 'project-1' },
|
||||
user: { id: 'user-1' },
|
||||
body: { type: 'telegram', credentialId: 'cred-telegram' },
|
||||
} as never,
|
||||
undefined as never,
|
||||
'agent-1',
|
||||
{ type: 'telegram', credentialId: 'cred-telegram' },
|
||||
),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
|
||||
|
|
@ -185,13 +188,14 @@ describe('AgentsController integration credentials', () => {
|
|||
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
|
||||
|
||||
const chatIntegrationService = mock<ChatIntegrationService>();
|
||||
const agentsService = mock<AgentsService>();
|
||||
const { controller } = makeController({
|
||||
agentsService,
|
||||
credentialsService,
|
||||
chatIntegrationService,
|
||||
agentRepository,
|
||||
});
|
||||
const settings = {
|
||||
type: 'telegram' as const,
|
||||
accessMode: 'private' as const,
|
||||
allowedUsers: ['123'],
|
||||
};
|
||||
|
|
@ -201,44 +205,36 @@ describe('AgentsController integration credentials', () => {
|
|||
{
|
||||
params: { projectId: 'project-1' },
|
||||
user: { id: 'user-1' },
|
||||
body: {
|
||||
type: 'telegram',
|
||||
credentialId: 'cred-telegram',
|
||||
settings,
|
||||
},
|
||||
} as never,
|
||||
undefined as never,
|
||||
'agent-1',
|
||||
{ type: 'telegram', credentialId: 'cred-telegram', settings },
|
||||
),
|
||||
).resolves.toEqual({ status: 'connected' });
|
||||
|
||||
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
'cred-telegram',
|
||||
'telegram',
|
||||
{
|
||||
type: 'telegram',
|
||||
credentialId: 'cred-telegram',
|
||||
settings,
|
||||
},
|
||||
'user-1',
|
||||
'project-1',
|
||||
{ settings },
|
||||
);
|
||||
expect(agentRepository.save).toHaveBeenCalledWith({
|
||||
...agent,
|
||||
integrations: [
|
||||
{
|
||||
type: 'telegram',
|
||||
credentialId: 'cred-telegram',
|
||||
credentialName: 'Telegram Bot',
|
||||
settings,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
'telegram',
|
||||
'cred-telegram',
|
||||
'connect',
|
||||
expect(agentsService.saveCredentialIntegration).toHaveBeenCalledWith(agent, {
|
||||
type: 'telegram',
|
||||
credentialId: 'cred-telegram',
|
||||
settings,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Telegram integrations from the persisted agent entry even when the live bridge is empty', async () => {
|
||||
const settings = {
|
||||
type: 'telegram' as const,
|
||||
accessMode: 'private' as const,
|
||||
allowedUsers: ['123'],
|
||||
};
|
||||
|
|
@ -250,7 +246,6 @@ describe('AgentsController integration credentials', () => {
|
|||
{
|
||||
type: 'telegram',
|
||||
credentialId: 'cred-telegram',
|
||||
credentialName: 'Telegram Bot',
|
||||
settings,
|
||||
},
|
||||
],
|
||||
|
|
@ -287,7 +282,13 @@ describe('AgentsController integration credentials', () => {
|
|||
),
|
||||
).resolves.toEqual({
|
||||
status: 'connected',
|
||||
integrations: [{ type: 'telegram', credentialId: 'cred-telegram', settings }],
|
||||
integrations: [
|
||||
{
|
||||
type: 'telegram',
|
||||
credentialId: 'cred-telegram',
|
||||
settings,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/unbound-method, id-denylist -- async mock stubs, unbound-method references and short `cb` names are acceptable test idioms */
|
||||
import type { AgentsConfig, GlobalConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegration } from '@n8n/api-types';
|
||||
import {
|
||||
DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT,
|
||||
type AgentIntegrationConfig,
|
||||
type AgentJsonConfig,
|
||||
} from '@n8n/api-types';
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
|
|
@ -25,7 +29,6 @@ import {
|
|||
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
|
||||
import type { N8nMemory } from '../integrations/n8n-memory';
|
||||
import type { AgentExecutionService } from '../agent-execution.service';
|
||||
import type { AgentJsonConfig } from '../json-config/agent-json-config';
|
||||
import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository';
|
||||
import type { AgentRepository } from '../repositories/agent.repository';
|
||||
|
||||
|
|
@ -76,6 +79,7 @@ describe('AgentsService', () => {
|
|||
let n8nCheckpointStorage: jest.Mocked<N8NCheckpointStorage>;
|
||||
let agentExecutionService: jest.Mocked<AgentExecutionService>;
|
||||
let scheduleService: jest.Mocked<AgentScheduleService>;
|
||||
let chatIntegrationService: jest.Mocked<ChatIntegrationService>;
|
||||
let publisher: jest.Mocked<Publisher>;
|
||||
let agentsConfig: AgentsConfig;
|
||||
let globalConfig: jest.Mocked<GlobalConfig>;
|
||||
|
|
@ -90,6 +94,7 @@ describe('AgentsService', () => {
|
|||
agentExecutionService = mock<AgentExecutionService>();
|
||||
agentExecutionService.recordMessage.mockResolvedValue('exec-id');
|
||||
scheduleService = mock<AgentScheduleService>();
|
||||
chatIntegrationService = mock<ChatIntegrationService>();
|
||||
publisher = mock<Publisher>();
|
||||
publisher.publishCommand.mockResolvedValue();
|
||||
agentsConfig = { modules: [] } as unknown as AgentsConfig;
|
||||
|
|
@ -121,6 +126,7 @@ describe('AgentsService', () => {
|
|||
agentsConfig,
|
||||
globalConfig,
|
||||
mock<Telemetry>(),
|
||||
chatIntegrationService,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -282,7 +288,6 @@ describe('AgentsService', () => {
|
|||
const slackIntegration = {
|
||||
type: 'slack',
|
||||
credentialId: 'cred-slack',
|
||||
credentialName: 'Slack workspace',
|
||||
} as const;
|
||||
const agent = makeAgent({
|
||||
integrations: [slackIntegration],
|
||||
|
|
@ -309,7 +314,6 @@ describe('AgentsService', () => {
|
|||
const slackIntegration = {
|
||||
type: 'slack',
|
||||
credentialId: 'cred-slack',
|
||||
credentialName: 'Slack workspace',
|
||||
} as const;
|
||||
const agent = makeAgent({
|
||||
integrations: [slackIntegration],
|
||||
|
|
@ -445,7 +449,7 @@ describe('AgentsService', () => {
|
|||
await service.updateConfig(agentId, projectId, minimalUpdate);
|
||||
|
||||
const savedEntity = agentRepository.save.mock.calls[0][0] as Agent;
|
||||
const savedSchema = savedEntity.schema as Record<string, unknown>;
|
||||
const savedSchema = savedEntity.schema as unknown as Record<string, unknown>;
|
||||
expect(savedSchema.instructions).toBe('Updated instructions');
|
||||
expect(savedSchema.description).toBe('previously stored description');
|
||||
expect(savedSchema.credential).toBe('cred-anthropic');
|
||||
|
|
@ -638,8 +642,8 @@ describe('AgentsService', () => {
|
|||
});
|
||||
|
||||
it('connects persisted credential integrations after publishing', async () => {
|
||||
const integrations: AgentIntegration[] = [
|
||||
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' },
|
||||
const integrations: AgentIntegrationConfig[] = [
|
||||
{ type: 'slack', credentialId: 'cred-1' },
|
||||
{
|
||||
type: 'schedule',
|
||||
active: false,
|
||||
|
|
@ -662,7 +666,7 @@ describe('AgentsService', () => {
|
|||
expect(chatIntegrationService.syncToConfig).toHaveBeenCalledWith(
|
||||
agent,
|
||||
[],
|
||||
[{ type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' }],
|
||||
[{ type: 'slack', credentialId: 'cred-1' }],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -821,7 +825,7 @@ describe('AgentsService', () => {
|
|||
});
|
||||
|
||||
it('deactivates the persisted schedule and stops the local cron job when unpublishing', async () => {
|
||||
const integrations: AgentIntegration[] = [
|
||||
const integrations: AgentIntegrationConfig[] = [
|
||||
{
|
||||
type: 'schedule',
|
||||
active: true,
|
||||
|
|
@ -1377,4 +1381,135 @@ describe('AgentsService', () => {
|
|||
await expect(service.unpublishAgent(agentId, projectId)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCredentialIntegration', () => {
|
||||
it('appends a new credential integration to an empty list', async () => {
|
||||
const agent = makeAgent({ integrations: [] });
|
||||
agentRepository.save.mockImplementation(async (a) => a as Agent);
|
||||
|
||||
const integration = {
|
||||
type: 'slack' as const,
|
||||
credentialId: 'cred-1',
|
||||
};
|
||||
|
||||
await service.saveCredentialIntegration(agent, integration);
|
||||
|
||||
expect(agentRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
integrations: [integration],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces an existing integration with the same type+credentialId', async () => {
|
||||
const existing = {
|
||||
type: 'slack' as const,
|
||||
credentialId: 'cred-1',
|
||||
};
|
||||
const agent = makeAgent({ integrations: [existing] });
|
||||
agentRepository.save.mockImplementation(async (a) => a as Agent);
|
||||
|
||||
const updated = {
|
||||
type: 'slack' as const,
|
||||
credentialId: 'cred-1',
|
||||
};
|
||||
|
||||
await service.saveCredentialIntegration(agent, updated);
|
||||
|
||||
expect(agentRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
integrations: [updated],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves schedule integrations when saving credential integrations', async () => {
|
||||
const schedule = {
|
||||
type: 'schedule' as const,
|
||||
active: false,
|
||||
cronExpression: '0 9 * * *',
|
||||
wakeUpPrompt: 'wake up',
|
||||
};
|
||||
const agent = makeAgent({ integrations: [schedule] });
|
||||
agentRepository.save.mockImplementation(async (a) => a as Agent);
|
||||
|
||||
const slack = {
|
||||
type: 'slack' as const,
|
||||
credentialId: 'cred-1',
|
||||
};
|
||||
|
||||
await service.saveCredentialIntegration(agent, slack);
|
||||
|
||||
expect(agentRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
integrations: [schedule, slack],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an integration missing credentialId', async () => {
|
||||
const agent = makeAgent({ integrations: [] });
|
||||
|
||||
await expect(
|
||||
service.saveCredentialIntegration(agent, {
|
||||
type: 'slack',
|
||||
} as never),
|
||||
).rejects.toThrow(/Invalid credential integration/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCredentialIntegration', () => {
|
||||
it('removes the matching credential integration', async () => {
|
||||
const slack = {
|
||||
type: 'slack' as const,
|
||||
credentialId: 'cred-1',
|
||||
};
|
||||
const schedule = {
|
||||
type: 'schedule' as const,
|
||||
active: false,
|
||||
cronExpression: '0 9 * * *',
|
||||
wakeUpPrompt: 'wake up',
|
||||
};
|
||||
const agent = makeAgent({ integrations: [slack, schedule] });
|
||||
agentRepository.save.mockImplementation(async (a) => a as Agent);
|
||||
|
||||
await service.removeCredentialIntegration(agent, 'slack', 'cred-1');
|
||||
|
||||
expect(agentRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
integrations: [schedule],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when integration does not exist', async () => {
|
||||
const agent = makeAgent({ integrations: [] });
|
||||
|
||||
const result = await service.removeCredentialIntegration(agent, 'slack', 'cred-1');
|
||||
|
||||
expect(agentRepository.save).not.toHaveBeenCalled();
|
||||
expect(result).toBe(agent);
|
||||
});
|
||||
|
||||
it('preserves other credential integrations', async () => {
|
||||
const slack = {
|
||||
type: 'slack' as const,
|
||||
credentialId: 'cred-1',
|
||||
};
|
||||
const linear = {
|
||||
type: 'linear' as const,
|
||||
credentialId: 'cred-2',
|
||||
};
|
||||
const agent = makeAgent({ integrations: [slack, linear] });
|
||||
agentRepository.save.mockImplementation(async (a) => a as Agent);
|
||||
|
||||
await service.removeCredentialIntegration(agent, 'slack', 'cred-1');
|
||||
|
||||
expect(agentRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
integrations: [linear],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }] : [];
|
||||
|
|
|
|||
|
|
@ -8,12 +8,21 @@ import type {
|
|||
import {
|
||||
AGENT_SCHEDULE_TRIGGER_TYPE,
|
||||
AGENT_WORKFLOW_TRIGGER_TYPE,
|
||||
type AgentPersistedMessageDto,
|
||||
AgentCredentialIntegrationSchema,
|
||||
AgentJsonConfigSchema,
|
||||
isAgentCredentialIntegration,
|
||||
isAgentScheduleIntegration,
|
||||
isNodeToolsEnabled,
|
||||
AgentModelSchema,
|
||||
type AgentCredentialIntegrationConfig,
|
||||
type AgentIntegrationConfig,
|
||||
type AgentJsonConfig,
|
||||
type AgentJsonMemoryConfig,
|
||||
type AgentJsonToolConfig,
|
||||
type AgentSkill,
|
||||
type AgentSkillMutationResponse,
|
||||
type ChatIntegrationDescriptor,
|
||||
AgentPersistedMessageDto,
|
||||
} from '@n8n/api-types';
|
||||
import * as agents from '@n8n/agents';
|
||||
import { extractFromAIParameters } from '@n8n/ai-utilities';
|
||||
|
|
@ -21,7 +30,6 @@ import { Logger } from '@n8n/backend-common';
|
|||
import { AgentsConfig, GlobalConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import {
|
||||
CredentialsRepository,
|
||||
ExecutionRepository,
|
||||
In,
|
||||
ProjectRelationRepository,
|
||||
|
|
@ -68,17 +76,6 @@ import { syncAgentIntegrations } from './integrations/integrations-sync';
|
|||
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
|
||||
import { N8nMemory } from './integrations/n8n-memory';
|
||||
import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition';
|
||||
import {
|
||||
AgentJsonConfigSchema,
|
||||
AgentModelSchema,
|
||||
isNodeToolsEnabled,
|
||||
} from './json-config/agent-json-config';
|
||||
import type {
|
||||
AgentJsonConfig,
|
||||
AgentJsonConfigRef,
|
||||
AgentJsonMemoryConfig,
|
||||
AgentJsonToolConfig,
|
||||
} from './json-config/agent-json-config';
|
||||
import {
|
||||
buildFromJson,
|
||||
type MemoryFactory,
|
||||
|
|
@ -88,6 +85,7 @@ import { AgentPublishedVersionRepository } from './repositories/agent-published-
|
|||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { AgentSecureRuntime } from './runtime/agent-secure-runtime';
|
||||
import { buildToolRegistry, type ToolRegistry } from './tool-registry';
|
||||
import { ChatIntegrationService } from './integrations/chat-integration.service';
|
||||
|
||||
type AgentToolEntries = Agent['tools'];
|
||||
|
||||
|
|
@ -268,6 +266,7 @@ export class AgentsService {
|
|||
private readonly agentsConfig: AgentsConfig,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly telemetry: Telemetry,
|
||||
private readonly chatIntegrationService: ChatIntegrationService,
|
||||
) {}
|
||||
|
||||
private isNodeToolsModuleEnabled(): boolean {
|
||||
|
|
@ -1329,51 +1328,6 @@ export class AgentsService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `credentialName` on credential integrations that were created
|
||||
* before the field was required. Looks up the name by `credentialId` and
|
||||
* splices it into the config; integrations that already have a name, or
|
||||
* aren't credential integrations at all, pass through untouched.
|
||||
*
|
||||
* If a credential id no longer resolves, the integration is left as-is —
|
||||
* validation will then fail with a clear "credentialName required" error
|
||||
* pointing at the orphaned integration, which is the correct outcome.
|
||||
*/
|
||||
private async healIntegrationCredentialNames(rawConfig: unknown): Promise<unknown> {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') return rawConfig;
|
||||
const cfg = rawConfig as { integrations?: unknown };
|
||||
if (!Array.isArray(cfg.integrations)) return rawConfig;
|
||||
|
||||
const missingIds = new Set<string>();
|
||||
for (const integration of cfg.integrations) {
|
||||
if (!integration || typeof integration !== 'object') continue;
|
||||
const i = integration as { credentialId?: unknown; credentialName?: unknown };
|
||||
if (typeof i.credentialId === 'string' && i.credentialName === undefined) {
|
||||
missingIds.add(i.credentialId);
|
||||
}
|
||||
}
|
||||
if (missingIds.size === 0) return rawConfig;
|
||||
|
||||
const credentials = await Container.get(CredentialsRepository).findBy({
|
||||
id: In(Array.from(missingIds)),
|
||||
});
|
||||
const namesById = new Map(credentials.map((c) => [c.id, c.name]));
|
||||
|
||||
const integrations: unknown[] = cfg.integrations;
|
||||
return {
|
||||
...cfg,
|
||||
integrations: integrations.map((integration: unknown): unknown => {
|
||||
if (!integration || typeof integration !== 'object') return integration;
|
||||
const i = integration as { credentialId?: unknown; credentialName?: unknown };
|
||||
if (typeof i.credentialId !== 'string' || i.credentialName !== undefined) {
|
||||
return integration;
|
||||
}
|
||||
const name = namesById.get(i.credentialId);
|
||||
return name ? { ...integration, credentialName: name } : integration;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new AgentJsonConfig (full replace).
|
||||
*/
|
||||
|
|
@ -1385,14 +1339,7 @@ export class AgentsService {
|
|||
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!entity) throw new NotFoundError('Agent not found');
|
||||
|
||||
// Repair integrations missing `credentialName`. Agents created before
|
||||
// the field was added to the schema return integrations of the form
|
||||
// `{ type, credentialId }` from `findById`; the UI sends them back
|
||||
// unchanged on save and validation rejects them as invalid. Look the
|
||||
// names up by id once here so the next save persists the full shape.
|
||||
const healedConfig = await this.healIntegrationCredentialNames(config);
|
||||
|
||||
const result = await this.validateConfig(healedConfig);
|
||||
const result = await this.validateConfig(config);
|
||||
if (!result.valid) {
|
||||
throw new UserError(`Invalid agent config: ${result.error}`);
|
||||
}
|
||||
|
|
@ -1458,7 +1405,7 @@ export class AgentsService {
|
|||
if (toolsProvided) {
|
||||
const referencedIds = new Set(
|
||||
(result.config.tools ?? [])
|
||||
.filter((t): t is Extract<AgentJsonConfigRef, { type: 'custom' }> => t.type === 'custom')
|
||||
.filter((t): t is Extract<AgentJsonToolConfig, { type: 'custom' }> => t.type === 'custom')
|
||||
.map((t) => t.id),
|
||||
);
|
||||
const orphanIds = Object.keys(entity.tools).filter((id) => !referencedIds.has(id));
|
||||
|
|
@ -1498,6 +1445,81 @@ export class AgentsService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a credential integration on the agent after validation.
|
||||
* Replaces an existing entry with the same type+credentialId or appends a new one.
|
||||
*/
|
||||
async saveCredentialIntegration(
|
||||
agent: Agent,
|
||||
integration: AgentCredentialIntegrationConfig,
|
||||
): Promise<Agent> {
|
||||
const parseResult = AgentCredentialIntegrationSchema.safeParse(integration);
|
||||
if (!parseResult.success) {
|
||||
throw new UserError(`Invalid credential integration: ${parseResult.error.message}`);
|
||||
}
|
||||
const validated = parseResult.data;
|
||||
const { type, credentialId } = validated;
|
||||
|
||||
const existing = agent.integrations ?? [];
|
||||
const alreadyExists = existing.some(
|
||||
(i) => isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId,
|
||||
);
|
||||
|
||||
agent.integrations = alreadyExists
|
||||
? existing.map((existingIntegration) =>
|
||||
isAgentCredentialIntegration(existingIntegration) &&
|
||||
existingIntegration.type === type &&
|
||||
existingIntegration.credentialId === credentialId
|
||||
? validated
|
||||
: existingIntegration,
|
||||
)
|
||||
: [...existing, validated];
|
||||
|
||||
const result = await this.agentRepository.save(agent);
|
||||
await this.chatIntegrationService.broadcastIntegrationChange(agent.id, integration, 'connect');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a credential integration from the agent.
|
||||
*/
|
||||
async removeCredentialIntegration(
|
||||
agent: Agent,
|
||||
type: string,
|
||||
credentialId: string,
|
||||
): Promise<Agent> {
|
||||
if (!agent.integrations?.length) return agent;
|
||||
const integration = agent.integrations.find(
|
||||
(i) => isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId,
|
||||
);
|
||||
if (!integration) return agent;
|
||||
// filter by ref
|
||||
agent.integrations = agent.integrations.filter((i) => i !== integration);
|
||||
|
||||
const result = await this.agentRepository.save(agent);
|
||||
await this.chatIntegrationService.broadcastIntegrationChange(
|
||||
agent.id,
|
||||
integration as AgentCredentialIntegrationConfig,
|
||||
'disconnect',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and persist the full integrations array on an agent.
|
||||
* Used internally by updateConfig and exposed for direct schedule/credential writes.
|
||||
*/
|
||||
private validateIntegrationRefs(integrations: AgentIntegrationConfig[], agent: Agent): void {
|
||||
const activeUnpublishedSchedule = integrations.some(
|
||||
(integration) => isAgentScheduleIntegration(integration) && integration.active,
|
||||
);
|
||||
if (activeUnpublishedSchedule && !agent.publishedVersion) {
|
||||
throw new UserError(
|
||||
'Invalid agent config: schedule integration cannot be active until the agent is published',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and persist a custom tool for an agent.
|
||||
* The tool code is described in an isolate, and the descriptor + code
|
||||
|
|
@ -1585,7 +1607,7 @@ export class AgentsService {
|
|||
// Remove from config tools array
|
||||
if (entity.schema?.tools) {
|
||||
entity.schema.tools = entity.schema.tools.filter(
|
||||
(t: AgentJsonConfigRef) => !(t.type === 'custom' && 'id' in t && t.id === toolId),
|
||||
(t: AgentJsonToolConfig) => !(t.type === 'custom' && 'id' in t && t.id === toolId),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1663,18 +1685,7 @@ export class AgentsService {
|
|||
);
|
||||
}
|
||||
|
||||
// Mirror AgentScheduleService.activate(): a schedule integration cannot be
|
||||
// active until the agent has a published version. Otherwise the persisted
|
||||
// config can claim active=true while the cron registration silently
|
||||
// refuses to register.
|
||||
const activeUnpublishedSchedule = (config.integrations ?? []).some(
|
||||
(integration) => isAgentScheduleIntegration(integration) && integration.active,
|
||||
);
|
||||
if (activeUnpublishedSchedule && !entity.publishedVersion) {
|
||||
throw new UserError(
|
||||
'Invalid agent config: schedule integration cannot be active until the agent is published',
|
||||
);
|
||||
}
|
||||
this.validateIntegrationRefs(config.integrations ?? [], entity);
|
||||
}
|
||||
|
||||
private getMissingCustomToolIds(
|
||||
|
|
@ -1682,7 +1693,7 @@ export class AgentsService {
|
|||
tools: AgentToolEntries,
|
||||
): string[] {
|
||||
const refs = (config?.tools ?? []).filter(
|
||||
(ref): ref is Extract<AgentJsonConfigRef, { type: 'custom' }> => ref.type === 'custom',
|
||||
(ref): ref is Extract<AgentJsonToolConfig, { type: 'custom' }> => ref.type === 'custom',
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const missing: string[] = [];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { JSONSchema7 } from 'json-schema';
|
|||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { RunnableAgentJsonConfigSchema } from '../json-config/agent-json-config';
|
||||
import { RunnableAgentJsonConfigSchema } from '@n8n/api-types';
|
||||
import { jsonSchemaToCompactText } from '../json-config/schema-text-serializer';
|
||||
|
||||
const BuilderPromptMemoryConfigSchema = z.object({
|
||||
|
|
@ -354,14 +354,14 @@ Two kinds:
|
|||
2. **Chat integrations** — connect the agent to a messaging platform. Multiple allowed.
|
||||
Shape:
|
||||
\`\`\`json
|
||||
{ "type": "slack", "credentialId": "<id>", "credentialName": "<name>" }
|
||||
{ "type": "slack", "credentialId": "<id>" }
|
||||
\`\`\`
|
||||
|
||||
### Workflow for adding integrations
|
||||
|
||||
1. Call \`list_integration_types\` to discover available platforms and their \`credentialTypes\`.
|
||||
2. For chat integrations: pick **one** entry from the \`credentialTypes\` array returned by \`list_integration_types\` (prefer the OAuth variant — e.g. \`slackOAuth2Api\` over \`slackApi\`) and pass it to \`ask_credential\` as the singular \`credentialType\` arg. It returns \`{ credentialId, credentialName }\`.
|
||||
3. Use \`patch_config\` (or \`write_config\`) to add an entry to \`integrations\`. For chat integrations, both \`credentialId\` and \`credentialName\` are required and must come from the \`ask_credential\` result. For schedule, write the cron expression directly.
|
||||
3. Use \`patch_config\` (or \`write_config\`) to add an entry to \`integrations\`. For chat integrations, only persist \`type\` and \`credentialId\`. For schedule, write the cron expression directly.
|
||||
|
||||
Never invent credential IDs or names. Always go through \`ask_credential\`.`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { agentTelegramSettingsSchema, type AgentIntegrationSettings } from '@n8n/api-types';
|
||||
import type { StreamChunk } from '@n8n/agents';
|
||||
import type { Author } from 'chat';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { UnexpectedError, type Logger } from 'n8n-workflow';
|
||||
import { type Logger } from 'n8n-workflow';
|
||||
|
||||
import { AgentChatBridge } from '../agent-chat-bridge';
|
||||
import {
|
||||
|
|
@ -12,6 +10,7 @@ import {
|
|||
type AgentChatIntegrationContext,
|
||||
} from '../agent-chat-integration';
|
||||
import type { ComponentMapper } from '../component-mapper';
|
||||
import type { AgentCredentialIntegrationConfig } from '@n8n/api-types';
|
||||
|
||||
type ChatBotLike = ConstructorParameters<typeof AgentChatBridge>[0];
|
||||
|
||||
|
|
@ -93,41 +92,25 @@ class StreamingTestIntegration extends AgentChatIntegration {
|
|||
}
|
||||
|
||||
// TODO: use real Telegram integration for testing
|
||||
class TelegramTestIntegration extends AgentChatIntegration {
|
||||
readonly type = 'telegram';
|
||||
readonly credentialTypes: string[] = [];
|
||||
readonly supportedComponents: string[] = [];
|
||||
readonly displayLabel = 'Telegram';
|
||||
readonly displayIcon = 'telegram';
|
||||
async createAdapter(_ctx: AgentChatIntegrationContext): Promise<unknown> {
|
||||
return {};
|
||||
}
|
||||
isUserAllowed(author: Author, settings: AgentIntegrationSettings | undefined): boolean {
|
||||
if (!settings) return true;
|
||||
const validConfig = agentTelegramSettingsSchema.safeParse(settings);
|
||||
if (!validConfig.success) {
|
||||
throw new UnexpectedError(
|
||||
`Invalid Telegram integration settings: ${validConfig.error.message}`,
|
||||
);
|
||||
}
|
||||
if (settings.accessMode === 'public') return true;
|
||||
return settings.allowedUsers.some((allowed) => {
|
||||
const normalized = allowed.startsWith('@') ? allowed.slice(1) : allowed;
|
||||
return normalized === author.userId || normalized === author.userName;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentChatBridge — consumeStream', () => {
|
||||
let registry: ChatIntegrationRegistry;
|
||||
const componentMapper = mock<ComponentMapper>();
|
||||
const logger = mock<Logger>();
|
||||
|
||||
const bufferedIntegration = {
|
||||
type: 'test-buffered',
|
||||
credentialId: 'cred-1',
|
||||
} as unknown as AgentCredentialIntegrationConfig;
|
||||
const streamingIntegration = {
|
||||
type: 'test-streaming',
|
||||
credentialId: 'cred-1',
|
||||
} as unknown as AgentCredentialIntegrationConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ChatIntegrationRegistry();
|
||||
registry.register(new BufferingTestIntegration());
|
||||
registry.register(new StreamingTestIntegration());
|
||||
registry.register(new TelegramTestIntegration());
|
||||
Container.set(ChatIntegrationRegistry, registry);
|
||||
});
|
||||
|
||||
|
|
@ -160,7 +143,7 @@ describe('AgentChatBridge — consumeStream', () => {
|
|||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'test-buffered',
|
||||
bufferedIntegration,
|
||||
);
|
||||
|
||||
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
|
||||
|
|
@ -194,7 +177,7 @@ describe('AgentChatBridge — consumeStream', () => {
|
|||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'test-buffered',
|
||||
bufferedIntegration,
|
||||
);
|
||||
|
||||
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
|
||||
|
|
@ -220,7 +203,7 @@ describe('AgentChatBridge — consumeStream', () => {
|
|||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'test-buffered',
|
||||
bufferedIntegration,
|
||||
);
|
||||
|
||||
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
|
||||
|
|
@ -246,7 +229,7 @@ describe('AgentChatBridge — consumeStream', () => {
|
|||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'test-streaming',
|
||||
streamingIntegration,
|
||||
);
|
||||
|
||||
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
|
||||
|
|
@ -256,126 +239,4 @@ describe('AgentChatBridge — consumeStream', () => {
|
|||
expect(received).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Telegram access settings', () => {
|
||||
it('silently ignores Telegram messages from users outside the private whitelist', async () => {
|
||||
const { bot, handlers } = makeBot();
|
||||
const thread = makeThread();
|
||||
const agentExecutor = {
|
||||
executeForChatPublished: jest.fn(() =>
|
||||
toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]),
|
||||
),
|
||||
resumeForChat: jest.fn(() => toStream([])),
|
||||
};
|
||||
|
||||
new AgentChatBridge(
|
||||
bot as unknown as ChatBotLike,
|
||||
'agent-1',
|
||||
agentExecutor as never,
|
||||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'telegram',
|
||||
{ accessMode: 'private', allowedUsers: ['123'] },
|
||||
);
|
||||
|
||||
await handlers.mention!(thread, {
|
||||
text: 'hi',
|
||||
author: { userId: '999', userName: 'stranger' },
|
||||
});
|
||||
|
||||
expect(thread.subscribe).not.toHaveBeenCalled();
|
||||
expect(thread.post).not.toHaveBeenCalled();
|
||||
expect(agentExecutor.executeForChatPublished).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows Telegram messages from users in the private whitelist', async () => {
|
||||
const { bot, handlers } = makeBot();
|
||||
const thread = makeThread();
|
||||
const agentExecutor = {
|
||||
executeForChatPublished: jest.fn(() =>
|
||||
toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]),
|
||||
),
|
||||
resumeForChat: jest.fn(() => toStream([])),
|
||||
};
|
||||
|
||||
new AgentChatBridge(
|
||||
bot as unknown as ChatBotLike,
|
||||
'agent-1',
|
||||
agentExecutor as never,
|
||||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'telegram',
|
||||
{ accessMode: 'private', allowedUsers: ['123'] },
|
||||
);
|
||||
|
||||
await handlers.mention!(thread, {
|
||||
text: 'hi',
|
||||
author: { userId: '123', userName: 'alloweduser' },
|
||||
});
|
||||
|
||||
expect(thread.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows legacy Telegram integrations without settings', async () => {
|
||||
const { bot, handlers } = makeBot();
|
||||
const thread = makeThread();
|
||||
const agentExecutor = {
|
||||
executeForChatPublished: jest.fn(() =>
|
||||
toStream([{ type: 'text-delta', id: 't1', delta: 'Hello' }]),
|
||||
),
|
||||
resumeForChat: jest.fn(() => toStream([])),
|
||||
};
|
||||
|
||||
new AgentChatBridge(
|
||||
bot as unknown as ChatBotLike,
|
||||
'agent-1',
|
||||
agentExecutor as never,
|
||||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'telegram',
|
||||
);
|
||||
|
||||
await handlers.mention!(thread, {
|
||||
text: 'hi',
|
||||
author: { userId: '999', userName: 'anyuser' },
|
||||
});
|
||||
|
||||
expect(agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('silently ignores Telegram actions from users outside the private whitelist', async () => {
|
||||
const { bot, handlers } = makeBot();
|
||||
const thread = makeThread();
|
||||
const agentExecutor = {
|
||||
executeForChatPublished: jest.fn(() => toStream([])),
|
||||
resumeForChat: jest.fn(() => toStream([])),
|
||||
};
|
||||
|
||||
new AgentChatBridge(
|
||||
bot as unknown as ChatBotLike,
|
||||
'agent-1',
|
||||
agentExecutor as never,
|
||||
componentMapper,
|
||||
logger,
|
||||
'project-1',
|
||||
'telegram',
|
||||
{ accessMode: 'private', allowedUsers: ['123'] },
|
||||
);
|
||||
|
||||
await handlers.action!({
|
||||
actionId: 'run-1:tool-1',
|
||||
value: JSON.stringify({ response: 'yes' }),
|
||||
thread,
|
||||
threadId: thread.id,
|
||||
user: { userId: '999', userName: 'stranger' },
|
||||
});
|
||||
|
||||
expect(agentExecutor.resumeForChat).not.toHaveBeenCalled();
|
||||
expect(thread.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
|
||||
import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegration } from '@n8n/api-types';
|
||||
import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegrationConfig } from '@n8n/api-types';
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
|
@ -12,7 +12,7 @@ import type { Agent } from '../../entities/agent.entity';
|
|||
import { AgentScheduleService } from '../agent-schedule.service';
|
||||
|
||||
function makePublishedAgent(
|
||||
integrations: AgentIntegration[] = [],
|
||||
integrations: AgentIntegrationConfig[] = [],
|
||||
overrides: Partial<Agent> = {},
|
||||
): Agent {
|
||||
return {
|
||||
|
|
@ -72,16 +72,14 @@ describe('AgentScheduleService', () => {
|
|||
});
|
||||
|
||||
it('saveConfig upserts the schedule integration alongside credential-backed integrations', async () => {
|
||||
const agent = makePublishedAgent([
|
||||
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' },
|
||||
]);
|
||||
const agent = makePublishedAgent([{ type: 'slack', credentialId: 'cred-1' }]);
|
||||
|
||||
const result = await service.saveConfig(agent, '* * * * *');
|
||||
|
||||
expect(agentRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
integrations: [
|
||||
{ type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' },
|
||||
{ type: 'slack', credentialId: 'cred-1' },
|
||||
{
|
||||
type: 'schedule',
|
||||
active: false,
|
||||
|
|
@ -116,6 +114,44 @@ describe('AgentScheduleService', () => {
|
|||
).rejects.toBeInstanceOf(BadRequestError);
|
||||
});
|
||||
|
||||
it('rejects various malformed cron patterns before saving', async () => {
|
||||
const malformed = ['* * *', '99 99 * * *', 'every-day', '0 0 0 0'];
|
||||
for (const cron of malformed) {
|
||||
await expect(service.saveConfig(makePublishedAgent(), cron)).rejects.toBeInstanceOf(
|
||||
BadRequestError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('saveConfig rejects an empty cron when the schedule is active', async () => {
|
||||
const agent = makePublishedAgent([
|
||||
{
|
||||
type: 'schedule',
|
||||
active: true,
|
||||
cronExpression: '* * * * *',
|
||||
wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.saveConfig(agent, '')).rejects.toBeInstanceOf(BadRequestError);
|
||||
});
|
||||
|
||||
it('saveConfig accepts empty cron when the schedule is inactive', async () => {
|
||||
const agent = makePublishedAgent([
|
||||
{
|
||||
type: 'schedule',
|
||||
active: false,
|
||||
cronExpression: '* * * * *',
|
||||
wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT,
|
||||
},
|
||||
]);
|
||||
agentRepository.save.mockImplementation(async (a: Agent) => a);
|
||||
|
||||
const result = await service.saveConfig(agent, '');
|
||||
|
||||
expect(result.cronExpression).toBe('');
|
||||
});
|
||||
|
||||
it('activate rejects unpublished agents', async () => {
|
||||
const agent = makePublishedAgent(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { AgentCredentialIntegration } from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
type AgentChatIntegrationContext,
|
||||
} from '../agent-chat-integration';
|
||||
import { ChatIntegrationService } from '../chat-integration.service';
|
||||
import type { AgentCredentialIntegrationConfig } from '@n8n/api-types';
|
||||
|
||||
/**
|
||||
* Test double — exposes the registry without invoking the real Chat SDK
|
||||
|
|
@ -54,10 +54,9 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|||
} as unknown as Agent;
|
||||
}
|
||||
|
||||
const slackIntegration: AgentCredentialIntegration = {
|
||||
const slackIntegration: AgentCredentialIntegrationConfig = {
|
||||
type: 'slack',
|
||||
credentialId: 'cred-1',
|
||||
credentialName: 'Acme Slack',
|
||||
};
|
||||
|
||||
function buildServiceWith(
|
||||
|
|
@ -122,7 +121,7 @@ describe('ChatIntegrationService.syncToConfig — publish gate', () => {
|
|||
|
||||
await service.syncToConfig(agent, [slackIntegration], []);
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalledWith('agent-1', 'slack', 'cred-1');
|
||||
expect(disconnectSpy).toHaveBeenCalledWith('agent-1', slackIntegration);
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -250,8 +249,8 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
agentRepository.findPublished.mockResolvedValue([
|
||||
makeAgent({
|
||||
integrations: [
|
||||
{ type: 'telegram', credentialId: 'c1', credentialName: 'Tg' },
|
||||
{ type: 'linear', credentialId: 'c2', credentialName: 'Ln' },
|
||||
{ type: 'telegram', credentialId: 'c1' },
|
||||
{ type: 'linear', credentialId: 'c2' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
|
@ -274,9 +273,13 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
// Followers must not run external hooks during startup reconnect — the
|
||||
// leader owns Telegram setWebhook etc., so a follower racing it would
|
||||
// just trip Telegram's 1/sec rate limit.
|
||||
expect(connectSpy).toHaveBeenCalledWith('agent-1', 'c2', 'linear', 'u1', 'project-1', {
|
||||
skipExternalHooks: true,
|
||||
});
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
{ type: 'linear', credentialId: 'c2' },
|
||||
'u1',
|
||||
'project-1',
|
||||
{ skipExternalHooks: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('connects every integration when this main is the leader and runs external hooks', async () => {
|
||||
|
|
@ -288,8 +291,8 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
agentRepository.findPublished.mockResolvedValue([
|
||||
makeAgent({
|
||||
integrations: [
|
||||
{ type: 'telegram', credentialId: 'c1', credentialName: 'Tg' },
|
||||
{ type: 'linear', credentialId: 'c2', credentialName: 'Ln' },
|
||||
{ type: 'telegram', credentialId: 'c1' },
|
||||
{ type: 'linear', credentialId: 'c2' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
|
@ -310,7 +313,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
|
||||
expect(connectSpy).toHaveBeenCalledTimes(2);
|
||||
for (const call of connectSpy.mock.calls) {
|
||||
expect(call[5]).toEqual({ skipExternalHooks: false });
|
||||
expect(call[4]).toEqual({ skipExternalHooks: false });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -321,7 +324,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
const agentRepository = mock<AgentRepository>();
|
||||
agentRepository.findPublished.mockResolvedValue([
|
||||
makeAgent({
|
||||
integrations: [{ type: 'linear', credentialId: 'c1', credentialName: 'Ln' }],
|
||||
integrations: [{ type: 'linear', credentialId: 'c1' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
@ -384,12 +387,14 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
|
||||
await service.handleIntegrationChanged({
|
||||
agentId: 'a1',
|
||||
type: 'linear',
|
||||
credentialId: 'c1',
|
||||
integration: { type: 'linear', credentialId: 'c1' },
|
||||
action: 'disconnect',
|
||||
});
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalledWith('a1', 'linear', 'c1');
|
||||
expect(disconnectSpy).toHaveBeenCalledWith('a1', {
|
||||
type: 'linear',
|
||||
credentialId: 'c1',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips connect for a leader-only integration on a follower', async () => {
|
||||
|
|
@ -402,8 +407,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
|
||||
await service.handleIntegrationChanged({
|
||||
agentId: 'a1',
|
||||
type: 'telegram',
|
||||
credentialId: 'c1',
|
||||
integration: { type: 'telegram', credentialId: 'c1' },
|
||||
action: 'connect',
|
||||
});
|
||||
|
||||
|
|
@ -431,17 +435,22 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
|
||||
await service.handleIntegrationChanged({
|
||||
agentId: 'a1',
|
||||
type: 'linear',
|
||||
credentialId: 'c1',
|
||||
integration: { type: 'linear', credentialId: 'c1' },
|
||||
action: 'connect',
|
||||
});
|
||||
|
||||
// External hooks (Telegram setWebhook, DB validation) already ran on
|
||||
// the originator — the peer must skip them to avoid duplicate API
|
||||
// calls and the resulting 429 rate-limit failure.
|
||||
expect(connectSpy).toHaveBeenCalledWith('a1', 'c1', 'linear', 'u1', 'p1', {
|
||||
skipExternalHooks: true,
|
||||
});
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
'a1',
|
||||
{ type: 'linear', credentialId: 'c1' },
|
||||
'u1',
|
||||
'p1',
|
||||
{
|
||||
skipExternalHooks: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through user list until one succeeds', async () => {
|
||||
|
|
@ -472,15 +481,20 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
|
||||
await service.handleIntegrationChanged({
|
||||
agentId: 'a1',
|
||||
type: 'linear',
|
||||
credentialId: 'c1',
|
||||
integration: { type: 'linear', credentialId: 'c1' },
|
||||
action: 'connect',
|
||||
});
|
||||
|
||||
expect(connectSpy).toHaveBeenCalledTimes(2);
|
||||
expect(connectSpy).toHaveBeenLastCalledWith('a1', 'c1', 'linear', 'u-with-access', 'p1', {
|
||||
skipExternalHooks: true,
|
||||
});
|
||||
expect(connectSpy).toHaveBeenLastCalledWith(
|
||||
'a1',
|
||||
{ type: 'linear', credentialId: 'c1' },
|
||||
'u-with-access',
|
||||
'p1',
|
||||
{
|
||||
skipExternalHooks: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when the agent has been deleted', async () => {
|
||||
|
|
@ -496,8 +510,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
|
||||
await service.handleIntegrationChanged({
|
||||
agentId: 'gone',
|
||||
type: 'linear',
|
||||
credentialId: 'c1',
|
||||
integration: { type: 'linear', credentialId: 'c1' },
|
||||
action: 'connect',
|
||||
});
|
||||
|
||||
|
|
@ -518,8 +531,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
|
||||
await service.handleIntegrationChanged({
|
||||
agentId: 'a1',
|
||||
type: 'linear',
|
||||
credentialId: 'c1',
|
||||
integration: { type: 'linear', credentialId: 'c1' },
|
||||
action: 'connect',
|
||||
});
|
||||
|
||||
|
|
@ -532,7 +544,11 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
const publisher = mock<Publisher>();
|
||||
const { service } = buildServiceWith({ multiMainEnabled: false, publisher });
|
||||
|
||||
await service.broadcastIntegrationChange('a1', 'linear', 'c1', 'connect');
|
||||
await service.broadcastIntegrationChange(
|
||||
'a1',
|
||||
{ type: 'linear', credentialId: 'c1' },
|
||||
'connect',
|
||||
);
|
||||
|
||||
expect(publisher.publishCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -541,14 +557,17 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
const publisher = mock<Publisher>();
|
||||
const { service } = buildServiceWith({ multiMainEnabled: true, publisher });
|
||||
|
||||
await service.broadcastIntegrationChange('a1', 'linear', 'c1', 'disconnect');
|
||||
await service.broadcastIntegrationChange(
|
||||
'a1',
|
||||
{ type: 'linear', credentialId: 'c1' },
|
||||
'disconnect',
|
||||
);
|
||||
|
||||
expect(publisher.publishCommand).toHaveBeenCalledWith({
|
||||
command: 'agent-chat-integration-changed',
|
||||
payload: {
|
||||
agentId: 'a1',
|
||||
type: 'linear',
|
||||
credentialId: 'c1',
|
||||
integration: { type: 'linear', credentialId: 'c1' },
|
||||
action: 'disconnect',
|
||||
},
|
||||
});
|
||||
|
|
@ -557,22 +576,23 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
it('publishes settings alongside a connect broadcast', async () => {
|
||||
const publisher = mock<Publisher>();
|
||||
const { service } = buildServiceWith({ multiMainEnabled: true, publisher });
|
||||
const settings = {
|
||||
type: 'telegram' as const,
|
||||
accessMode: 'private' as const,
|
||||
allowedUsers: ['123'],
|
||||
const integration: AgentCredentialIntegrationConfig = {
|
||||
type: 'telegram',
|
||||
credentialId: 'c1',
|
||||
settings: {
|
||||
accessMode: 'private' as const,
|
||||
allowedUsers: ['123'],
|
||||
},
|
||||
};
|
||||
|
||||
await service.broadcastIntegrationChange('a1', 'telegram', 'c1', 'connect', settings);
|
||||
await service.broadcastIntegrationChange('a1', integration, 'connect');
|
||||
|
||||
expect(publisher.publishCommand).toHaveBeenCalledWith({
|
||||
command: 'agent-chat-integration-changed',
|
||||
payload: {
|
||||
agentId: 'a1',
|
||||
type: 'telegram',
|
||||
credentialId: 'c1',
|
||||
integration,
|
||||
action: 'connect',
|
||||
settings,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -584,7 +604,7 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
|
|||
const { service } = buildServiceWith({ multiMainEnabled: true, publisher });
|
||||
|
||||
await expect(
|
||||
service.broadcastIntegrationChange('a1', 'linear', 'c1', 'connect'),
|
||||
service.broadcastIntegrationChange('a1', { type: 'linear', credentialId: 'c1' }, 'connect'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import type { AgentMessage, StreamChunk } from '@n8n/agents';
|
||||
import type { AgentIntegrationSettings } from '@n8n/api-types';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ActionEvent, Chat, Message, Thread, Author } from 'chat';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import type { ActionEvent, Author, Chat, Message, Thread } from 'chat';
|
||||
import type { Logger } from 'n8n-workflow';
|
||||
|
||||
import type { AgentsService } from '../agents.service';
|
||||
|
|
@ -11,7 +9,8 @@ import type { AgentChatIntegration } from './agent-chat-integration';
|
|||
import { ChatIntegrationRegistry } from './agent-chat-integration';
|
||||
import { CallbackStore } from './callback-store';
|
||||
import type { ComponentMapper } from './component-mapper';
|
||||
import { type TextEndFn, type TextYieldFn, type InternalThread, toInternalThreadId } from './types';
|
||||
import { type InternalThread, type TextEndFn, type TextYieldFn, toInternalThreadId } from './types';
|
||||
import type { AgentCredentialIntegrationConfig } from '@n8n/api-types';
|
||||
|
||||
interface AgentExecutor {
|
||||
executeForChatPublished(config: {
|
||||
|
|
@ -63,7 +62,7 @@ export class AgentChatBridge {
|
|||
private readonly disableStreaming: boolean;
|
||||
|
||||
/** Resolved integration for this platform (may be undefined for unknown types). */
|
||||
private readonly integration: AgentChatIntegration | undefined;
|
||||
private readonly integrationImpl: AgentChatIntegration | undefined;
|
||||
|
||||
/**
|
||||
* In-flight `rich_interaction` tool inputs keyed by toolCallId. Populated on
|
||||
|
|
@ -86,18 +85,13 @@ export class AgentChatBridge {
|
|||
private readonly componentMapper: ComponentMapper,
|
||||
private readonly logger: Logger,
|
||||
private readonly n8nProjectId: string,
|
||||
private readonly integrationType: string,
|
||||
private readonly integrationSettings?: AgentIntegrationSettings,
|
||||
private readonly integration: AgentCredentialIntegrationConfig,
|
||||
) {
|
||||
const integration = Container.get(ChatIntegrationRegistry).get(integrationType);
|
||||
if (!integration) {
|
||||
throw new UnexpectedError(`Unknown integration type: ${integrationType}`);
|
||||
}
|
||||
this.integration = integration;
|
||||
if (this.integration?.needsShortCallbackData) {
|
||||
this.integrationImpl = Container.get(ChatIntegrationRegistry).get(integration.type);
|
||||
if (this.integrationImpl?.needsShortCallbackData) {
|
||||
this.callbackStore = new CallbackStore();
|
||||
}
|
||||
this.disableStreaming = this.integration?.disableStreaming ?? false;
|
||||
this.disableStreaming = this.integrationImpl?.disableStreaming ?? false;
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
|
|
@ -112,8 +106,7 @@ export class AgentChatBridge {
|
|||
componentMapper: ComponentMapper,
|
||||
logger: Logger,
|
||||
n8nProjectId: string,
|
||||
integrationType: string,
|
||||
integrationSettings?: AgentIntegrationSettings,
|
||||
integration: AgentCredentialIntegrationConfig,
|
||||
): AgentChatBridge {
|
||||
const agentExecutor: AgentExecutor = {
|
||||
async *executeForChatPublished({ memory, agentId: aid, message, integrationType }) {
|
||||
|
|
@ -136,8 +129,7 @@ export class AgentChatBridge {
|
|||
componentMapper,
|
||||
logger,
|
||||
n8nProjectId,
|
||||
integrationType,
|
||||
integrationSettings,
|
||||
integration,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +173,7 @@ export class AgentChatBridge {
|
|||
}
|
||||
|
||||
private canUserAccess(author: Author): boolean {
|
||||
return this.integration?.isUserAllowed?.(author, this.integrationSettings) ?? true;
|
||||
return this.integrationImpl?.isUserAllowed?.(author, this.integration) ?? true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -199,7 +191,7 @@ export class AgentChatBridge {
|
|||
* helper so platform-specific formatting is never accidentally skipped.
|
||||
*/
|
||||
private resolveThreadId(thread: Thread<unknown, unknown>) {
|
||||
return toInternalThreadId(this.integration?.formatThreadId?.fromSdk(thread) ?? thread.id);
|
||||
return toInternalThreadId(this.integrationImpl?.formatThreadId?.fromSdk(thread) ?? thread.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -235,7 +227,7 @@ export class AgentChatBridge {
|
|||
projectId: this.n8nProjectId,
|
||||
message: text,
|
||||
memory: { threadId, resourceId: message.author.userId },
|
||||
integrationType: this.integrationType,
|
||||
integrationType: this.integration.type,
|
||||
});
|
||||
|
||||
await this.consumeStream(stream, thread);
|
||||
|
|
@ -521,7 +513,7 @@ export class AgentChatBridge {
|
|||
toolCallId,
|
||||
chunk.resumeSchema,
|
||||
this.getShortenCallback(),
|
||||
this.integrationType,
|
||||
this.integration.type,
|
||||
);
|
||||
await thread.post({ card });
|
||||
} catch (error) {
|
||||
|
|
@ -576,7 +568,7 @@ export class AgentChatBridge {
|
|||
toolCallId,
|
||||
riResumeSchema,
|
||||
this.getShortenCallback(),
|
||||
this.integrationType,
|
||||
this.integration.type,
|
||||
);
|
||||
await thread.post(card);
|
||||
} catch (error) {
|
||||
|
|
@ -657,7 +649,7 @@ export class AgentChatBridge {
|
|||
toolCallId,
|
||||
displayResumeSchema,
|
||||
this.getShortenCallback(),
|
||||
this.integrationType,
|
||||
this.integration.type,
|
||||
);
|
||||
await thread.post({ card });
|
||||
} catch (error) {
|
||||
|
|
@ -832,7 +824,7 @@ export class AgentChatBridge {
|
|||
runId,
|
||||
toolCallId,
|
||||
resumeData,
|
||||
integrationType: this.integrationType,
|
||||
integrationType: this.integration.type,
|
||||
});
|
||||
await this.consumeStream(stream, thread as Thread);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
AgentCredentialIntegrationConfig,
|
||||
isAgentCredentialIntegration,
|
||||
type AgentCredentialIntegration,
|
||||
type AgentIntegrationStatusResponse,
|
||||
type AgentIntegrationSettings,
|
||||
type AgentIntegrationStatusResponse,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
|
|
@ -92,23 +92,19 @@ export class ChatIntegrationService {
|
|||
*/
|
||||
async broadcastIntegrationChange(
|
||||
agentId: string,
|
||||
type: string,
|
||||
credentialId: string,
|
||||
integration: AgentCredentialIntegrationConfig,
|
||||
action: 'connect' | 'disconnect',
|
||||
settings?: AgentIntegrationSettings,
|
||||
): Promise<void> {
|
||||
if (!this.globalConfig.multiMainSetup.enabled) return;
|
||||
try {
|
||||
const payload = settings
|
||||
? { agentId, type, credentialId, action, settings }
|
||||
: { agentId, type, credentialId, action };
|
||||
const payload = { agentId, integration, action };
|
||||
await this.publisher.publishCommand({
|
||||
command: 'agent-chat-integration-changed',
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] Failed to publish ${action} for ${type} on agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`[ChatIntegrationService] Failed to publish ${action} for ${integration.type} on agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -139,30 +135,29 @@ export class ChatIntegrationService {
|
|||
*/
|
||||
async connect(
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
integrationType: string,
|
||||
integration: AgentCredentialIntegrationConfig,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
options: ConnectOptions = {},
|
||||
): Promise<void> {
|
||||
const key = this.connectionKey(agentId, integrationType, credentialId);
|
||||
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
|
||||
|
||||
// Tear down existing connection if reconnecting
|
||||
if (this.connections.has(key)) {
|
||||
await this.disconnectOne(key);
|
||||
}
|
||||
|
||||
const integration = this.integrationRegistry.require(integrationType);
|
||||
const integrationImpl = this.integrationRegistry.require(integration.type);
|
||||
|
||||
const user = await this.resolveUser(userId);
|
||||
|
||||
// Decrypt the integration credential to get platform tokens
|
||||
const decryptedData = await this.decryptCredential(credentialId, user);
|
||||
const decryptedData = await this.decryptCredential(integration.credentialId, user);
|
||||
|
||||
const ctx: AgentChatIntegrationContext = {
|
||||
agentId,
|
||||
projectId,
|
||||
credentialId,
|
||||
credentialId: integration.credentialId,
|
||||
credential: decryptedData,
|
||||
webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform),
|
||||
};
|
||||
|
|
@ -170,12 +165,12 @@ export class ChatIntegrationService {
|
|||
// Pre-connect hook — webhook-based platforms use this to detect
|
||||
// credential conflicts (e.g. a Telegram bot token already in use) and
|
||||
// abort the connect before we touch any external API.
|
||||
if (integration.onBeforeConnect && !options.skipExternalHooks) {
|
||||
await integration.onBeforeConnect(ctx);
|
||||
if (integrationImpl.onBeforeConnect && !options.skipExternalHooks) {
|
||||
await integrationImpl.onBeforeConnect(ctx);
|
||||
}
|
||||
|
||||
// Delegate adapter construction to the platform implementation.
|
||||
const adapter = await integration.createAdapter(ctx);
|
||||
const adapter = await integrationImpl.createAdapter(ctx);
|
||||
|
||||
// Dynamic imports — chat packages are ESM-only, use loader to bypass CJS transform
|
||||
const { Chat } = await loadChatSdk();
|
||||
|
|
@ -185,7 +180,7 @@ export class ChatIntegrationService {
|
|||
// bot.webhooks.slack maps correctly to the handler.
|
||||
const chat = new Chat({
|
||||
userName: `n8n-agent-${agentId}`,
|
||||
adapters: { [integrationType]: adapter } as Record<string, never>,
|
||||
adapters: { [integration.type]: adapter } as Record<string, never>,
|
||||
state: createMemoryState(),
|
||||
});
|
||||
|
||||
|
|
@ -204,8 +199,7 @@ export class ChatIntegrationService {
|
|||
componentMapper,
|
||||
this.logger,
|
||||
projectId,
|
||||
integrationType,
|
||||
options.settings,
|
||||
integration,
|
||||
);
|
||||
|
||||
// Initialize the Chat instance (connects adapters, state adapter, etc.)
|
||||
|
|
@ -213,9 +207,9 @@ export class ChatIntegrationService {
|
|||
|
||||
// Post-initialize hooks (e.g. Telegram setWebhook) run AFTER chat is live.
|
||||
// If one throws we must shut the chat down, otherwise adapters/timers leak.
|
||||
if (integration.onAfterConnect && !options.skipExternalHooks) {
|
||||
if (integrationImpl.onAfterConnect && !options.skipExternalHooks) {
|
||||
try {
|
||||
await integration.onAfterConnect(ctx);
|
||||
await integrationImpl.onAfterConnect(ctx);
|
||||
} catch (error) {
|
||||
await chat.shutdown().catch((shutdownError: unknown) => {
|
||||
this.logger.warn(
|
||||
|
|
@ -244,9 +238,14 @@ export class ChatIntegrationService {
|
|||
* If `type` and `credentialId` are provided, disconnects only that integration.
|
||||
* Otherwise disconnects all integrations for the agent.
|
||||
*/
|
||||
async disconnect(agentId: string, type?: string, credentialId?: string): Promise<void> {
|
||||
if (type && credentialId) {
|
||||
await this.disconnectOne(this.connectionKey(agentId, type, credentialId));
|
||||
async disconnect(
|
||||
agentId: string,
|
||||
integration?: { credentialId: string; type: string },
|
||||
): Promise<void> {
|
||||
if (integration) {
|
||||
await this.disconnectOne(
|
||||
this.connectionKey(agentId, integration.type, integration.credentialId),
|
||||
);
|
||||
} else {
|
||||
const keysToRemove = [...this.connections.keys()].filter((k) => k.startsWith(`${agentId}:`));
|
||||
for (const k of keysToRemove) {
|
||||
|
|
@ -306,29 +305,22 @@ export class ChatIntegrationService {
|
|||
*/
|
||||
async syncToConfig(
|
||||
agent: Agent,
|
||||
previous: AgentCredentialIntegration[],
|
||||
next: AgentCredentialIntegration[],
|
||||
previous: AgentCredentialIntegrationConfig[],
|
||||
next: AgentCredentialIntegrationConfig[],
|
||||
): Promise<void> {
|
||||
const key = (i: AgentCredentialIntegration) => `${i.type}:${i.credentialId}`;
|
||||
const key = (i: AgentCredentialIntegrationConfig) => `${i.type}:${i.credentialId}`;
|
||||
const previousKeys = new Set(previous.map(key));
|
||||
const nextKeys = new Set(next.map(key));
|
||||
|
||||
for (const integration of previous) {
|
||||
if (!nextKeys.has(key(integration))) {
|
||||
try {
|
||||
await this.disconnect(agent.id, integration.type, integration.credentialId);
|
||||
await this.broadcastIntegrationChange(
|
||||
agent.id,
|
||||
integration.type,
|
||||
integration.credentialId,
|
||||
'disconnect',
|
||||
);
|
||||
await this.disconnect(agent.id, integration);
|
||||
await this.broadcastIntegrationChange(agent.id, integration, 'disconnect');
|
||||
} catch (error) {
|
||||
this.logger.warn('[ChatIntegrationService] Disconnect during sync failed', {
|
||||
agentId: agent.id,
|
||||
type: integration.type,
|
||||
error,
|
||||
});
|
||||
this.logger.warn(
|
||||
`[ChatIntegrationService] Disconnect during sync failed for ${integration.type} on agent ${agent.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -356,14 +348,7 @@ export class ChatIntegrationService {
|
|||
let connected = false;
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await this.connect(
|
||||
agent.id,
|
||||
integration.credentialId,
|
||||
integration.type,
|
||||
userId,
|
||||
agent.projectId,
|
||||
integration.settings ? { settings: integration.settings } : undefined,
|
||||
);
|
||||
await this.connect(agent.id, integration, userId, agent.projectId);
|
||||
|
||||
connected = true;
|
||||
break;
|
||||
|
|
@ -377,13 +362,7 @@ export class ChatIntegrationService {
|
|||
}
|
||||
}
|
||||
if (connected) {
|
||||
await this.broadcastIntegrationChange(
|
||||
agent.id,
|
||||
integration.type,
|
||||
integration.credentialId,
|
||||
'connect',
|
||||
integration.settings,
|
||||
);
|
||||
await this.broadcastIntegrationChange(agent.id, integration, 'connect');
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'[ChatIntegrationService] Could not connect integration during sync — no project member had credential access',
|
||||
|
|
@ -499,14 +478,7 @@ export class ChatIntegrationService {
|
|||
let connected = false;
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await this.connect(
|
||||
agent.id,
|
||||
integration.credentialId,
|
||||
integration.type,
|
||||
userId,
|
||||
agent.projectId,
|
||||
options,
|
||||
);
|
||||
await this.connect(agent.id, integration, userId, agent.projectId, options);
|
||||
connected = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
|
|
@ -539,10 +511,11 @@ export class ChatIntegrationService {
|
|||
async handleIntegrationChanged(
|
||||
payload: PubSubCommandMap['agent-chat-integration-changed'],
|
||||
): Promise<void> {
|
||||
const { agentId, type, credentialId, action, settings: integrationSettings } = payload;
|
||||
const { agentId, integration, action } = payload;
|
||||
const { type, credentialId } = integration;
|
||||
|
||||
if (action === 'disconnect') {
|
||||
await this.disconnect(agentId, type, credentialId);
|
||||
await this.disconnect(agentId, integration);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -574,10 +547,8 @@ export class ChatIntegrationService {
|
|||
// Telegram setWebhook). We only need local state here — skipping
|
||||
// the hooks also avoids racing the originator into Telegram's 1/sec
|
||||
// rate limit.
|
||||
const options: ConnectOptions = integrationSettings
|
||||
? { skipExternalHooks: true, settings: integrationSettings }
|
||||
: { skipExternalHooks: true };
|
||||
await this.connect(agentId, credentialId, type, userId, agent.projectId, options);
|
||||
const options: ConnectOptions = { skipExternalHooks: true };
|
||||
await this.connect(agentId, integration, userId, agent.projectId, options);
|
||||
return;
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
|
|
@ -647,10 +618,10 @@ export class ChatIntegrationService {
|
|||
}
|
||||
|
||||
private connectOptionsFor(
|
||||
integration: AgentCredentialIntegration,
|
||||
integration: AgentCredentialIntegrationConfig,
|
||||
skipExternalHooks: boolean,
|
||||
): ConnectOptions {
|
||||
return integration.settings
|
||||
return 'settings' in integration
|
||||
? { skipExternalHooks, settings: integration.settings }
|
||||
: { skipExternalHooks };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import {
|
||||
isAgentScheduleIntegration,
|
||||
type AgentCredentialIntegration,
|
||||
type AgentIntegration,
|
||||
} from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import {
|
||||
type AgentCredentialIntegrationConfig,
|
||||
type AgentIntegrationConfig,
|
||||
isAgentScheduleIntegration,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
function scheduleConfigsEqual(
|
||||
a: AgentIntegration | undefined,
|
||||
b: AgentIntegration | undefined,
|
||||
a: AgentIntegrationConfig | undefined,
|
||||
b: AgentIntegrationConfig | undefined,
|
||||
): boolean {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
|
|
@ -37,8 +37,8 @@ function scheduleConfigsEqual(
|
|||
*/
|
||||
export async function syncAgentIntegrations(
|
||||
agent: Agent,
|
||||
previous: AgentIntegration[],
|
||||
next: AgentIntegration[],
|
||||
previous: AgentIntegrationConfig[],
|
||||
next: AgentIntegrationConfig[],
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const prevSchedule = previous.find(isAgentScheduleIntegration);
|
||||
|
|
@ -56,10 +56,10 @@ export async function syncAgentIntegrations(
|
|||
}
|
||||
|
||||
const prevChat = previous.filter(
|
||||
(i): i is AgentCredentialIntegration => !isAgentScheduleIntegration(i),
|
||||
(i): i is AgentCredentialIntegrationConfig => !isAgentScheduleIntegration(i),
|
||||
);
|
||||
const nextChat = next.filter(
|
||||
(i): i is AgentCredentialIntegration => !isAgentScheduleIntegration(i),
|
||||
(i): i is AgentCredentialIntegrationConfig => !isAgentScheduleIntegration(i),
|
||||
);
|
||||
try {
|
||||
// eslint-disable-next-line import-x/no-cycle
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { AgentIntegration } from '@n8n/api-types';
|
||||
import type { AgentIntegrationConfig, AgentJsonConfig } from '@n8n/api-types';
|
||||
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { AgentJsonConfig } from './agent-json-config';
|
||||
|
||||
/**
|
||||
* Build the unified `AgentJsonConfig` view from an agent entity. The schema
|
||||
|
|
@ -22,7 +21,7 @@ export function composeJsonConfig(agent: Agent): AgentJsonConfig | null {
|
|||
*/
|
||||
export function decomposeJsonConfig(config: AgentJsonConfig): {
|
||||
schemaConfig: Omit<AgentJsonConfig, 'integrations'>;
|
||||
integrations: AgentIntegration[];
|
||||
integrations: AgentIntegrationConfig[];
|
||||
} {
|
||||
const { integrations, ...schemaConfig } = config;
|
||||
return { schemaConfig, integrations: integrations ?? [] };
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ import type {
|
|||
JSONObject,
|
||||
} from '@n8n/agents';
|
||||
import { Agent, Memory, Tool, wrapToolForApproval } from '@n8n/agents';
|
||||
import type { AgentSkill } from '@n8n/api-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
AgentSkill,
|
||||
AgentJsonConfig,
|
||||
AgentJsonConfigRef,
|
||||
AgentJsonMemoryConfig,
|
||||
AgentJsonToolConfig,
|
||||
} from './agent-json-config';
|
||||
AgentJsonSkillConfig,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import { mapCredentialForProvider } from './credential-field-mapping';
|
||||
import { resolveProviderToolName } from './provider-tool-aliases';
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ export async function buildFromJson(
|
|||
type ConfiguredSkill = { id: string; skill: AgentSkill };
|
||||
|
||||
function getConfiguredSkills(
|
||||
refs: Array<Extract<AgentJsonConfigRef, { type: 'skill' }>>,
|
||||
refs: AgentJsonSkillConfig[],
|
||||
skills: Record<string, AgentSkill>,
|
||||
): ConfiguredSkill[] {
|
||||
const seen = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { agentIntegrationSettingsSchema } from '@n8n/api-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidCronExpression } from '../integrations/cron-validation';
|
||||
|
||||
export const AgentScheduleIntegrationSchema = z
|
||||
.object({
|
||||
type: z.literal('schedule'),
|
||||
active: z.boolean(),
|
||||
cronExpression: z
|
||||
.string()
|
||||
.min(1, 'cronExpression is required')
|
||||
.refine(isValidCronExpression, { message: 'Invalid cron expression' }),
|
||||
wakeUpPrompt: z.string().min(1, 'wakeUpPrompt is required'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const AgentCredentialIntegrationSchema = z
|
||||
.object({
|
||||
type: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((value) => value !== 'schedule', {
|
||||
message: 'Type "schedule" is reserved for the schedule trigger',
|
||||
}),
|
||||
credentialId: z.string().min(1),
|
||||
credentialName: z.string().min(1),
|
||||
settings: agentIntegrationSettingsSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const AgentIntegrationSchema = z.union([
|
||||
AgentScheduleIntegrationSchema,
|
||||
AgentCredentialIntegrationSchema,
|
||||
]);
|
||||
|
||||
export type AgentScheduleIntegrationConfig = z.infer<typeof AgentScheduleIntegrationSchema>;
|
||||
export type AgentCredentialIntegrationConfig = z.infer<typeof AgentCredentialIntegrationSchema>;
|
||||
export type AgentIntegrationConfig = z.infer<typeof AgentIntegrationSchema>;
|
||||
|
|
@ -5,7 +5,7 @@ import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPar
|
|||
|
||||
import { AgentPublishedVersion } from '../entities/agent-published-version.entity';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { AgentJsonConfig } from '../json-config/agent-json-config';
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
|
||||
@Service()
|
||||
export class AgentPublishedVersionRepository extends Repository<AgentPublishedVersion> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ const ElDialogStub = {
|
|||
|
||||
const MODAL_NAME = 'AgentToolConfigModal';
|
||||
|
||||
function toolRef(overrides: Partial<AgentJsonToolRef['node']> = {}): AgentJsonToolRef {
|
||||
function toolRef(
|
||||
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>> = {},
|
||||
): Extract<AgentJsonToolRef, { type: 'node' }> {
|
||||
return {
|
||||
type: 'node',
|
||||
name: 'Slack',
|
||||
|
|
|
|||
|
|
@ -252,8 +252,8 @@ describe('AgentToolsModal', () => {
|
|||
|
||||
function toolRef(
|
||||
nodeType: string,
|
||||
overrides: Partial<AgentJsonToolRef['node']> = {},
|
||||
): AgentJsonToolRef {
|
||||
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>['node']> = {},
|
||||
): Extract<AgentJsonToolRef, { type: 'node' }> {
|
||||
return {
|
||||
type: 'node',
|
||||
name: nodeType,
|
||||
|
|
@ -261,6 +261,7 @@ describe('AgentToolsModal', () => {
|
|||
nodeType,
|
||||
nodeTypeVersion: 1,
|
||||
credentials: { slackApi: { id: 'c', name: 'cred' } },
|
||||
nodeParameters: {},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
|
|
@ -483,7 +484,7 @@ describe('AgentToolsModal', () => {
|
|||
await fireEvent.click(gearButton!);
|
||||
|
||||
const [payload] = (uiStore.openModalWithData as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const editedRef: AgentJsonToolRef = {
|
||||
const editedRef: Extract<AgentJsonToolRef, { type: 'node' }> = {
|
||||
...tool,
|
||||
name: 'Slack renamed',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -9,12 +9,19 @@ vi.mock('@/app/composables/useTelemetry', () => ({
|
|||
useTelemetry: () => ({ track: trackMock }),
|
||||
}));
|
||||
|
||||
function nodeRef(overrides: Partial<AgentJsonToolRef['node']> = {}): AgentJsonToolRef {
|
||||
function nodeRef(
|
||||
overrides: Partial<Extract<AgentJsonToolRef, { type: 'node' }>['node']> = {},
|
||||
): Extract<AgentJsonToolRef, { type: 'node' }> {
|
||||
return {
|
||||
type: 'node',
|
||||
name: 'Slack',
|
||||
requireApproval: false,
|
||||
node: { nodeType: 'n8n-nodes-base.slack', nodeTypeVersion: 1, ...overrides },
|
||||
node: {
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
nodeTypeVersion: 1,
|
||||
nodeParameters: {},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -66,14 +66,17 @@ const activeView = ref<'config' | 'raw'>('config');
|
|||
const initialNode = computed<INode | null>(() =>
|
||||
isWorkflowTool.value || isCustomTool.value ? null : toolRefToNode(props.data.toolRef),
|
||||
);
|
||||
const initialName = computed(() => props.data.toolRef.name ?? initialNode.value?.name ?? '');
|
||||
const initialName = computed(() => {
|
||||
const toolName = props.data.toolRef.type === 'node' ? props.data.toolRef.name : undefined;
|
||||
return toolName ?? initialNode.value?.name ?? '';
|
||||
});
|
||||
const nodeName = ref(initialName.value);
|
||||
const customToolCode = computed(() => props.data.customTool?.code ?? '');
|
||||
const customToolTitle = computed(
|
||||
() =>
|
||||
props.data.customTool?.descriptor.name ??
|
||||
props.data.toolRef.name ??
|
||||
props.data.toolRef.id ??
|
||||
('name' in props.data.toolRef ? props.data.toolRef.name : undefined) ??
|
||||
('id' in props.data.toolRef ? props.data.toolRef.id : undefined) ??
|
||||
i18n.baseText('agents.builder.tree.customBadge'),
|
||||
);
|
||||
|
||||
|
|
@ -211,7 +214,7 @@ function handleNodeNameUpdate(name: string) {
|
|||
/>
|
||||
<div v-show="activeView === 'config'" :class="$style.configureTab">
|
||||
<WorkflowToolConfigContent
|
||||
v-if="isWorkflowTool"
|
||||
v-if="data.toolRef.type === 'workflow'"
|
||||
ref="workflowContentRef"
|
||||
:initial-ref="data.toolRef"
|
||||
@update:valid="handleValidUpdate"
|
||||
|
|
|
|||
|
|
@ -125,8 +125,7 @@ const customRows = computed<CustomRow[]>(() =>
|
|||
.filter((item): item is { ref: CustomToolRef; index: number } => item.ref.type === 'custom')
|
||||
.map(({ ref, index }) => ({
|
||||
index,
|
||||
label: ref.name?.trim() || ref.id || `Custom tool ${index + 1}`,
|
||||
description: ref.description,
|
||||
label: ref.id || `Custom tool ${index + 1}`,
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { N8nCard, N8nText, N8nIcon } from '@n8n/design-system';
|
||||
import type {
|
||||
AskLlmResume,
|
||||
ChatHubConversationModel,
|
||||
ChatHubProvider,
|
||||
ChatModelsResponse,
|
||||
} from '@n8n/api-types';
|
||||
import type { AskLlmResume } from '@n8n/api-types';
|
||||
import type { ChatHubConversationModel, ChatHubProvider, ChatModelsResponse } from '@n8n/api-types';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useChatStore } from '@/features/ai/chatHub/chat.store';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { ref, type Ref } from 'vue';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import {
|
||||
isAgentCredentialIntegration,
|
||||
isAgentScheduleIntegration,
|
||||
type AgentIntegrationStatusEntry,
|
||||
} from '@n8n/api-types';
|
||||
import { type AgentIntegrationStatusEntry, isAgentScheduleIntegration } from '@n8n/api-types';
|
||||
import {
|
||||
buildAgentConfigFingerprint,
|
||||
deriveAgentStatus,
|
||||
|
|
@ -92,7 +88,7 @@ function integrationStatusEntriesFromConfig(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (isAgentCredentialIntegration(integration)) {
|
||||
if (!isAgentScheduleIntegration(integration)) {
|
||||
entries.push({ type: integration.type, credentialId: integration.credentialId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ export function toolRefToNode(ref: AgentJsonToolRef): INode | null {
|
|||
}
|
||||
|
||||
/** Build a new `AgentJsonToolRef` for a node type the user just connected. */
|
||||
export function nodeTypeToNewToolRef(nodeType: INodeTypeDescription): AgentJsonToolRef {
|
||||
export function nodeTypeToNewToolRef(
|
||||
nodeType: INodeTypeDescription,
|
||||
): Extract<AgentJsonToolRef, { type: 'node' }> {
|
||||
const version = pickLatestVersion(nodeType.version);
|
||||
return {
|
||||
type: 'node',
|
||||
|
|
@ -116,7 +118,9 @@ export function updateToolRefFromNode(original: AgentJsonToolRef, node: INode):
|
|||
* because the backend's `buildWorkflowTool` looks workflows up by name scoped
|
||||
* to the project — see `cli/src/modules/agents/tools/workflow-tool-factory.ts`.
|
||||
*/
|
||||
export function workflowToNewToolRef(workflow: IWorkflowDb): AgentJsonToolRef {
|
||||
export function workflowToNewToolRef(
|
||||
workflow: IWorkflowDb,
|
||||
): Extract<AgentJsonToolRef, { type: 'workflow' }> {
|
||||
return {
|
||||
type: 'workflow',
|
||||
workflow: workflow.name,
|
||||
|
|
@ -136,7 +140,9 @@ export function getExistingToolNames(
|
|||
tools: AgentJsonToolRef[],
|
||||
exclude?: AgentJsonToolRef,
|
||||
): string[] {
|
||||
return tools.filter((t) => t !== exclude && Boolean(t.name)).map((t) => t.name as string);
|
||||
return tools
|
||||
.filter((t) => t !== exclude && t.type !== 'custom' && Boolean(t.name))
|
||||
.map((t) => (t as Extract<AgentJsonToolRef, { type: 'workflow' | 'node' }>).name!);
|
||||
}
|
||||
|
||||
/** Merge edits from the workflow config form back into the ref. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { BaseResource } from '@/Interface';
|
||||
import type { AgentJsonToolRef as ApiAgentJsonToolRef, AgentSkill } from '@n8n/api-types';
|
||||
import type { AgentJsonToolConfig, AgentSkill } from '@n8n/api-types';
|
||||
import type { Agent, ToolDescriptor, CustomToolEntry } from './agent.types';
|
||||
|
||||
export type { ToolDescriptor, CustomToolEntry, AgentSkill };
|
||||
|
|
@ -119,12 +119,13 @@ export interface ThinkingSchema {
|
|||
reasoningEffort?: string;
|
||||
}
|
||||
|
||||
export type WorkflowToolRef = ApiAgentJsonToolRef & { type: 'workflow' };
|
||||
export type WorkflowToolRef = AgentJsonToolConfig & { type: 'workflow' };
|
||||
|
||||
export type {
|
||||
NodeToolConfig,
|
||||
AgentJsonToolRef,
|
||||
AgentJsonSkillRef,
|
||||
AgentJsonConfigRef,
|
||||
AgentJsonToolConfig,
|
||||
AgentJsonToolConfig as AgentJsonToolRef,
|
||||
AgentJsonSkillConfig as AgentJsonSkillRef,
|
||||
AgentJsonConfig as AgentJsonConfigRef,
|
||||
AgentJsonConfig,
|
||||
} from '@n8n/api-types';
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
createAgentSkill,
|
||||
} from '../composables/useAgentApi';
|
||||
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
|
||||
import type { AgentResource, AgentJsonConfig, AgentJsonToolRef, AgentSkill } from '../types';
|
||||
import type { AgentResource, AgentJsonConfig, AgentJsonToolConfig, AgentSkill } from '../types';
|
||||
import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetry';
|
||||
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
|
||||
import { useAgentConfig } from '../composables/useAgentConfig';
|
||||
|
|
@ -636,7 +636,7 @@ function onOpenAddToolModal() {
|
|||
tools: localConfig.value?.tools ?? [],
|
||||
projectId: projectId.value,
|
||||
agentId: agentId.value,
|
||||
onConfirm: (tools: AgentJsonToolRef[]) => onConfigFieldUpdate({ tools }),
|
||||
onConfirm: (tools: AgentJsonToolConfig[]) => onConfigFieldUpdate({ tools }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -672,9 +672,9 @@ function onOpenToolFromList(index: number) {
|
|||
projectId: projectId.value,
|
||||
agentId: agentId.value,
|
||||
existingToolNames: tools
|
||||
.map((toolRef, i) => (i === index ? null : toolRef.name))
|
||||
.map((toolRef, i) => (i === index || toolRef.type === 'custom' ? null : toolRef.name))
|
||||
.filter((name): name is string => !!name),
|
||||
onConfirm: (updatedTool: AgentJsonToolRef) => {
|
||||
onConfirm: (updatedTool: AgentJsonToolConfig) => {
|
||||
const nextTools = [...(localConfig.value?.tools ?? [])];
|
||||
nextTools[index] = updatedTool;
|
||||
onConfigFieldUpdate({ tools: nextTools });
|
||||
|
|
@ -797,7 +797,7 @@ function onOpenAddSkillModal() {
|
|||
});
|
||||
}
|
||||
|
||||
function onQuickActionAddTool(tools: AgentJsonToolRef[]) {
|
||||
function onQuickActionAddTool(tools: AgentJsonToolConfig[]) {
|
||||
onConfigFieldUpdate({ tools });
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user