refactor(core): Simplify agent service functions (no-changelog) (#39331)

This commit is contained in:
Robin Braumann
2026-09-23 11:52:01 +00:00
committed by GitHub
parent da49251bf3
commit 80e29bf57c
119 changed files with 5935 additions and 5274 deletions
@@ -537,6 +537,7 @@ export const RunnableAgentJsonConfigSchema = AgentJsonConfigBaseSchema.extend({
});
export type AgentJsonConfig = z.infer<typeof AgentJsonConfigSchema>;
export type AgentModelCredentialConfig = Required<Pick<AgentJsonConfig, 'model' | 'credential'>>;
export type RunnableAgentJsonConfig = z.infer<typeof RunnableAgentJsonConfigSchema>;
export type AgentJsonToolConfig = z.infer<typeof AgentJsonToolConfigSchema>;
export type AgentJsonWorkflowToolConfig = Extract<AgentJsonToolConfig, { type: 'workflow' }>;
+3 -1
View File
@@ -6,6 +6,8 @@ import type { AgentBackgroundJobSignal } from './background-job';
export type AgentActor = 'user' | 'builder' | 'mcp';
export type AgentExecutionStatus = 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
export interface AgentSessionPreviewAccess {
canContinueInPreview: boolean;
}
@@ -361,7 +363,7 @@ export interface AgentPersistedMessageDto {
/** Agent-execution turn id when this message was produced from an execution transcript. */
executionId?: string;
/** Outcome of the execution that produced this message. */
executionStatus?: 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
executionStatus?: AgentExecutionStatus;
/**
* The recorded run error for a turn that ended in `error` or `interrupted`,
* so history renders the same error bubble the live stream showed.
+15
View File
@@ -0,0 +1,15 @@
/** Serialize work per key. A failed operation does not block later work. */
export async function runSerially<T>(
pending: Map<string, Promise<unknown>>,
key: string,
work: () => Promise<T>,
): Promise<T> {
const previous = pending.get(key) ?? Promise.resolve();
const run = previous.catch(() => {}).then(work);
pending.set(key, run);
try {
return await run;
} finally {
if (pending.get(key) === run) pending.delete(key);
}
}
@@ -7,6 +7,7 @@ import type { Push } from '@/push';
import type { RoleService } from '@/services/role.service';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { AgentPushRecipientsService } from '../agent-push-recipients.service';
import { AgentExecutionUpdateBroadcaster } from '../agent-execution-update-broadcaster';
import type { AgentExecutionThreadRepository } from '../repositories/agent-execution-thread.repository';
import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
@@ -46,12 +47,11 @@ describe('AgentExecutionUpdateBroadcaster', () => {
});
broadcaster = new AgentExecutionUpdateBroadcaster(
logger,
userRepository,
new AgentPushRecipientsService(userRepository, roleService),
push,
publisher,
instanceSettings,
threadRepository,
roleService,
);
});
@@ -9,6 +9,7 @@ import { OperationalError } from 'n8n-workflow';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { AgentChangePublisher } from '../agent-change-publisher.service';
import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
import { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal';
@@ -62,8 +63,7 @@ function makeService({
const service = new AgentRuntimeCacheService(
mockLogger(),
agentRepository,
publisher,
globalConfig,
new AgentChangePublisher(publisher, globalConfig, mockLogger()),
reconstructionService,
credentialsService,
sandboxRuntimeService,
@@ -510,8 +510,9 @@ describe('AgentRuntimeCacheService', () => {
const first = service.getRuntime({ agentId, projectId });
const second = service.getRuntime({ agentId, projectId });
await Promise.resolve();
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenCalledTimes(1);
await vi.waitFor(() =>
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenCalledTimes(1),
);
resolveRuntime(runtime);
const [firstRuntime, secondRuntime] = await Promise.all([first, second]);
@@ -27,6 +27,7 @@ import type { AiService } from '@/services/ai.service';
import type { Telemetry } from '@/telemetry';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AgentChangePublisher } from '../agent-change-publisher.service';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import { AgentConfigService } from '../agent-config.service';
import type { NodeToolAiGatewayService } from '../json-config/node-tool-ai-gateway.service';
@@ -237,8 +238,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
runtimeCacheService = new AgentRuntimeCacheService(
logger,
agentRepository,
publisher,
globalConfig,
new AgentChangePublisher(publisher, globalConfig, logger),
agentRuntimeReconstructionService,
credentialsService,
agentSandboxRuntimeService,
@@ -9,6 +9,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { AgentChangePublisher } from '../agent-change-publisher.service';
import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service';
import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service';
import { AgentTaskService } from '../agent-task.service';
@@ -151,7 +152,7 @@ describe('AgentTaskService', () => {
agentExecutionOrchestratorService,
mock<InstanceSettings>({ isLeader }),
agentTaskScheduler,
publisher,
new AgentChangePublisher(publisher, globalConfig, logger),
modificationTelemetry,
durableJobRegistrar,
mock<AgentUpdateBroadcaster>(),
@@ -7,6 +7,7 @@ import type { Push } from '@/push';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { RoleService } from '@/services/role.service';
import { AgentPushRecipientsService } from '../agent-push-recipients.service';
import { AgentUpdateBroadcaster } from '../agent-update-broadcaster';
const update = { projectId: 'project-1', agentId: 'agent-1', source: 'mcp' as const };
@@ -29,8 +30,7 @@ describe('AgentUpdateBroadcaster', () => {
});
broadcaster = new AgentUpdateBroadcaster(
logger,
userRepository,
roleService,
new AgentPushRecipientsService(userRepository, roleService),
push,
publisher,
instanceSettings,
@@ -1,7 +1,6 @@
import type { CredentialProvider, ResolvedCredential, CredentialListItem } from '@n8n/agents';
import type { CredentialsEntity, User } from '@n8n/db';
import { Container } from '@n8n/di';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
import type { CredentialsService } from '@/credentials/credentials.service';
@@ -9,6 +8,7 @@ import { CredentialsHelper } from '@/credentials-helper';
import { AiGatewayService } from '@/services/ai-gateway.service';
import type { AiGatewayModelCredentialResolver } from '../json-config/model-config';
import { decryptAgentCredential } from '../utils/decrypt-agent-credential';
function toResolvedCredential(data: unknown): ResolvedCredential {
const resolved = data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {};
@@ -70,42 +70,11 @@ export class AgentsCredentialProvider
throw new Error(`Credential "${credentialId}" not found or not accessible`);
}
const data = await this.decryptWithExpressions(credential);
return toResolvedCredential(data);
}
/**
* Decrypt through `CredentialsHelper.getDecrypted` — the path node execution
* takes — so expressions in credential fields are evaluated instead of
* reaching the caller as raw `={{ ... }}` strings. An external-secret-backed
* field would otherwise be sent verbatim and fail auth.
*
* Agents have no workflow or execution, so `additionalData` comes from
* `getBase()`, which supplies the secrets proxy and the project's variables.
*
* `internal` mode skips dynamic-credential resolution, which needs an
* execution context agents don't have. A per-user resolvable credential
* therefore falls back to its static stored data rather than failing — the
* same behaviour as before this path existed.
*/
private async decryptWithExpressions(
credential: CredentialsEntity,
): Promise<ICredentialDataDecryptedObject> {
// Imported lazily: `workflow-execute-additional-data` reaches back into
// this module to run agents from workflows.
// eslint-disable-next-line import-x/no-cycle
const { getBase } = await import('@/workflow-execute-additional-data.js');
const additionalData = await getBase({
const data = await decryptAgentCredential(Container.get(CredentialsHelper), credential, {
userId: this.user?.id,
projectId: this.projectId,
});
return await Container.get(CredentialsHelper).getDecrypted(
additionalData,
{ id: credential.id, name: credential.name },
credential.type,
'internal',
);
return toResolvedCredential(data);
}
/**
@@ -0,0 +1,44 @@
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { PubSub } from '@/scaling/pubsub/pubsub.types';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { agentChannelRef } from './utils/agent-channel';
type AgentChange = Extract<
PubSub.Command,
{
command:
| 'agent-config-changed'
| 'agent-tasks-changed'
| 'agent-chat-integration-changed'
| 'agent-chat-subscription-changed';
}
>;
@Service()
export class AgentChangePublisher {
constructor(
private readonly publisher: Publisher,
private readonly globalConfig: GlobalConfig,
private readonly logger: Logger,
) {}
async publish(change: AgentChange): Promise<void> {
if (!this.globalConfig.multiMainSetup.enabled) return;
try {
await this.publisher.publishCommand(change);
} catch (error) {
const { payload } = change;
this.logger.warn(`Failed to publish ${change.command}`, {
agentId: payload.agentId,
...('integration' in payload && agentChannelRef(payload.agentId, payload.integration)),
...('threadId' in payload && { threadId: payload.threadId }),
...('action' in payload && { action: payload.action }),
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -35,14 +35,6 @@ export interface StoreInboundAttachmentParams {
data: Buffer;
}
/** Reference passed from ingestion (controller/bridge) to the orchestrator. */
export interface StoredAttachmentRef {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}
/**
* One directory per attachment, so deleting a single file never touches
* others. Inline agents share the `agents/inline/` directory (a real agent id
@@ -21,6 +21,12 @@ import { getDelegatedChildCheckpoints } from './utils/delegated-child-checkpoint
type ExecutionContext = PubSubCommandMap['cancel-agent-chat-execution'];
export interface CancelSuspendedRunParams {
agentId: string;
runId: string;
resourceId: string;
}
export class AgentTurnAlreadyRunningError extends UserError {
constructor() {
super('A turn is already running in this conversation.');
@@ -200,11 +206,7 @@ export class AgentChatExecutionService {
});
}
async cancelSuspended(params: {
agentId: string;
runId: string;
resourceId: string;
}): Promise<boolean> {
async cancelSuspended(params: CancelSuspendedRunParams): Promise<boolean> {
const checkpointStatus = await this.checkpointStorage.getStatus(params.runId, params.agentId);
if (checkpointStatus.status === 'not-found' || checkpointStatus.checkpoint === undefined)
return false;
@@ -21,10 +21,8 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AgentsCredentialProvider } from './adapters/agents-credential-provider';
import {
AgentChatAttachmentService,
type StoredAttachmentRef,
} from './agent-chat-attachment.service';
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
import type { StoredAttachmentRef } from './types/agent-chat-attachment';
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
import { AgentExecutionRecordingError } from './agent-execution-recording.error';
import {
@@ -16,10 +16,10 @@ import { UserError } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import {
type AgentConfigPart,
AgentModificationTelemetryService,
diffAgentConfigParts,
isUnconfiguredAgent,
@@ -36,6 +36,7 @@ import { NodeToolAiGatewayService } from './json-config/node-tool-ai-gateway.ser
import { sanitizeUnknownAgentCredentials } from './json-config/sanitize-unknown-agent-credentials';
import { AgentTaskRepository } from './repositories/agent-task.repository';
import { AgentRepository } from './repositories/agent.repository';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import { normalizeWorkflowToolRefs } from './tools/workflow-tool-workflow-resolver';
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
import { getAgentConfigHash } from './utils/agent-config-hash';
@@ -46,6 +47,24 @@ import {
validateNodeToolExpressions,
} from './utils/node-tool-validation';
import { resolveUniqueSubAgents, type ResolvedSubAgentRef } from './utils/sub-agent-resolver';
import type { AgentsCredentialProvider } from './adapters/agents-credential-provider';
interface AgentConfigUpdateOptions {
/** Hash of the config the caller read before editing; `null` when the agent had none. */
baseConfigHash: string | null;
clearOmittedOptionalFields?: boolean;
modifiedBy: AgentActor;
/** Push connection of the tab that made the change; excluded from the `agentUpdated` broadcast. */
pushRef?: string;
}
interface ConfigReplacement {
nextSchema: AgentJsonConfig;
nextIntegrations: NonNullable<Agent['integrations']>;
previousSchema: AgentJsonConfig | null;
previousIntegrations: NonNullable<Agent['integrations']>;
changedParts: AgentConfigPart[];
}
@Service()
export class AgentConfigService {
@@ -68,8 +87,12 @@ export class AgentConfigService {
* Get the JSON config for an agent.
*/
async getConfig(agentId: string, projectId: string): Promise<AgentJsonConfig> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
const config = composeJsonConfig(entity);
if (!config) {
throw new UserError('Agent has no JSON config yet.');
@@ -148,23 +171,232 @@ export class AgentConfigService {
projectId: string,
config: unknown,
user: User,
options: {
/** Hash of the config the caller read before editing; `null` when the agent had none. */
baseConfigHash: string | null;
clearOmittedOptionalFields?: boolean;
modifiedBy: AgentActor;
/** Push connection of the tab that made the change; excluded from the `agentUpdated` broadcast. */
pushRef?: string;
},
options: AgentConfigUpdateOptions,
): Promise<AgentConfigMutationResponse> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
if (options.baseConfigHash !== getAgentConfigHash(composeJsonConfig(entity))) {
throw new ConflictError(
'Agent config was changed elsewhere; reload to get the latest version',
);
}
const { validatedConfig, credentialProvider, existingTaskIds } = await this.prepareConfig(
entity,
config,
user,
);
const replacement = this.buildConfigReplacement(entity, validatedConfig, config, options);
entity.schema = replacement.nextSchema;
entity.name = validatedConfig.name;
entity.integrations = replacement.nextIntegrations;
markAgentDraftDirty(entity);
this.removeUnreferencedResources(entity, validatedConfig);
const saved = await this.saveConfig(entity, credentialProvider, user, options, replacement);
return await this.finishConfigUpdate(saved, validatedConfig, existingTaskIds, replacement);
}
private async finishConfigUpdate(
saved: Agent,
validatedConfig: AgentJsonConfig,
existingTaskIds: string[],
replacement: ConfigReplacement,
): Promise<AgentConfigMutationResponse> {
await this.removeUnreferencedTasks(validatedConfig, existingTaskIds);
if (validatedConfig.integrations !== undefined) {
await syncAgentIntegrations(
saved,
replacement.previousIntegrations,
replacement.nextIntegrations,
this.logger,
);
}
const savedConfig = composeJsonConfig(saved) ?? validatedConfig;
return {
config: savedConfig,
configHash: getAgentConfigHash(savedConfig),
updatedAt: saved.updatedAt.toISOString(),
versionId: saved.versionId,
};
}
private async removeUnreferencedTasks(
config: AgentJsonConfig,
existingTaskIds: string[],
): Promise<void> {
if (config.tasks === undefined) return;
const referencedTaskIds = new Set(config.tasks.map((ref) => ref.id));
const orphanTaskIds = existingTaskIds.filter((id) => !referencedTaskIds.has(id));
if (orphanTaskIds.length > 0) await this.agentTaskRepository.delete(orphanTaskIds);
}
private async saveConfig(
entity: Agent,
credentialProvider: AgentsCredentialProvider,
user: User,
options: AgentConfigUpdateOptions,
replacement: ConfigReplacement,
) {
const { id: agentId, projectId } = entity;
const { changedParts, previousSchema, previousIntegrations } = replacement;
this.runtimeCacheService.clearRuntimes(agentId);
// Gate evaluated against the state about to be written; the marker is
// claimed and reported only once that write succeeded.
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
entity,
projectId,
credentialProvider,
user,
);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.eventService.emit('agent-saved', { agentId });
// Every config writer (editor, builder, MCP) lands here, so this is where
// other open Agent Builder tabs learn that their loaded config is stale.
this.agentUpdateBroadcaster.notify(
{ projectId, agentId, source: options.modifiedBy },
options.pushRef,
);
this.logger.debug('Updated agent JSON config', { agentId, projectId });
this.modificationTelemetry.record({
agent: saved,
projectId,
user,
by: options.modifiedBy,
changedParts,
wasUnconfigured: isUnconfiguredAgent(previousSchema, previousIntegrations),
});
await emitSetupCompleted?.();
return saved;
}
private removeUnreferencedResources(entity: Agent, config: AgentJsonConfig): void {
if (config.tools !== undefined) this.removeUnreferencedCustomTools(entity, config);
if (config.skills !== undefined)
this.agentSkillsService.removeUnreferencedSkills(entity, config);
}
private removeUnreferencedCustomTools(entity: Agent, config: AgentJsonConfig): void {
const referencedIds = new Set(
(config.tools ?? [])
.filter(
(tool): tool is Extract<AgentJsonToolConfig, { type: 'custom' }> =>
tool.type === 'custom',
)
.map((tool) => tool.id),
);
const orphanIds = Object.keys(entity.tools).filter((id) => !referencedIds.has(id));
if (orphanIds.length === 0) return;
const tools = { ...entity.tools };
for (const id of orphanIds) delete tools[id];
entity.tools = tools;
}
private buildConfigReplacement(
entity: Agent,
validatedConfig: AgentJsonConfig,
rawConfig: unknown,
options: AgentConfigUpdateOptions,
): ConfigReplacement {
const previousIntegrations = entity.integrations ?? [];
const previousSchema = entity.schema ?? null;
const { schemaConfig, integrations } = decomposeJsonConfig(validatedConfig);
const nextIntegrations =
validatedConfig.integrations !== undefined ? integrations : previousIntegrations;
const nextSchema = this.mergeConfigSchema(
schemaConfig,
previousSchema,
rawConfig,
options.clearOmittedOptionalFields,
);
// Compare before the entity is changed.
const changedParts = diffAgentConfigParts(
previousSchema,
nextSchema,
previousIntegrations,
nextIntegrations,
);
return { nextSchema, nextIntegrations, previousSchema, previousIntegrations, changedParts };
}
private mergeConfigSchema(
decomposedSchema: AgentJsonConfig,
previousSchema: AgentJsonConfig | null,
config: unknown,
clearOmitted: boolean | undefined,
): AgentJsonConfig {
// Under clearOmittedOptionalFields an omitted gradient is a deliberate
// removal, so the schema default wins instead of the previous gradient.
let nextPersonalisation = decomposedSchema.personalisation;
if (decomposedSchema.personalisation !== undefined && !clearOmitted) {
nextPersonalisation = mergePersonalisationWithPreviousGradient(
decomposedSchema.personalisation,
previousSchema,
config,
);
}
const nextSchema: AgentJsonConfig = {
...omitLegacyAgentDescription(previousSchema),
name: decomposedSchema.name,
model: decomposedSchema.model,
instructions: decomposedSchema.instructions,
...(decomposedSchema.credential !== undefined
? { credential: decomposedSchema.credential }
: {}),
...(decomposedSchema.personalisation !== undefined
? { personalisation: nextPersonalisation }
: {}),
...(decomposedSchema.memory !== undefined ? { memory: decomposedSchema.memory } : {}),
...(decomposedSchema.subAgents !== undefined
? { subAgents: decomposedSchema.subAgents }
: {}),
...(decomposedSchema.tools !== undefined ? { tools: decomposedSchema.tools } : {}),
...(decomposedSchema.skills !== undefined ? { skills: decomposedSchema.skills } : {}),
...(decomposedSchema.tasks !== undefined ? { tasks: decomposedSchema.tasks } : {}),
...(decomposedSchema.providerTools !== undefined
? { providerTools: decomposedSchema.providerTools }
: {}),
...(decomposedSchema.config !== undefined ? { config: decomposedSchema.config } : {}),
...(decomposedSchema.mcpServers !== undefined
? { mcpServers: decomposedSchema.mcpServers }
: {}),
...(decomposedSchema.vectorStores !== undefined
? { vectorStores: decomposedSchema.vectorStores }
: {}),
};
this.normalizeOptionalConfigFields(nextSchema, decomposedSchema, clearOmitted);
return nextSchema;
}
private normalizeOptionalConfigFields(
nextSchema: AgentJsonConfig,
validatedConfig: AgentJsonConfig,
clearOmitted: boolean | undefined,
): void {
if (validatedConfig.modelDeploymentName !== undefined) {
const deploymentName = validatedConfig.modelDeploymentName?.trim();
if (deploymentName) {
nextSchema.modelDeploymentName = deploymentName;
} else {
delete nextSchema.modelDeploymentName;
}
}
if (clearOmitted) {
clearOmittedOptionalFields(nextSchema, validatedConfig);
}
}
private async prepareConfig(entity: Agent, config: unknown, user: User) {
const { id: agentId, projectId } = entity;
const credentialProvider = createAgentCredentialProvider(
this.credentialsService,
projectId,
@@ -209,153 +441,7 @@ export class AgentConfigService {
new Set(existingTaskIds),
);
this.validateSubAgentRefs(resolvedSubAgents, entity);
const previousIntegrations = entity.integrations ?? [];
const previousSchema = entity.schema ?? null;
const integrationsProvided = validatedConfig.integrations !== undefined;
const toolsProvided = validatedConfig.tools !== undefined;
const skillsProvided = validatedConfig.skills !== undefined;
const credentialProvided = validatedConfig.credential !== undefined;
const modelDeploymentNameProvided = validatedConfig.modelDeploymentName !== undefined;
const personalisationProvided = validatedConfig.personalisation !== undefined;
const memoryProvided = validatedConfig.memory !== undefined;
const subAgentsProvided = validatedConfig.subAgents !== undefined;
const providerToolsProvided = validatedConfig.providerTools !== undefined;
const configBlockProvided = validatedConfig.config !== undefined;
const mcpServersProvided = validatedConfig.mcpServers !== undefined;
const vectorStoresProvided = validatedConfig.vectorStores !== undefined;
const { schemaConfig: decomposedSchema, integrations: decomposedIntegrations } =
decomposeJsonConfig(validatedConfig);
const nextIntegrations = integrationsProvided ? decomposedIntegrations : previousIntegrations;
// Under clearOmittedOptionalFields an omitted gradient is a deliberate
// removal, so the schema default wins instead of the previous gradient.
const nextPersonalisation = personalisationProvided
? options?.clearOmittedOptionalFields
? decomposedSchema.personalisation
: mergePersonalisationWithPreviousGradient(
decomposedSchema.personalisation,
previousSchema,
config,
)
: undefined;
const nextSchema: AgentJsonConfig = {
...omitLegacyAgentDescription(previousSchema),
name: decomposedSchema.name,
model: decomposedSchema.model,
instructions: decomposedSchema.instructions,
...(credentialProvided ? { credential: decomposedSchema.credential } : {}),
...(personalisationProvided ? { personalisation: nextPersonalisation } : {}),
...(memoryProvided ? { memory: decomposedSchema.memory } : {}),
...(subAgentsProvided ? { subAgents: decomposedSchema.subAgents } : {}),
...(toolsProvided ? { tools: decomposedSchema.tools } : {}),
...(skillsProvided ? { skills: decomposedSchema.skills } : {}),
...(tasksProvided ? { tasks: decomposedSchema.tasks } : {}),
...(providerToolsProvided ? { providerTools: decomposedSchema.providerTools } : {}),
...(configBlockProvided ? { config: decomposedSchema.config } : {}),
...(mcpServersProvided ? { mcpServers: decomposedSchema.mcpServers } : {}),
...(vectorStoresProvided ? { vectorStores: decomposedSchema.vectorStores } : {}),
};
if (modelDeploymentNameProvided) {
const deploymentName = decomposedSchema.modelDeploymentName?.trim();
if (deploymentName) {
nextSchema.modelDeploymentName = deploymentName;
} else {
delete nextSchema.modelDeploymentName;
}
}
if (options?.clearOmittedOptionalFields) {
clearOmittedOptionalFields(nextSchema, validatedConfig);
}
// Diffed against what is about to be written, before `entity` is mutated.
const changedParts = diffAgentConfigParts(
previousSchema,
nextSchema,
previousIntegrations,
nextIntegrations,
);
entity.schema = nextSchema;
entity.name = validatedConfig.name;
entity.integrations = nextIntegrations;
markAgentDraftDirty(entity);
if (toolsProvided) {
const referencedIds = new Set(
(validatedConfig.tools ?? [])
.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));
if (orphanIds.length > 0) {
const tools = { ...entity.tools };
for (const id of orphanIds) {
delete tools[id];
}
entity.tools = tools;
}
}
if (skillsProvided) {
this.agentSkillsService.removeUnreferencedSkills(entity, validatedConfig);
}
this.runtimeCacheService.clearRuntimes(agentId);
// Gate evaluated against the state about to be written; the marker is
// claimed and reported only once that write succeeded.
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
entity,
projectId,
credentialProvider,
user,
);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.eventService.emit('agent-saved', { agentId });
// Every config writer (editor, builder, MCP) lands here, so this is where
// other open Agent Builder tabs learn that their loaded config is stale.
this.agentUpdateBroadcaster.notify(
{ projectId, agentId, source: options.modifiedBy },
options.pushRef,
);
this.logger.debug('Updated agent JSON config', { agentId, projectId });
this.modificationTelemetry.record({
agent: saved,
projectId,
user,
by: options.modifiedBy,
changedParts,
wasUnconfigured: isUnconfiguredAgent(previousSchema, previousIntegrations),
});
await emitSetupCompleted?.();
if (tasksProvided) {
const referencedTaskIds = new Set((validatedConfig.tasks ?? []).map((ref) => ref.id));
const orphanTaskIds = existingTaskIds.filter((id) => !referencedTaskIds.has(id));
if (orphanTaskIds.length > 0) {
await this.agentTaskRepository.delete(orphanTaskIds);
}
}
if (integrationsProvided) {
await syncAgentIntegrations(saved, previousIntegrations, nextIntegrations, this.logger);
}
const savedConfig = composeJsonConfig(saved) ?? validatedConfig;
return {
config: savedConfig,
configHash: getAgentConfigHash(savedConfig),
updatedAt: saved.updatedAt.toISOString(),
versionId: saved.versionId,
};
return { validatedConfig, credentialProvider, existingTaskIds };
}
private async removeMissingConfigRefs(
@@ -9,18 +9,17 @@ import { Service } from '@n8n/di';
import isEqual from 'lodash/isEqual';
import { UserError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import {
AgentModificationTelemetryService,
type AgentMutationTelemetryContext,
diffAgentConfigParts,
buildAgentMutationEvent,
captureAgentMutation,
} from './agent-modification-telemetry.service';
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
import type { Agent } from './entities/agent.entity';
import { AgentRepository } from './repositories/agent.repository';
import { isUnconfiguredAgent } from './utils/agent-capabilities';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
type AgentToolEntries = Agent['tools'];
@@ -48,8 +47,12 @@ export class AgentCustomToolsService {
context: AgentMutationTelemetryContext,
options: { recordTelemetry?: boolean } = {},
): Promise<{ ok: boolean; id: string; descriptor: ToolDescriptor }> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
if (!CUSTOM_TOOL_ID_REGEX.test(descriptor.name)) {
throw new UserError(
@@ -63,37 +66,18 @@ export class AgentCustomToolsService {
return { ok: true, id: toolId, descriptor };
}
const previousSchema = entity.schema ?? null;
const previousIntegrations = entity.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(entity);
entity.tools = {
...entity.tools,
[toolId]: nextEntry,
};
markAgentDraftDirty(entity);
this.runtimeCacheService.clearRuntimes(agentId);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.agentUpdateBroadcaster.notify(
{ projectId, agentId, source: context.modifiedBy },
context.pushRef,
);
const saved = await this.saveToolChanges(entity, projectId, context);
if (options.recordTelemetry !== false) {
this.modificationTelemetry.record({
agent: saved,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
saved.schema,
previousIntegrations,
saved.integrations ?? [],
{ tools: true },
),
wasUnconfigured,
});
this.modificationTelemetry.record(
buildAgentMutationEvent(saved, projectId, context, previous, { tools: true }),
);
}
this.logger.debug('Built custom tool', { agentId, projectId, toolId });
@@ -110,13 +94,15 @@ export class AgentCustomToolsService {
toolId: string,
context: AgentMutationTelemetryContext,
): Promise<void> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
if (!entity.tools?.[toolId]) return;
const previousSchema = entity.schema ?? null;
const previousIntegrations = entity.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(entity);
const tools = { ...entity.tools };
delete tools[toolId];
@@ -128,27 +114,10 @@ export class AgentCustomToolsService {
);
}
markAgentDraftDirty(entity);
this.runtimeCacheService.clearRuntimes(agentId);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.agentUpdateBroadcaster.notify(
{ projectId, agentId, source: context.modifiedBy },
context.pushRef,
const saved = await this.saveToolChanges(entity, projectId, context);
this.modificationTelemetry.record(
buildAgentMutationEvent(saved, projectId, context, previous, { tools: true }),
);
this.modificationTelemetry.record({
agent: saved,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
saved.schema,
previousIntegrations,
saved.integrations ?? [],
{ tools: true },
),
wasUnconfigured,
});
this.logger.debug('Deleted custom tool', { agentId, projectId, toolId });
}
@@ -190,4 +159,18 @@ export class AgentCustomToolsService {
}
return snapshot;
}
private async saveToolChanges(
entity: Agent,
projectId: string,
context: AgentMutationTelemetryContext,
): Promise<Agent> {
markAgentDraftDirty(entity);
this.runtimeCacheService.clearRuntimes(entity.id);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.agentUpdateBroadcaster.notify(
{ projectId, agentId: entity.id, source: context.modifiedBy },
context.pushRef,
);
return saved;
}
}
@@ -1,5 +1,5 @@
import type { CredentialListItem } from '@n8n/agents';
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import { AI_GATEWAY_MANAGED_TAG, type AgentModelCredentialConfig } from '@n8n/api-types';
import { isModelDiscoveryProvider } from '@n8n/ai-utilities/model-discovery';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
@@ -12,11 +12,6 @@ import { LLM_PROVIDER_DEFAULTS, LLM_PROVIDER_PRIORITY } from './llm-provider-def
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
import { findVerifiedModelId } from './utils/provider-model-id';
export interface ResolvedAgentDefaultModel {
model: string;
credential: string;
}
/**
* Resolves a sensible default model+credential for a new agent, and answers
* "is the maintained default still in this provider's verified model list?"
@@ -42,7 +37,7 @@ export class AgentDefaultModelResolverService {
* personal LLM credential. Returns `null` when the choice is ambiguous
* or the default is not live — the caller keeps the agent as a draft.
*/
async resolve(user: User, projectId: string): Promise<ResolvedAgentDefaultModel | null> {
async resolve(user: User, projectId: string): Promise<AgentModelCredentialConfig | null> {
const credentials = await createAgentCredentialProvider(
this.credentialsService,
projectId,
@@ -84,7 +79,7 @@ export class AgentDefaultModelResolverService {
provider: string,
credentialId: string,
verifiedModelIds: readonly string[],
): ResolvedAgentDefaultModel | null {
): AgentModelCredentialConfig | null {
const defaults = this.findProviderDefault(provider);
if (!defaults) return null;
@@ -110,7 +105,7 @@ export class AgentDefaultModelResolverService {
private async resolveManagedOpenAi(
user: User,
projectId: string,
): Promise<ResolvedAgentDefaultModel | null> {
): Promise<AgentModelCredentialConfig | null> {
const defaults = LLM_PROVIDER_DEFAULTS.openAiApi;
let credentialType: string | undefined;
try {
@@ -133,7 +128,7 @@ export class AgentDefaultModelResolverService {
user: User,
projectId: string,
credential: CredentialListItem | undefined,
): Promise<ResolvedAgentDefaultModel | null> {
): Promise<AgentModelCredentialConfig | null> {
if (!credential) return null;
const defaults = LLM_PROVIDER_DEFAULTS[credential.type];
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
import type { PushPayload } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { UserRepository } from '@n8n/db';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@@ -8,9 +7,9 @@ import { InstanceSettings } from 'n8n-core';
import { Push } from '@/push';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { RoleService } from '@/services/role.service';
import type { AgentExecutionThread } from './entities/agent-execution-thread.entity';
import { AgentPushRecipientsService } from './agent-push-recipients.service';
import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository';
import { threadBelongsTo } from './utils/agent-thread-access';
@@ -20,12 +19,11 @@ type AgentExecutionUpdate = PushPayload<'agentExecutionUpdated'>;
export class AgentExecutionUpdateBroadcaster {
constructor(
private readonly logger: Logger,
private readonly userRepository: UserRepository,
private readonly recipients: AgentPushRecipientsService,
private readonly push: Push,
private readonly publisher: Publisher,
private readonly instanceSettings: InstanceSettings,
private readonly threadRepository: AgentExecutionThreadRepository,
private readonly roleService: RoleService,
) {
this.logger = this.logger.scoped('agents');
}
@@ -80,15 +78,7 @@ export class AgentExecutionUpdateBroadcaster {
}
private async getRecipients(thread: AgentExecutionThread): Promise<string[]> {
const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([
this.roleService.rolesWithScope('global', ['agent:read']),
this.roleService.rolesWithScope('project', ['agent:read']),
]);
const userIds = await this.userRepository.findIdsWithGlobalOrProjectRoles({
projectIds: [thread.projectId],
projectRoleSlugs,
globalRoleSlugs,
});
const userIds = await this.recipients.getProjectReaders(thread.projectId);
return userIds.filter((id) => threadBelongsTo(thread, thread.projectId, thread.agentId, id));
}
@@ -1,4 +1,5 @@
import type {
AgentExecutionStatus,
AgentMessageAuthor,
AgentSessionPreviewAccess,
AgentSessionQueryFilters,
@@ -16,18 +17,20 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces';
import { Telemetry } from '@/telemetry';
import {
AgentChatAttachmentService,
type StoredAttachmentRef,
} from './agent-chat-attachment.service';
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
import type { StoredAttachmentRef } from './types/agent-chat-attachment';
import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster';
import { buildAgentTurnMetrics } from './agent-telemetry';
import {
AgentExecutionThread,
type AgentThreadAccess,
} from './entities/agent-execution-thread.entity';
import { AgentExecution, type AgentExecutionStatus } from './entities/agent-execution.entity';
import { AgentExecution, type AgentExecutionHitlStatus } from './entities/agent-execution.entity';
import type { MessageRecord, TimelineEvent } from './execution-recorder';
import { AgentExecutionLogStore } from './execution-log/agent-execution-log-store';
import {
AgentExecutionLogStore,
type AgentExecutionLogRef,
} from './execution-log/agent-execution-log-store';
import { N8nMemory } from './integrations/n8n-memory';
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
@@ -60,7 +63,7 @@ export interface RecordMessageParams {
attachments?: StoredAttachmentRef[];
record: MessageRecord;
/** Set to 'suspended' or 'resumed' for HITL tool call flows. */
hitlStatus?: 'suspended' | 'resumed';
hitlStatus?: AgentExecutionHitlStatus;
/** Where the message originated from, e.g. 'chat', 'slack', 'task'. */
source?: string;
/** Optional metadata persisted on the thread when it is first created. */
@@ -83,11 +86,8 @@ export interface StartExecutionParams extends Omit<RecordMessageParams, 'record'
initialTimeline?: TimelineEvent[];
}
interface TimelineSnapshotParams {
executionId: string;
interface TimelineSnapshotParams extends AgentExecutionLogRef {
projectId: string;
agentId: string;
threadId: string;
timeline: TimelineEvent[];
}
@@ -196,7 +196,7 @@ export class AgentExecutionService {
async finalizeExecution(executionId: string, params: RecordMessageParams): Promise<string> {
this.stopHeartbeat(executionId);
this.pendingTimelineSnapshots.delete(executionId);
const { record, hitlStatus } = params;
const { record } = params;
const status = executionStatus(record);
const stoppedAt = new Date(record.startTime + record.duration);
const failureSummary = computeExecutionFailureSummary({
@@ -209,41 +209,10 @@ export class AgentExecutionService {
record.timeline.length > 0 ? this.storageConfig.modeTag : 'db';
try {
await this.timelineSnapshotWrites.get(executionId);
const finalized = await this.agentExecutionRepository.updateIfRunning(executionId, {
status,
stoppedAt,
duration: record.duration,
model: record.model,
promptTokens: record.usage?.promptTokens ?? null,
completionTokens: record.usage?.completionTokens ?? null,
totalTokens: record.usage?.totalTokens ?? null,
cost: record.totalCost,
timeline: record.timeline.length > 0 ? record.timeline : null,
storedAt: 'db',
error: record.error,
failureSummary,
hitlStatus: hitlStatus ?? null,
});
if (!finalized) {
throw new OperationalError('Agent execution is no longer running', {
extra: { executionId },
});
}
await this.writeTerminalExecution(executionId, params, status, stoppedAt, failureSummary);
// Save the terminal row first. A rejected finalization must not replace a stored blob.
if (storedAt !== 'db') {
const logRef = { agentId: params.agentId, threadId: params.threadId, executionId };
let blobWritten = false;
try {
await this.agentExecutionLogStore.write(logRef, { timeline: record.timeline }, storedAt);
blobWritten = true;
await this.agentExecutionRepository.moveTimelineToBlob(executionId, storedAt);
} catch (error) {
this.errorReporter.error(error);
if (blobWritten) await this.deleteUnreferencedTimelineBlob(logRef, storedAt);
}
}
await this.moveFinalTimelineToBlob(executionId, params, storedAt);
this.executionUpdateBroadcaster.notify({
projectId: params.projectId,
@@ -262,7 +231,7 @@ export class AgentExecutionService {
}
private async deleteUnreferencedTimelineBlob(
ref: { agentId: string; threadId: string; executionId: string },
ref: AgentExecutionLogRef,
storedAt: StorageLocation,
): Promise<void> {
try {
@@ -361,36 +330,7 @@ export class AgentExecutionService {
const snapshot = this.pendingTimelineSnapshots.get(executionId);
if (!snapshot) return;
this.pendingTimelineSnapshots.delete(executionId);
try {
if (
!(await this.agentExecutionRepository.updateTimelineIfRunning(
executionId,
snapshot.timeline,
))
) {
this.pendingTimelineSnapshots.delete(executionId);
return;
}
this.executionUpdateBroadcaster.notify({
projectId: snapshot.projectId,
agentId: snapshot.agentId,
threadId: snapshot.threadId,
executionId,
});
} catch (error) {
if (!this.heartbeatTimers.has(executionId)) return;
if (!this.pendingTimelineSnapshots.has(executionId)) {
this.pendingTimelineSnapshots.set(executionId, snapshot);
}
this.logger.warn('Failed to persist an agent execution timeline snapshot; retrying', {
executionId,
error: error instanceof Error ? error.message : String(error),
});
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, TIMELINE_SNAPSHOT_RETRY_DELAY_MS);
timer.unref();
});
}
if (!(await this.writeTimelineSnapshot(executionId, snapshot))) return;
}
}
@@ -419,53 +359,10 @@ export class AgentExecutionService {
executionId: string,
status: AgentExecution['status'],
): Promise<void> {
const { threadId, agentId, record, hitlStatus } = params;
const updates: Array<Promise<unknown>> = [];
if (hitlStatus === 'resumed' && record.model) {
updates.push(this.backfillSuspendedExecutions(threadId, record.model));
}
if (record.usage) {
updates.push(
this.agentExecutionThreadRepository.incrementUsage(
threadId,
record.usage.promptTokens,
record.usage.completionTokens,
record.totalCost ?? 0,
record.duration,
),
);
}
for (const result of await Promise.allSettled(updates)) {
if (result.status === 'rejected') {
this.logger.warn('Failed to update agent execution thread metadata', {
executionId,
threadId,
error: result.reason,
});
}
}
if (params.telemetry) {
try {
this.telemetry.trackAgentTurnFinished({
agent_id: agentId,
user_id: params.telemetry.userId,
thread_id: threadId,
run_type: params.telemetry.runType,
turn_status: status === 'success' ? 'succeeded' : 'failed',
configuration: params.telemetry.configuration,
latency_ms: record.duration,
cost: record.totalCost ?? 0,
token_count: record.usage?.totalTokens ?? 0,
tool_call_count: record.timeline.filter((event) => event.type === 'tool-call').length,
});
} catch (error) {
this.logger.warn('Failed to track agent execution telemetry', {
agentId,
threadId,
error: error instanceof Error ? error.message : String(error),
});
}
}
const { threadId, agentId, record } = params;
await this.updateThreadAfterExecution(params, executionId);
this.trackFinishedTurn(params, status);
this.logger.debug('Recorded agent execution', {
executionId,
threadId,
@@ -754,6 +651,146 @@ export class AgentExecutionService {
private toBlobRefs<T extends { storedAt: AgentExecution['storedAt'] }>(refs: T[]) {
return refs.filter((r): r is T & { storedAt: StorageLocation } => r.storedAt !== 'db');
}
private async writeTerminalExecution(
executionId: string,
params: RecordMessageParams,
status: AgentExecutionStatus,
stoppedAt: Date,
failureSummary: ReturnType<typeof computeExecutionFailureSummary>,
): Promise<void> {
const { record, hitlStatus } = params;
await this.timelineSnapshotWrites.get(executionId);
const finalized = await this.agentExecutionRepository.updateIfRunning(executionId, {
status,
stoppedAt,
duration: record.duration,
model: record.model,
promptTokens: record.usage?.promptTokens ?? null,
completionTokens: record.usage?.completionTokens ?? null,
totalTokens: record.usage?.totalTokens ?? null,
cost: record.totalCost,
timeline: record.timeline.length > 0 ? record.timeline : null,
storedAt: 'db',
error: record.error,
failureSummary,
hitlStatus: hitlStatus ?? null,
});
if (!finalized) {
throw new OperationalError('Agent execution is no longer running', {
extra: { executionId },
});
}
}
private async moveFinalTimelineToBlob(
executionId: string,
params: RecordMessageParams,
storedAt: AgentExecution['storedAt'],
): Promise<void> {
if (storedAt === 'db') return;
const { record } = params;
const logRef = { agentId: params.agentId, threadId: params.threadId, executionId };
let blobWritten = false;
try {
await this.agentExecutionLogStore.write(logRef, { timeline: record.timeline }, storedAt);
blobWritten = true;
await this.agentExecutionRepository.moveTimelineToBlob(executionId, storedAt);
} catch (error) {
this.errorReporter.error(error);
if (blobWritten) await this.deleteUnreferencedTimelineBlob(logRef, storedAt);
}
}
private async writeTimelineSnapshot(
executionId: string,
snapshot: Omit<TimelineSnapshotParams, 'executionId'>,
): Promise<boolean> {
try {
if (
!(await this.agentExecutionRepository.updateTimelineIfRunning(
executionId,
snapshot.timeline,
))
) {
this.pendingTimelineSnapshots.delete(executionId);
return false;
}
this.executionUpdateBroadcaster.notify({
projectId: snapshot.projectId,
agentId: snapshot.agentId,
threadId: snapshot.threadId,
executionId,
});
} catch (error) {
if (!this.heartbeatTimers.has(executionId)) return false;
if (!this.pendingTimelineSnapshots.has(executionId)) {
this.pendingTimelineSnapshots.set(executionId, snapshot);
}
this.logger.warn('Failed to persist an agent execution timeline snapshot; retrying', {
executionId,
error: error instanceof Error ? error.message : String(error),
});
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, TIMELINE_SNAPSHOT_RETRY_DELAY_MS);
timer.unref();
});
}
return true;
}
private async updateThreadAfterExecution(
params: RecordMessageParams,
executionId: string,
): Promise<void> {
const { threadId, record, hitlStatus } = params;
const updates: Array<Promise<unknown>> = [];
if (hitlStatus === 'resumed' && record.model) {
updates.push(this.backfillSuspendedExecutions(threadId, record.model));
}
if (record.usage) {
updates.push(
this.agentExecutionThreadRepository.incrementUsage(
threadId,
record.usage.promptTokens,
record.usage.completionTokens,
record.totalCost ?? 0,
record.duration,
),
);
}
for (const result of await Promise.allSettled(updates)) {
if (result.status === 'rejected') {
this.logger.warn('Failed to update agent execution thread metadata', {
executionId,
threadId,
error: result.reason,
});
}
}
}
private trackFinishedTurn(params: RecordMessageParams, status: AgentExecutionStatus): void {
if (!params.telemetry) return;
const { agentId, threadId, record } = params;
try {
this.telemetry.trackAgentTurnFinished({
agent_id: agentId,
user_id: params.telemetry.userId,
thread_id: threadId,
run_type: params.telemetry.runType,
turn_status: status === 'success' ? 'succeeded' : 'failed',
configuration: params.telemetry.configuration,
...buildAgentTurnMetrics(record),
});
} catch (error) {
this.logger.warn('Failed to track agent execution telemetry', {
agentId,
threadId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
function toSessionStatus(
@@ -6,6 +6,7 @@ import {
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { runSerially } from '@n8n/utils/run-serially';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@@ -15,7 +16,7 @@ import {
AgentIntegrationPersistenceService,
matchesIntegrationRef,
type IntegrationDeltaResult,
type IntegrationRef,
type IntegrationDelta,
} from './agent-integration-persistence.service';
import type { AgentActor } from './agent-modification-telemetry.service';
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
@@ -23,10 +24,26 @@ import type { Agent } from './entities/agent.entity';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
import { ChatIntegrationService } from './integrations/chat-integration.service';
import { AgentRepository } from './repositories/agent.repository';
import type { IntegrationRef } from './utils/agent-channel';
interface IntegrationChangeOptions extends IntegrationDelta {
agent: Agent;
user: User;
cleanupRemovedIntegration?: boolean;
deleteExternalResource?: boolean;
modifiedBy: AgentActor;
pushRef?: string;
}
interface IntegrationRuntimeState {
published: boolean;
wasLive: boolean;
persisted?: AgentIntegrationConfig;
}
@Service()
export class AgentIntegrationManagementService {
/** One in-flight channel mutation per agent; see {@link serializePerAgent}. */
/** Serialize channel changes per agent within this process. */
private readonly mutations = new Map<string, Promise<unknown>>();
constructor(
@@ -112,127 +129,35 @@ export class AgentIntegrationManagementService {
* 3. release the replaced connection — only once the write is durable, so a
* failed write leaves the existing channel live.
*/
private async applyChange(options: {
agent: Agent;
user: User;
add?: AgentIntegrationConfig;
remove?: IntegrationRef;
cleanupRemovedIntegration?: boolean;
deleteExternalResource?: boolean;
modifiedBy: AgentActor;
pushRef?: string;
}): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
return await this.serializePerAgent(
private async applyChange(
options: IntegrationChangeOptions,
): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
// Keep startup, persistence, and teardown in one queue to prevent interleaved changes.
return await runSerially(
this.mutations,
options.agent.id,
async () => await this.runChange(options),
);
}
/**
* Run one channel mutation per agent at a time.
*
* Steps 1 and 3 straddle the write, so two mutations on the same channel can
* interleave: a removal releasing after its write can tear down the
* connection a concurrent re-connect established before its own write, leaving
* the channel persisted with nothing running. Queueing them removes the
* interleaving outright. This is per-process — durable state is still
* protected by the compare-and-set, and runtime state is per-main anyway.
*/
private async serializePerAgent<T>(agentId: string, work: () => Promise<T>): Promise<T> {
const previous = this.mutations.get(agentId) ?? Promise.resolve();
const run = previous.catch(() => {}).then(work);
this.mutations.set(agentId, run);
try {
return await run;
} finally {
if (this.mutations.get(agentId) === run) this.mutations.delete(agentId);
}
}
private async runChange(options: {
agent: Agent;
user: User;
add?: AgentIntegrationConfig;
remove?: IntegrationRef;
cleanupRemovedIntegration?: boolean;
deleteExternalResource?: boolean;
modifiedBy: AgentActor;
pushRef?: string;
}): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
private async runChange(
options: IntegrationChangeOptions,
): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
const { agent, add } = options;
// "Replace this channel with itself" is just a connect. Left as a removal,
// step 3 would release the connection step 1 just brought up, because both
// resolve to the same runtime connection.
// A self-replacement must not release the connection that it just started.
const remove =
options.remove && add && matchesIntegrationRef(add, options.remove)
? undefined
: options.remove;
// The entity can predate a publish or another channel write, so read the row
// for both decisions that depend on it: whether to connect at all, and what
// a rollback would restore to. The write does its own read and reconciles
// anything that lands after this one.
const state =
add || options.cleanupRemovedIntegration
? await this.agentRepository.findIntegrationState(agent.id)
: null;
const publishedBefore = state ? state.activeVersionId !== null : agent.activeVersionId !== null;
// `connect` restarts a connection that is already live — a settings-only
// save, or re-connecting the same credential — so a rollback has to know
// whether step 1 created this connection or merely replaced it.
//
// An unpublished agent has no live channel to begin with: it never receives
// events, so nothing was started for it to restore. The publication check is
// what makes that explicit — `isChannelLive` reports a leader-routed channel
// as live without being able to inspect the leader, so on its own it would
// have a draft's failed pre-connect validation start a runtime.
const wasLive = !!add && publishedBefore && this.chatService.isChannelLive(agent.id, add);
const persistedBefore =
add && state
? (state.integrations ?? []).find((entry) => matchesIntegrationRef(entry, add))
: undefined;
let connected: boolean;
try {
connected = add ? await this.startRuntime(agent, add, publishedBefore) : false;
} catch (error) {
// `connect` releases the live connection before it builds the new one, so
// a failed rebuild leaves a still-persisted channel with nothing running.
if (wasLive) await this.restorePersistedRuntime(agent, persistedBefore);
throw error;
}
let result: IntegrationDeltaResult;
try {
result = await this.persistenceService.applyIntegrationDelta(
agent,
{ add, remove },
{ user: options.user, modifiedBy: options.modifiedBy },
);
} catch (error) {
// Undo step 1 on this main only. Nothing durable changed, so the cluster's
// view is still correct — broadcasting a disconnect would have every peer
// tear down a runtime that is still persisted and still healthy. Neither
// path touches thread subscriptions, which the row still justifies.
if (connected && add) {
if (wasLive && persistedBefore) {
// `connect` releases the existing key before rebuilding, so restoring
// both drops the failed attempt and puts the previous entry back.
await this.restorePersistedRuntime(agent, persistedBefore);
} else {
await this.releaseRuntimeQuietly(agent, add);
}
}
throw error;
}
const previous = await this.loadRuntimeBeforeChange(options);
let connected = await this.startAddedRuntime(options, previous);
const result = await this.persistChange(options, remove, connected, previous);
if (result.changed) {
this.agentUpdateBroadcaster.notify(
{ projectId: agent.projectId, agentId: agent.id, source: options.modifiedBy },
options.pushRef,
);
}
if (add && result.published !== undefined) {
connected = await this.reconcileRuntimeWithPublication(
agent,
@@ -241,33 +166,100 @@ export class AgentIntegrationManagementService {
result.published,
);
}
const isPublished = result.published ?? publishedBefore;
let warning: AgentIntegrationDisconnectWarning | undefined;
try {
warning =
result.removed && options.cleanupRemovedIntegration
? await this.registry.get(result.removed.type)?.onRemove?.({
agentId: agent.id,
projectId: agent.projectId,
credentialId: result.removed.credentialId,
user: options.user,
deleteExternalResource:
// if not published, by default delete the external resource
options.deleteExternalResource ?? !isPublished,
})
: undefined;
} finally {
if (remove) await this.releaseRemoved(agent, remove, result);
}
const warning = await this.cleanupRemovedIntegration(
options,
remove,
result,
previous.published,
);
if (connected && add) {
await this.chatService.broadcastIntegrationChange(agent.id, add, 'connect');
}
return { ...result, ...(warning ? { warning } : {}) };
}
private async cleanupRemovedIntegration(
options: IntegrationChangeOptions,
remove: IntegrationRef | undefined,
result: IntegrationDeltaResult,
publishedBefore: boolean,
): Promise<AgentIntegrationDisconnectWarning | undefined> {
const { agent } = options;
const isPublished = result.published ?? publishedBefore;
try {
if (!result.removed || !options.cleanupRemovedIntegration) return undefined;
return await this.registry.get(result.removed.type)?.onRemove?.({
agentId: agent.id,
projectId: agent.projectId,
credentialId: result.removed.credentialId,
user: options.user,
// Draft channels delete their external resource by default.
deleteExternalResource: options.deleteExternalResource ?? !isPublished,
});
} finally {
if (remove) await this.releaseRemoved(agent, remove, result);
}
}
private async persistChange(
options: IntegrationChangeOptions,
remove: IntegrationRef | undefined,
connected: boolean,
previous: IntegrationRuntimeState,
): Promise<IntegrationDeltaResult> {
const { agent, add } = options;
try {
return await this.persistenceService.applyIntegrationDelta(
agent,
{ add, remove },
{ user: options.user, modifiedBy: options.modifiedBy },
);
} catch (error) {
if (!connected || !add) throw error;
// Roll back this main only. Other mains still match the durable state.
if (previous.wasLive && previous.persisted) {
await this.restorePersistedRuntime(agent, previous.persisted);
} else {
await this.releaseRuntimeQuietly(agent, add);
}
throw error;
}
}
private async startAddedRuntime(
{ agent, add }: IntegrationChangeOptions,
previous: IntegrationRuntimeState,
): Promise<boolean> {
if (!add) return false;
try {
return await this.startRuntime(agent, add, previous.published);
} catch (error) {
// A failed reconnect has already released the previous runtime.
if (previous.wasLive) await this.restorePersistedRuntime(agent, previous.persisted);
throw error;
}
}
private async loadRuntimeBeforeChange({
agent,
add,
cleanupRemovedIntegration,
}: IntegrationChangeOptions): Promise<IntegrationRuntimeState> {
// The caller's entity can predate a publish or another channel write.
const state =
add || cleanupRemovedIntegration
? await this.agentRepository.findIntegrationState(agent.id)
: null;
const published = state ? state.activeVersionId !== null : agent.activeVersionId !== null;
// Leader-routed channels report live even for drafts. Check publication first.
const wasLive = !!add && published && this.chatService.isChannelLive(agent.id, add);
const persisted =
add && state
? (state.integrations ?? []).find((entry) => matchesIntegrationRef(entry, add))
: undefined;
return { published, wasLive, persisted };
}
/**
* Settle the runtime against the publication state the write actually read.
*
@@ -24,18 +24,13 @@ import type { Agent } from './entities/agent.entity';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
import { AgentRepository } from './repositories/agent.repository';
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
import type { IntegrationRef } from './utils/agent-channel';
export interface CredentialIntegrationMutationContext {
user: User;
modifiedBy: AgentActor;
}
/** Reference to a persisted entry; `credentialId: ''` targets a builder draft entry. */
export interface IntegrationRef {
type: string;
credentialId: string;
}
/**
* One durable change to an agent's channels. Both fields together are a
* replacement, and land in one write so a swap can't leave two entries or none.
@@ -62,10 +57,7 @@ export interface IntegrationDeltaResult {
/** Retries cover a lost compare-and-set, which needs a fresh read to resolve. */
const MAX_WRITE_ATTEMPTS = 3;
export function matchesIntegrationRef(
integration: { type: string; credentialId: string },
ref: IntegrationRef,
): boolean {
export function matchesIntegrationRef(integration: IntegrationRef, ref: IntegrationRef): boolean {
return integration.type === ref.type && integration.credentialId === ref.credentialId;
}
@@ -75,7 +67,7 @@ export function matchesIntegrationRef(
*/
function projectIntegrations(
current: AgentIntegrationConfig[],
delta: { add?: AgentIntegrationConfig; remove?: IntegrationRef },
delta: IntegrationDelta,
): AgentIntegrationConfig[] {
let next = delta.remove
? current.filter((entry) => !matchesIntegrationRef(entry, delta.remove!))
@@ -89,9 +81,8 @@ function projectIntegrations(
// instead of leaving both the draft and the connected entry behind.
next = next.filter((entry) => !(entry.type === add.type && isDraftIntegration(entry)));
return next.some((entry) => matchesIntegrationRef(entry, add))
? next.map((entry) => (matchesIntegrationRef(entry, add) ? add : entry))
: [...next, add];
if (!next.some((entry) => matchesIntegrationRef(entry, add))) return [...next, add];
return next.map((entry) => (matchesIntegrationRef(entry, add) ? add : entry));
}
@Service()
@@ -158,59 +149,13 @@ export class AgentIntegrationPersistenceService {
);
for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
const state = await this.agentRepository.findIntegrationState(agent.id);
if (!state) throw new UserError(`Agent "${agent.id}" no longer exists`);
const current = state.integrations ?? [];
const removed = remove
? current.find((entry) => matchesIntegrationRef(entry, remove))
: undefined;
const published = state.activeVersionId !== null;
// Callers derive their response and their runtime decisions from the
// entity, so correct it to what was read. Scalar only — nothing here reads
// the `activeVersion` relation, and fabricating one would be worse.
agent.activeVersionId = state.activeVersionId;
// A removal of something already gone is not a failure — and with
// nothing to add there is no write left to make.
if (!add && !removed) {
agent.integrations = current;
agent.versionId = state.versionId;
return { agent, changed: false, published };
}
const integrations = projectIntegrations(current, { add, remove });
// Always fresh: `versionId` is the compare-and-set token, so writing back
// the value we guarded on would let two concurrent writes both match.
// Consumers only compare it to `activeVersionId`, which a rotation keeps.
const versionId = uuid();
// Gate evaluated against the state about to be written; the marker is
// claimed and reported only once that write succeeded.
agent.integrations = integrations;
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
const result = await this.applyIntegrationAttempt(
agent,
agent.projectId,
{ add, remove },
context,
credentialProvider,
context.user,
);
const written = await this.agentRepository.updateIntegrations(
agent.id,
integrations,
{ versionId: state.versionId, activeVersionId: state.activeVersionId },
versionId,
);
if (!written) continue;
agent.versionId = versionId;
this.runtimeCacheService.clearRuntimes(agent.id);
this.eventService.emit('agent-saved', { agentId: agent.id });
await emitSetupCompleted?.();
this.recordIntegrationMutation(agent, current, context);
return { agent, changed: true, published, ...(removed ? { removed } : {}) };
if (result) return result;
}
throw new OperationalError(
@@ -253,4 +198,82 @@ export class AgentIntegrationPersistenceService {
wasUnconfigured,
});
}
private async applyIntegrationAttempt(
agent: Agent,
{ add, remove }: IntegrationDelta,
context: CredentialIntegrationMutationContext,
credentialProvider: ReturnType<typeof createAgentCredentialProvider>,
): Promise<IntegrationDeltaResult | undefined> {
const state = await this.agentRepository.findIntegrationState(agent.id);
if (!state) throw new UserError(`Agent "${agent.id}" no longer exists`);
const current = state.integrations ?? [];
const removed = remove
? current.find((entry) => matchesIntegrationRef(entry, remove))
: undefined;
const published = state.activeVersionId !== null;
// Callers derive their response and their runtime decisions from the
// entity, so correct it to what was read. Scalar only — nothing here reads
// the `activeVersion` relation, and fabricating one would be worse.
agent.activeVersionId = state.activeVersionId;
// A removal of something already gone is not a failure — and with
// nothing to add there is no write left to make.
if (!add && !removed) {
agent.integrations = current;
agent.versionId = state.versionId;
return { agent, changed: false, published };
}
const integrations = projectIntegrations(current, { add, remove });
const written = await this.persistIntegrations(
agent,
integrations,
state,
context,
credentialProvider,
);
if (!written) return undefined;
this.runtimeCacheService.clearRuntimes(agent.id);
this.eventService.emit('agent-saved', { agentId: agent.id });
await written.emitSetupCompleted?.();
this.recordIntegrationMutation(agent, current, context);
return { agent, changed: true, published, ...(removed ? { removed } : {}) };
}
private async persistIntegrations(
agent: Agent,
integrations: AgentIntegrationConfig[],
state: Pick<Agent, 'versionId' | 'activeVersionId'>,
context: CredentialIntegrationMutationContext,
credentialProvider: ReturnType<typeof createAgentCredentialProvider>,
) {
// Always fresh: `versionId` is the compare-and-set token, so writing back
// the value we guarded on would let two concurrent writes both match.
// Consumers only compare it to `activeVersionId`, which a rotation keeps.
const versionId = uuid();
// Gate evaluated against the state about to be written; the marker is
// claimed and reported only once that write succeeded.
agent.integrations = integrations;
const emitSetupCompleted = await this.setupCompletionService.recordIfSetupComplete(
agent,
agent.projectId,
credentialProvider,
context.user,
);
const written = await this.agentRepository.updateIntegrations(
agent.id,
integrations,
{ versionId: state.versionId, activeVersionId: state.activeVersionId },
versionId,
);
if (!written) return undefined;
agent.versionId = versionId;
return { emitSetupCompleted };
}
}
@@ -26,11 +26,8 @@ const SEARCH_JSON_EVENT_OVERHEAD_CHARS = 1_500;
const MAX_SEARCH_OPERATION_OUTPUT_CHARS = 500_000;
export const KNOWLEDGE_FILES_DIR_UNAVAILABLE_EXIT_CODE = 3;
interface ParsedRipgrepContentLine {
interface ParsedRipgrepContentLine extends SearchKnowledgeContextLine {
filePath: string;
lineNumber: number;
text: string;
matched: boolean;
}
interface SearchContextSourceLine {
@@ -2,6 +2,7 @@ import type { CommandResult } from '@n8n/agents/sandbox';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { runSerially } from '@n8n/utils/run-serially';
import escapeRegExp from 'lodash/escapeRegExp';
import { OperationalError, safeRegex } from 'n8n-workflow';
import { nanoid } from 'nanoid';
@@ -119,7 +120,7 @@ function assertKnowledgeFilesDirectoryAvailable(
@Service()
export class AgentKnowledgeMirrorService {
private readonly pendingMirrorSyncs = new Map<string, Promise<void>>();
private readonly pendingMirrorSyncs = new Map<string, Promise<unknown>>();
constructor(
private readonly agentsConfig: AgentsConfig,
@@ -144,15 +145,7 @@ export class AgentKnowledgeMirrorService {
return emptySearchKnowledgeResult(outputMode, limit);
}
const scopedFilesByPath = new Map<string, AgentKnowledgeFileReference>();
for (const path of validatedRequest.path ?? []) {
const file = this.resolveOptionalFile({ file: path }, references);
if (!file) {
throw new BadRequestError('Knowledge file not found');
}
scopedFilesByPath.set(file.file, file);
}
const scopedFiles = [...scopedFilesByPath.values()];
const scopedFiles = this.resolveSearchFiles(validatedRequest, references);
const command = buildSearchKnowledgeCommand(
validatedRequest,
scopedFiles.map((file) => file.file),
@@ -172,44 +165,7 @@ export class AgentKnowledgeMirrorService {
throw new OperationalError(formatSandboxCommandFailure('search', result));
}
if (outputMode === 'files_with_matches') {
const parsed = parseRipgrepFilesOutput(result.stdout, references.byFile);
const files = parsed.files.slice(0, limit);
return {
outputMode,
files,
limit,
hasMore: parsed.files.length > limit,
truncated: parsed.incomplete,
};
}
if (outputMode === 'count') {
const parsed = parseRipgrepCountOutput(result.stdout, references.byFile);
const counts = parsed.counts.slice(0, limit);
return {
outputMode,
counts,
limit,
hasMore: parsed.counts.length > limit,
truncated: parsed.incomplete,
};
}
const parsed = parseRipgrepOutput(
result.stdout,
references.byFile,
getSearchContextWindow(validatedRequest),
);
const matches = parsed.matches.slice(0, limit);
return {
outputMode,
matches,
limit,
hasMore: parsed.matches.length > limit,
truncated: parsed.incomplete,
};
return this.parseSearchResult(result.stdout, validatedRequest, references, outputMode, limit);
}
async globKnowledgeFiles(
@@ -311,19 +267,11 @@ export class AgentKnowledgeMirrorService {
runtime: AgentKnowledgeMirrorRuntime,
files: AgentKnowledgeFileReference[],
): Promise<void> {
const previous = this.pendingMirrorSyncs.get(runtime.cacheKey) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(async () => await this.syncMirror(runtime, files));
this.pendingMirrorSyncs.set(runtime.cacheKey, next);
try {
await next;
} finally {
if (this.pendingMirrorSyncs.get(runtime.cacheKey) === next) {
this.pendingMirrorSyncs.delete(runtime.cacheKey);
}
}
await runSerially(
this.pendingMirrorSyncs,
runtime.cacheKey,
async () => await this.syncMirror(runtime, files),
);
}
private async syncMirror(
@@ -351,44 +299,9 @@ export class AgentKnowledgeMirrorService {
const stagingId = nanoid();
const stagingDir = `${runtime.paths.stagingDir}/${stagingId}`;
try {
const copiedNames = await this.uploadMirrorFiles(runtime, toCopy, stagingId);
const finalManifestFiles = files.filter(
(file) => copiedNames.has(file.file) || present.get(file.file) === file.fileId,
);
const staleFiles = toCopy
.filter((file) => present.has(file.file) && !copiedNames.has(file.file))
.map((file) => file.file);
const syncResult = await this.agentSandboxRuntimeService.executeSandboxCommand(
runtime.sandbox,
buildMirrorFinalizeCommand(
[...copiedNames],
[...toDelete, ...staleFiles],
finalManifestFiles,
runtime.paths,
stagingId,
),
MIRROR_SYNC_TIMEOUT_SECONDS * 1000,
);
if (syncResult.exitCode !== 0) {
throw new OperationalError(
`Agent knowledge mirror sync failed: exitCode=${syncResult.exitCode}; output=${sanitizeSandboxErrorDetail(syncResult.stdout)}`,
);
}
await this.finalizeMirror(runtime, files, toCopy, toDelete, present, stagingId);
} finally {
if (toCopy.length > 0) {
try {
await runtime.filesystem.rmdir(stagingDir, { recursive: true, force: true });
} catch (error) {
this.logger.warn('Failed to clean agent knowledge mirror staging directory', {
sandboxName: runtime.cacheKey,
stagingId,
error: sanitizeSandboxErrorDetail(
error instanceof Error ? error.message : String(error),
),
});
}
}
if (toCopy.length > 0) await this.cleanMirrorStaging(runtime, stagingDir, stagingId);
}
}
@@ -534,6 +447,118 @@ export class AgentKnowledgeMirrorService {
);
return { ...runtime, paths: getAgentKnowledgePaths(runtime.provider) };
}
private resolveSearchFiles(
request: SearchKnowledgeRequest,
references: AgentKnowledgeReferenceLookup,
): AgentKnowledgeFileReference[] {
const scopedFilesByPath = new Map<string, AgentKnowledgeFileReference>();
for (const path of request.path ?? []) {
const file = this.resolveOptionalFile({ file: path }, references);
if (!file) {
throw new BadRequestError('Knowledge file not found');
}
scopedFilesByPath.set(file.file, file);
}
return [...scopedFilesByPath.values()];
}
private parseSearchResult(
stdout: string,
validatedRequest: SearchKnowledgeRequest,
references: AgentKnowledgeReferenceLookup,
outputMode: NonNullable<SearchKnowledgeRequest['output_mode']>,
limit: number,
): SearchKnowledgeResult {
if (outputMode === 'files_with_matches') {
const parsed = parseRipgrepFilesOutput(stdout, references.byFile);
const files = parsed.files.slice(0, limit);
return {
outputMode,
files,
limit,
hasMore: parsed.files.length > limit,
truncated: parsed.incomplete,
};
}
if (outputMode === 'count') {
const parsed = parseRipgrepCountOutput(stdout, references.byFile);
const counts = parsed.counts.slice(0, limit);
return {
outputMode,
counts,
limit,
hasMore: parsed.counts.length > limit,
truncated: parsed.incomplete,
};
}
const parsed = parseRipgrepOutput(
stdout,
references.byFile,
getSearchContextWindow(validatedRequest),
);
const matches = parsed.matches.slice(0, limit);
return {
outputMode,
matches,
limit,
hasMore: parsed.matches.length > limit,
truncated: parsed.incomplete,
};
}
private async finalizeMirror(
runtime: AgentKnowledgeMirrorRuntime,
files: AgentKnowledgeFileReference[],
toCopy: AgentKnowledgeFileReference[],
toDelete: string[],
present: Map<string, string>,
stagingId: string,
): Promise<void> {
const copiedNames = await this.uploadMirrorFiles(runtime, toCopy, stagingId);
const finalManifestFiles = files.filter(
(file) => copiedNames.has(file.file) || present.get(file.file) === file.fileId,
);
const staleFiles = toCopy
.filter((file) => present.has(file.file) && !copiedNames.has(file.file))
.map((file) => file.file);
const syncResult = await this.agentSandboxRuntimeService.executeSandboxCommand(
runtime.sandbox,
buildMirrorFinalizeCommand(
[...copiedNames],
[...toDelete, ...staleFiles],
finalManifestFiles,
runtime.paths,
stagingId,
),
MIRROR_SYNC_TIMEOUT_SECONDS * 1000,
);
if (syncResult.exitCode !== 0) {
throw new OperationalError(
`Agent knowledge mirror sync failed: exitCode=${syncResult.exitCode}; output=${sanitizeSandboxErrorDetail(syncResult.stdout)}`,
);
}
}
private async cleanMirrorStaging(
runtime: AgentKnowledgeMirrorRuntime,
stagingDir: string,
stagingId: string,
): Promise<void> {
try {
await runtime.filesystem.rmdir(stagingDir, { recursive: true, force: true });
} catch (error) {
this.logger.warn('Failed to clean agent knowledge mirror staging directory', {
sandboxName: runtime.cacheKey,
stagingId,
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
});
}
}
}
function matchKnowledgeFilesByGlob(
@@ -636,10 +661,9 @@ function containsTokenSequence(fileTokens: string[], patternTokens: string[]): b
let patternIndex = 0;
for (const fileToken of fileTokens) {
if (fileToken === patternTokens[patternIndex]) {
patternIndex++;
if (patternIndex === patternTokens.length) return true;
}
if (fileToken !== patternTokens[patternIndex]) continue;
patternIndex++;
if (patternIndex === patternTokens.length) return true;
}
return false;
}
@@ -196,10 +196,12 @@ export interface AgentKnowledgeFileReference {
createdAt: string;
}
export interface AgentKnowledgeCitation {
file: string;
fileId: string;
displayName: string;
type AgentKnowledgeFileIdentity = Pick<
AgentKnowledgeFileReference,
'file' | 'fileId' | 'displayName'
>;
export interface AgentKnowledgeCitation extends AgentKnowledgeFileIdentity {
startLine: number;
endLine: number;
}
@@ -210,10 +212,7 @@ export interface AgentKnowledgeLine {
truncated: boolean;
}
export interface SearchKnowledgeMatch {
file: string;
fileId: string;
displayName: string;
export interface SearchKnowledgeMatch extends AgentKnowledgeFileIdentity {
lineNumber: number;
text: string;
textTruncated: boolean;
@@ -226,10 +225,7 @@ export interface SearchKnowledgeContextLine {
matched: boolean;
}
export interface SearchKnowledgeCount {
file: string;
fileId: string;
displayName: string;
export interface SearchKnowledgeCount extends AgentKnowledgeFileIdentity {
count: number;
}
@@ -276,10 +272,7 @@ export interface ReadKnowledgeRangeResult {
citation: AgentKnowledgeCitation;
}
export interface ReadKnowledgeResult {
file: string;
fileId: string;
displayName: string;
export interface ReadKnowledgeResult extends AgentKnowledgeFileIdentity {
ranges: ReadKnowledgeRangeResult[];
truncated: boolean;
}
@@ -13,8 +13,8 @@ import { unlink } from 'node:fs/promises';
import path from 'node:path';
import type { Readable } from 'node:stream';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import {
AgentKnowledgeFileStore,
type StoredAgentKnowledgeFile,
@@ -45,7 +45,7 @@ export class AgentKnowledgeService {
files: Express.Multer.File[],
): Promise<AgentFileDto[]> {
try {
await this.ensureAgentBelongsToProject(agentId, projectId);
await getAgentOrThrow(this.agentRepository, agentId, projectId);
this.validateUploadMetadata(files);
await this.validateUploadBatch(agentId, files);
@@ -68,20 +68,20 @@ export class AgentKnowledgeService {
}
async listFiles(agentId: string, projectId: string): Promise<AgentFileDto[]> {
await this.ensureAgentBelongsToProject(agentId, projectId);
await getAgentOrThrow(this.agentRepository, agentId, projectId);
const files = await this.agentFileRepository.findByAgentId(agentId);
return files.map((file) => toAgentFileDto(file));
}
async warmKnowledgeSandbox(agentId: string, projectId: string): Promise<void> {
await this.ensureAgentBelongsToProject(agentId, projectId);
await getAgentOrThrow(this.agentRepository, agentId, projectId);
if (!(await this.agentFileRepository.hasFilesForAgent(agentId))) return;
await this.agentSandboxRuntimeService.warmKnowledgeSandbox(projectId, agentId);
}
async deleteFile(agentId: string, projectId: string, fileId: string): Promise<void> {
await this.ensureAgentBelongsToProject(agentId, projectId);
await getAgentOrThrow(this.agentRepository, agentId, projectId);
const file = await this.agentFileRepository.findByIdAndAgentId(fileId, agentId);
if (!file) {
@@ -276,14 +276,6 @@ export class AgentKnowledgeService {
}
}
private async ensureAgentBelongsToProject(agentId: string, projectId: string) {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
return agent;
}
private async cleanupUploadTempFiles(files: Express.Multer.File[]) {
await Promise.all(files.map(async (file) => await this.cleanupUploadTempFile(file)));
}
@@ -6,7 +6,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ProjectScopeService } from '@/permissions.ee/project-scope.service';
import type { Agent } from './entities/agent.entity';
import { AgentRepository } from './repositories/agent.repository';
import { AgentRepository, type AgentListResult } from './repositories/agent.repository';
const BULK_CHUNK_SIZE = 500;
@@ -34,10 +34,7 @@ export class AgentMcpAccessService {
* Paginated list of agents in projects where the user holds `agent:update`.
* Defaults to agents that are not yet available to MCP.
*/
async getAgents(
user: User,
options: ListAgentsQueryDto,
): Promise<{ count: number; data: Agent[] }> {
async getAgents(user: User, options: ListAgentsQueryDto): Promise<AgentListResult> {
const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:update']);
return await this.agentRepository.findByProjectIdsPaginated(
projectIds,
@@ -14,6 +14,7 @@ import {
BuilderModelLiveLookupService,
type LiveModelLookupResult,
} from './builder/builder-model-live-lookup.service';
import type { ModelChoice } from './builder/model-lookup.types';
import { AgentDefaultModelResolverService } from './agent-default-model-resolver.service';
import { stripSnapshotSuffix } from './utils/model-snapshot-alias';
import { normalizeProviderModelId } from './utils/provider-model-id';
@@ -60,11 +61,7 @@ export class AgentModelCatalogService {
credentialId?: string,
): Promise<AgentProviderModelsResponse> {
const credentialType = getProviderCredentialType(provider);
if (!credentialId || !credentialType) {
const catalogModels = await this.getCatalogModels(provider);
return { provider, verified: false, models: Object.values(catalogModels) };
}
if (!credentialId || !credentialType) return await this.unverifiedCatalog(provider);
let lookup: LiveModelLookupResult;
try {
@@ -76,27 +73,43 @@ export class AgentModelCatalogService {
provider,
);
} catch (error) {
this.logLiveLookupFailure(provider, error);
// Managed slot: the gateway allowlist is the contract, so don't fall back
// to the static catalog (it may list models the gateway won't serve).
// Flagged unavailable so the picker distinguishes a failed lookup from a
// gateway that genuinely allows nothing.
if (credentialId === AI_GATEWAY_MANAGED_TAG) {
return { provider, verified: true, unavailable: true, models: [] };
}
const catalogModels = await this.getCatalogModels(provider);
return { provider, verified: false, models: Object.values(catalogModels) };
return await this.unavailableModels(provider, credentialId, error);
}
return await this.modelsFromLookup(provider, credentialId, lookup);
}
// A custom endpoint can expose any OpenAI-compatible model id. Its live
// response is authoritative, so keep exact ids and attach no inferred or
// models.dev metadata.
private async unverifiedCatalog(provider: string): Promise<AgentProviderModelsResponse> {
const models = await this.getCatalogModels(provider);
return { provider, verified: false, models: Object.values(models) };
}
private async unavailableModels(
provider: string,
credentialId: string,
error: unknown,
policy?: LiveModelLookupResult['policy'],
): Promise<AgentProviderModelsResponse> {
this.logLiveLookupFailure(provider, error);
// Managed and custom endpoint lists are authoritative. Do not use a static fallback.
if (
policy === 'managed' ||
policy === 'endpoint-only' ||
credentialId === AI_GATEWAY_MANAGED_TAG
) {
return { provider, verified: true, unavailable: true, models: [] };
}
return await this.unverifiedCatalog(provider);
}
private async modelsFromLookup(
provider: string,
credentialId: string,
lookup: LiveModelLookupResult,
): Promise<AgentProviderModelsResponse> {
if (lookup.status === 'unavailable') {
return await this.unavailableModels(provider, credentialId, lookup.error, lookup.policy);
}
if (lookup.policy === 'endpoint-only') {
if (lookup.status === 'unavailable') {
this.logLiveLookupFailure(provider, lookup.error);
return { provider, verified: true, unavailable: true, models: [] };
}
return this.withDefaultModel(provider, credentialId, {
provider,
verified: true,
@@ -108,40 +121,38 @@ export class AgentModelCatalogService {
});
}
if (lookup.status === 'unavailable') {
this.logLiveLookupFailure(provider, lookup.error);
if (lookup.policy === 'managed' || credentialId === AI_GATEWAY_MANAGED_TAG) {
return { provider, verified: true, unavailable: true, models: [] };
}
const catalogModels = await this.getCatalogModels(provider);
return { provider, verified: false, models: Object.values(catalogModels) };
}
const liveModels = lookup.models;
const catalogModels = await this.getCatalogModels(provider);
const models =
lookup.policy === 'managed'
? this.enrichManagedModels(provider, lookup.models, catalogModels)
: this.verifyCuratedModels(provider, lookup.models, catalogModels);
return this.withDefaultModel(provider, credentialId, { provider, verified: true, models });
}
// n8n Connect managed slot: the gateway's `/models` is the authoritative,
// allowlist-filtered set, and its exact ids are what the gateway's model
// allowlist matches (e.g. a dated snapshot, not the catalog's versionless
// alias). Use those ids verbatim, enriched with catalog display/metadata.
if (lookup.policy === 'managed') {
return this.withDefaultModel(provider, credentialId, {
provider,
verified: true,
models: liveModels.map((live) => {
const id = normalizeProviderModelId(provider, live.value);
const catalogMatch = catalogModels[id] ?? catalogModels[stripSnapshotSuffix(id)];
return catalogMatch
? { ...catalogMatch, id }
: {
id,
name: normalizeProviderModelId(provider, live.name) || id,
toolCall: true,
};
}),
});
}
private enrichManagedModels(
provider: string,
liveModels: ModelChoice[],
catalogModels: Record<string, AgentCatalogModel>,
): AgentCatalogModel[] {
// Keep exact gateway IDs. Catalog aliases supply display metadata only.
return liveModels.map((live) => {
const id = normalizeProviderModelId(provider, live.value);
const catalogMatch = catalogModels[id] ?? catalogModels[stripSnapshotSuffix(id)];
return catalogMatch
? { ...catalogMatch, id }
: {
id,
name: normalizeProviderModelId(provider, live.name) || id,
toolCall: true,
};
});
}
private verifyCuratedModels(
provider: string,
liveModels: ModelChoice[],
catalogModels: Record<string, AgentCatalogModel>,
): AgentCatalogModel[] {
const liveModelIds = new Set(
liveModels.flatMap((live) =>
liveModelIdVariants(normalizeProviderModelId(provider, live.value)),
@@ -153,28 +164,16 @@ export class AgentModelCatalogService {
// Provider `/models` endpoints return every variant/snapshot, so we never
// add live-only models — we only prune catalog entries the provider no
// longer reports (retired ids that would 404 at call time).
if (catalogList.length > 0) {
return this.withDefaultModel(provider, credentialId, {
provider,
verified: true,
models: catalogList.filter((model) => liveModelIds.has(model.id)),
});
}
if (catalogList.length > 0) return catalogList.filter((model) => liveModelIds.has(model.id));
// Catalog unavailable (models.dev down or no entry for this provider): there
// is no curated list to prune against, so show the verified live list rather
// than an empty picker.
return this.withDefaultModel(provider, credentialId, {
provider,
verified: true,
models: liveModels.map((live) => {
const id = normalizeProviderModelId(provider, live.value);
return {
id,
name: normalizeProviderModelId(provider, live.name) || id,
toolCall: true,
};
}),
// With no catalog, return the verified live list.
return liveModels.map((live) => {
const id = normalizeProviderModelId(provider, live.value);
return {
id,
name: normalizeProviderModelId(provider, live.name) || id,
toolCall: true,
};
});
}
@@ -6,13 +6,9 @@ import isEqual from 'lodash/isEqual';
import { Telemetry } from '@/telemetry';
import { buildAgentConfigurationTelemetryFromConfig } from './agent-telemetry';
import { buildAgentCapabilityTelemetryProperties } from './agent-telemetry';
import type { Agent } from './entities/agent.entity';
import {
capabilityCountTelemetryProperties,
countAgentCapabilities,
isUnconfiguredAgent,
} from './utils/agent-capabilities';
import { isUnconfiguredAgent } from './utils/agent-capabilities';
export { isUnconfiguredAgent };
@@ -72,7 +68,7 @@ export function diffAgentConfigParts(
nextSchema: AgentJsonConfig | null,
previousIntegrations: AgentIntegrationConfig[],
nextIntegrations: AgentIntegrationConfig[],
sidecarChanges: Partial<Record<'tools' | 'skills' | 'tasks', boolean>> = {},
sidecarChanges: AgentSidecarChanges = {},
): AgentConfigPart[] {
return CONFIG_PARTS.filter((part) => {
if (part === 'triggers') return !isEqual(previousIntegrations, nextIntegrations);
@@ -83,6 +79,66 @@ export function diffAgentConfigParts(
});
}
interface AgentModificationEvent {
/** Post-save entity, so the reported profile is the one that landed. */
agent: Agent;
projectId: string;
user: User;
by: AgentActor;
changedParts: AgentConfigPart[];
/**
* Whether the agent was still unconfigured before this write. The write
* that leaves it configured is what creates it as far as telemetry is
* concerned, so it reports the creation event for `by` instead of the
* modification one — a write is never counted as both.
*/
wasUnconfigured: boolean;
}
export function captureAgentMutation(agent: Agent) {
const schema = agent.schema ?? null;
const integrations = agent.integrations ?? [];
return { schema, integrations, wasUnconfigured: isUnconfiguredAgent(schema, integrations) };
}
export type AgentMutationSnapshot = ReturnType<typeof captureAgentMutation>;
export type AgentSidecarChanges = Partial<Record<'tools' | 'skills' | 'tasks', boolean>>;
export function buildAgentMutationEvent(
agent: Agent,
projectId: string,
context: AgentMutationTelemetryContext,
previous: AgentMutationSnapshot,
sidecarChanges: AgentSidecarChanges,
): AgentModificationEvent {
return {
agent,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previous.schema,
agent.schema,
previous.integrations,
agent.integrations ?? [],
sidecarChanges,
),
wasUnconfigured: previous.wasUnconfigured,
};
}
function modificationProperties({ agent, projectId, user, changedParts }: AgentModificationEvent) {
return {
agent_id: agent.id,
project_id: projectId,
user_id: user.id,
changed_parts: changedParts,
...buildAgentCapabilityTelemetryProperties(agent.schema, agent.integrations),
has_published_version: Boolean(agent.activeVersionId),
} as const;
}
/**
* Single emitter for the six agent creation and modification events. Every
* config write reports through here, so the only things that differ between
@@ -93,81 +149,16 @@ export function diffAgentConfigParts(
export class AgentModificationTelemetryService {
constructor(private readonly telemetry: Telemetry) {}
record({
agent,
projectId,
user,
by,
changedParts,
wasUnconfigured,
}: {
/** Post-save entity, so the reported profile is the one that landed. */
agent: Agent;
projectId: string;
user: User;
by: AgentActor;
changedParts: AgentConfigPart[];
/**
* Whether the agent was still unconfigured before this write. The write
* that leaves it configured is what creates it as far as telemetry is
* concerned, so it reports the creation event for `by` instead of the
* modification one — a write is never counted as both.
*/
wasUnconfigured: boolean;
}): void {
if (changedParts.length === 0) return;
record(event: AgentModificationEvent): void {
if (event.changedParts.length === 0) return;
try {
const stillUnconfigured = isUnconfiguredAgent(agent.schema, agent.integrations);
// Blank-to-blank (rename/recolour of an unconfigured agent) stays silent
// so a creation remains the first event any agent reports. Configured →
// unconfigured (final capability removal) still reports modification.
if (wasUnconfigured && stillUnconfigured) return;
const counts = countAgentCapabilities(agent.schema, agent.integrations);
// Only model and tool_types: this helper's own tool_count folds in MCP
// servers, provider tools, web search and sub-agents, which would
// disagree with the per-kind counts above.
const { model, tool_types } = buildAgentConfigurationTelemetryFromConfig(
agent.schema,
agent.integrations,
);
const properties = {
agent_id: agent.id,
project_id: projectId,
user_id: user.id,
changed_parts: changedParts,
...capabilityCountTelemetryProperties(counts),
model,
tool_types,
has_published_version: Boolean(agent.activeVersionId),
} as const;
const { agent, by, wasUnconfigured } = event;
if (wasUnconfigured && isUnconfiguredAgent(agent.schema, agent.integrations)) return;
const properties = modificationProperties(event);
if (wasUnconfigured) {
// Written out per surface rather than looked up, because the creation
// events do not share one event_version and `Telemetry.track` types
// its payload against the specific event passed.
switch (by) {
case 'user':
this.telemetry.track(TELEMETRY_EVENT.AGENTS.USER_CREATED_AGENT, {
...properties,
event_version: '2',
});
return;
case 'builder':
this.telemetry.track(TELEMETRY_EVENT.AGENTS.BUILDER_CREATED_AGENT, {
...properties,
event_version: '2',
});
return;
case 'mcp':
this.telemetry.track(TELEMETRY_EVENT.AGENTS.MCP_CREATED_AGENT, {
...properties,
event_version: '1',
});
return;
}
this.recordCreation(by, properties);
return;
}
const entry = {
@@ -175,10 +166,36 @@ export class AgentModificationTelemetryService {
builder: TELEMETRY_EVENT.AGENTS.BUILDER_MODIFIED_AGENT,
mcp: TELEMETRY_EVENT.AGENTS.MCP_MODIFIED_AGENT,
}[by];
this.telemetry.track(entry, { ...properties, event_version: '1' });
} catch {
// Telemetry must never fail a write that already succeeded.
}
}
private recordCreation(
by: AgentActor,
properties: ReturnType<typeof modificationProperties>,
): void {
// Creation events use different versions. Keep each payload tied to its event.
switch (by) {
case 'user':
this.telemetry.track(TELEMETRY_EVENT.AGENTS.USER_CREATED_AGENT, {
...properties,
event_version: '2',
});
return;
case 'builder':
this.telemetry.track(TELEMETRY_EVENT.AGENTS.BUILDER_CREATED_AGENT, {
...properties,
event_version: '2',
});
return;
case 'mcp':
this.telemetry.track(TELEMETRY_EVENT.AGENTS.MCP_CREATED_AGENT, {
...properties,
event_version: '1',
});
return;
}
}
}
@@ -23,11 +23,12 @@ import { Telemetry } from '@/telemetry';
import { AgentsCredentialProvider } from './adapters/agents-credential-provider';
import { AgentCustomToolsService } from './agent-custom-tools.service';
import { buildAgentConfigurationTelemetryFromConfig } from './agent-telemetry';
import { buildAgentCapabilityTelemetryProperties } from './agent-telemetry';
import {
AgentModificationTelemetryService,
diffAgentConfigParts,
type AgentActor,
type AgentSidecarChanges,
} from './agent-modification-telemetry.service';
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
import { AgentSetupCompletionService } from './agent-setup-completion.service';
@@ -42,10 +43,7 @@ import { AgentHistoryRepository } from './repositories/agent-history.repository'
import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository';
import { AgentTaskRepository } from './repositories/agent-task.repository';
import { AgentRepository } from './repositories/agent.repository';
import {
capabilityCountTelemetryProperties,
countAgentCapabilities,
} from './utils/agent-capabilities';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import { saveAgentDraftFenced } from './utils/agent-draft.utils';
export type AgentPublishTrigger = 'explicit' | 'republish';
@@ -123,10 +121,7 @@ export class AgentPublishService {
versionId?: string,
pushRef?: string,
): Promise<PublishAgentResult> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
const expectedRevision = agent.revision;
@@ -138,14 +133,7 @@ export class AgentPublishService {
return { agent };
}
let targetHistory: AgentHistory | undefined;
if (versionId) {
const target = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agent.id);
if (!target) {
throw new NotFoundError(`Version "${versionId}" not found for agent "${agent.id}"`);
}
targetHistory = target;
}
const targetHistory = await this.findPublishTarget(versionId, agent.id);
const tasks = versionId
? new Map<string, AgentTask>()
@@ -164,66 +152,7 @@ export class AgentPublishService {
targetHistory ? targetHistory.schema : agent.schema,
);
await this.agentRepository.manager.transaction(async (trx) => {
let nextActiveVersionId: string;
let nextVersionId: string;
let nextActiveVersion: AgentHistory | null | undefined;
if (targetHistory) {
nextActiveVersionId = targetHistory.versionId;
nextActiveVersion = targetHistory;
nextVersionId = uuid();
} else {
nextVersionId = agent.versionId ?? uuid();
try {
nextActiveVersion = await this.agentHistoryRepository.saveVersion(
{
versionId: nextVersionId,
agentId: agent.id,
schema: agent.schema,
tools: this.customToolsService.snapshotConfiguredTools(
agent.schema,
agent.tools ?? {},
),
skills: this.pickConfiguredSkillBodies(agent.schema, agent.skills ?? {}),
publishedBy: user,
},
trx,
);
} catch (error) {
// Two concurrent publishes of the same draft share this
// versionId, so the loser collides on the history primary
// key before it can reach the revision fence. Surface the
// same retryable conflict the fence would have produced.
if (isUniqueConstraintError(error)) {
throw new ConflictError(
'Agent was modified concurrently while publishing; please retry',
);
}
throw error;
}
await this.snapshotConfiguredTasks(trx, nextVersionId, agent.schema, tasks);
nextActiveVersionId = nextVersionId;
}
const won = await this.agentRepository.setActiveVersionFenced(
agent.id,
expectedRevision,
{ activeVersionId: nextActiveVersionId, versionId: nextVersionId },
trx,
);
if (!won) {
throw new ConflictError('Agent was modified concurrently while publishing; please retry');
}
// Fence first: only mutate the in-memory entity once the row is ours,
// so a losing caller never sees phantom published state on the entity
// instance it still holds.
agent.activeVersionId = nextActiveVersionId;
agent.activeVersion = nextActiveVersion;
agent.versionId = nextVersionId;
agent.revision = expectedRevision + 1;
});
await this.commitPublication(agent, expectedRevision, user, tasks, targetHistory);
this.eventService.emit('agent-saved', { agentId });
this.agentUpdateBroadcaster.notify({ projectId, agentId, source: emitter.by }, pushRef);
@@ -232,24 +161,7 @@ export class AgentPublishService {
this.trackPublished(agent, projectId, user, emitter, targetHistory);
await emitSetupCompleted?.();
const credentialIntegrations = agent.integrations ?? [];
if (credentialIntegrations.length > 0) {
await Container.get(ChatIntegrationService)
.syncToConfig(agent, [], credentialIntegrations)
.catch((error) =>
this.logger.warn('Failed to connect integrations on publish', {
agentId,
error,
}),
);
}
const { AgentTaskService } = await import('./agent-task.service.js');
await Container.get(AgentTaskService)
.requestReconcile(agentId)
.catch((error) =>
this.logger.warn('Failed to register agent tasks on publish', { agentId, error }),
);
await this.startPublishedServices(agent);
this.logger.debug('Published SDK agent', { agentId, projectId, userId: user.id });
@@ -327,37 +239,14 @@ export class AgentPublishService {
by: AgentActor,
pushRef?: string,
): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
// Same optimistic revision fence as publish: a concurrent edit that bumped
// `revision` after this load makes the unpublish lose the fence and surface
// a user-retryable conflict instead of rolling back a newer active version.
const expectedRevision = agent.revision;
await this.agentRepository.manager.transaction(async (trx) => {
const nextVersionId = uuid();
// Fence first: only mutate the in-memory entity once the row is ours,
// so a losing caller never sees phantom unpublished state on the
// entity instance it still holds.
const won = await this.agentRepository.setActiveVersionFenced(
agent.id,
expectedRevision,
{ activeVersionId: null, versionId: nextVersionId },
trx,
);
if (!won) {
throw new ConflictError('Agent was modified concurrently while unpublishing; please retry');
}
agent.activeVersionId = null;
agent.activeVersion = null;
agent.versionId = nextVersionId;
agent.revision = expectedRevision + 1;
});
await this.commitUnpublication(agent, expectedRevision);
this.eventService.emit('agent-saved', { agentId });
this.agentUpdateBroadcaster.notify({ projectId, agentId, source: by }, pushRef);
@@ -399,15 +288,6 @@ export class AgentPublishService {
// The snapshot that actually went live, which for a republish is the
// version's schema rather than the draft.
const published = targetHistory ? targetHistory.schema : agent.schema;
const counts = countAgentCapabilities(published, agent.integrations);
// Only model and tool_types: this helper's own tool_count folds in MCP
// servers, provider tools, web search and sub-agents, which would
// disagree with the per-kind counts above.
const { model, tool_types } = buildAgentConfigurationTelemetryFromConfig(
published,
agent.integrations,
);
const properties = {
agent_id: agent.id,
project_id: projectId,
@@ -418,9 +298,7 @@ export class AgentPublishService {
// Set by the transaction above to either targetHistory.versionId or
// agent.versionId, so it is never null on this path.
version_id: agent.activeVersionId!,
...capabilityCountTelemetryProperties(counts),
model,
tool_types,
...buildAgentCapabilityTelemetryProperties(published, agent.integrations),
} as const;
switch (emitter.by) {
@@ -475,10 +353,7 @@ export class AgentPublishService {
modifiedBy: AgentActor,
pushRef?: string,
): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
const activeVersion = agent.activeVersion;
if (!activeVersion) {
@@ -525,10 +400,7 @@ export class AgentPublishService {
modifiedBy: AgentActor,
pushRef?: string,
): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
const previousSchema = agent.schema;
const previousTools = agent.tools ?? {};
@@ -587,7 +459,7 @@ export class AgentPublishService {
user: User,
modifiedBy: AgentActor,
previousSchema: AgentJsonConfig | null,
sidecarChanges: Partial<Record<'tools' | 'skills' | 'tasks', boolean>>,
sidecarChanges: AgentSidecarChanges,
): Promise<void> {
const integrations = agent.integrations ?? [];
this.modificationTelemetry.record({
@@ -625,10 +497,7 @@ export class AgentPublishService {
projectId: string,
versionId: string,
): Promise<{ agent: Agent; version: AgentHistory; tasks: AgentTaskSnapshot[] }> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
const version = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId);
if (!version) {
@@ -645,10 +514,7 @@ export class AgentPublishService {
take: number,
skip: number,
): Promise<AgentVersionListItemDto[]> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
const versions = await this.agentHistoryRepository.findByAgentId(agentId, take, skip);
@@ -738,27 +604,9 @@ export class AgentPublishService {
const existing = await repo.findBy({ agentId });
const snapshots = await this.agentTaskSnapshotRepository.findByVersionId(versionId, trx);
const existingBodies = Object.fromEntries(
existing.map((row) => [
row.id,
{
name: row.name,
objective: row.objective,
cronExpression: row.cronExpression,
timezone: row.timezone,
},
]),
);
const existingBodies = Object.fromEntries(existing.map((row) => [row.id, taskBody(row)]));
const snapshotBodies = Object.fromEntries(
snapshots.map((snapshot) => [
snapshot.taskId,
{
name: snapshot.name,
objective: snapshot.objective,
cronExpression: snapshot.cronExpression,
timezone: snapshot.timezone,
},
]),
snapshots.map((snapshot) => [snapshot.taskId, taskBody(snapshot)]),
);
const tasksChanged = !isEqual(existingBodies, snapshotBodies);
@@ -770,24 +618,144 @@ export class AgentPublishService {
const existingIds = new Set(existing.map((row) => row.id));
for (const snapshot of snapshots) {
if (existingIds.has(snapshot.taskId)) {
await repo.update(snapshot.taskId, {
name: snapshot.name,
objective: snapshot.objective,
cronExpression: snapshot.cronExpression,
timezone: snapshot.timezone,
});
await repo.update(snapshot.taskId, taskBody(snapshot));
} else {
await repo.insert({
id: snapshot.taskId,
agentId,
name: snapshot.name,
objective: snapshot.objective,
cronExpression: snapshot.cronExpression,
timezone: snapshot.timezone,
...taskBody(snapshot),
});
}
}
return tasksChanged;
}
private async startPublishedServices(agent: Agent): Promise<void> {
const agentId = agent.id;
const credentialIntegrations = agent.integrations ?? [];
if (credentialIntegrations.length > 0) {
await Container.get(ChatIntegrationService)
.syncToConfig(agent, [], credentialIntegrations)
.catch((error) =>
this.logger.warn('Failed to connect integrations on publish', {
agentId,
error,
}),
);
}
const { AgentTaskService } = await import('./agent-task.service.js');
await Container.get(AgentTaskService)
.requestReconcile(agentId)
.catch((error) =>
this.logger.warn('Failed to register agent tasks on publish', { agentId, error }),
);
}
private async commitUnpublication(agent: Agent, expectedRevision: number): Promise<void> {
await this.agentRepository.manager.transaction(async (trx) => {
const nextVersionId = uuid();
// Fence first: only mutate the in-memory entity once the row is ours,
// so a losing caller never sees phantom unpublished state on the
// entity instance it still holds.
const won = await this.agentRepository.setActiveVersionFenced(
agent.id,
expectedRevision,
{ activeVersionId: null, versionId: nextVersionId },
trx,
);
if (!won) {
throw new ConflictError('Agent was modified concurrently while unpublishing; please retry');
}
agent.activeVersionId = null;
agent.activeVersion = null;
agent.versionId = nextVersionId;
agent.revision = expectedRevision + 1;
});
}
private async findPublishTarget(
versionId: string | undefined,
agentId: string,
): Promise<AgentHistory | undefined> {
if (!versionId) return undefined;
const target = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId);
if (!target) throw new NotFoundError(`Version "${versionId}" not found for agent "${agentId}"`);
return target;
}
private async commitPublication(
agent: Agent,
expectedRevision: number,
user: User,
tasks: ReadonlyMap<string, AgentTask>,
targetHistory?: AgentHistory,
): Promise<void> {
await this.agentRepository.manager.transaction(async (trx) => {
const next = await this.preparePublishedVersion(trx, agent, user, tasks, targetHistory);
const won = await this.agentRepository.setActiveVersionFenced(
agent.id,
expectedRevision,
{ activeVersionId: next.activeVersionId, versionId: next.versionId },
trx,
);
if (!won)
throw new ConflictError('Agent was modified concurrently while publishing; please retry');
// Update the caller's entity only after the revision fence succeeds.
agent.activeVersionId = next.activeVersionId;
agent.activeVersion = next.activeVersion;
agent.versionId = next.versionId;
agent.revision = expectedRevision + 1;
});
}
private async preparePublishedVersion(
trx: EntityManager,
agent: Agent,
user: User,
tasks: ReadonlyMap<string, AgentTask>,
targetHistory?: AgentHistory,
) {
if (targetHistory) {
return {
activeVersionId: targetHistory.versionId,
activeVersion: targetHistory,
versionId: uuid(),
};
}
const versionId = agent.versionId ?? uuid();
const activeVersion = await this.saveDraftHistory(trx, agent, user, versionId);
await this.snapshotConfiguredTasks(trx, versionId, agent.schema, tasks);
return { activeVersionId: versionId, activeVersion, versionId };
}
private async saveDraftHistory(trx: EntityManager, agent: Agent, user: User, versionId: string) {
try {
return await this.agentHistoryRepository.saveVersion(
{
versionId,
agentId: agent.id,
schema: agent.schema,
tools: this.customToolsService.snapshotConfiguredTools(agent.schema, agent.tools ?? {}),
skills: this.pickConfiguredSkillBodies(agent.schema, agent.skills ?? {}),
publishedBy: user,
},
trx,
);
} catch (error) {
// Concurrent publishes of one draft can collide before they reach the revision fence.
if (isUniqueConstraintError(error)) {
throw new ConflictError('Agent was modified concurrently while publishing; please retry');
}
throw error;
}
}
}
function taskBody(task: Pick<AgentTask, 'name' | 'objective' | 'cronExpression' | 'timezone'>) {
const { name, objective, cronExpression, timezone } = task;
return { name, objective, cronExpression, timezone };
}
@@ -0,0 +1,24 @@
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { RoleService } from '@/services/role.service';
@Service()
export class AgentPushRecipientsService {
constructor(
private readonly userRepository: UserRepository,
private readonly roleService: RoleService,
) {}
async getProjectReaders(projectId: string): Promise<string[]> {
const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([
this.roleService.rolesWithScope('global', ['agent:read']),
this.roleService.rolesWithScope('project', ['agent:read']),
]);
return await this.userRepository.findIdsWithGlobalOrProjectRoles({
projectIds: [projectId],
projectRoleSlugs,
globalRoleSlugs,
});
}
}
@@ -1,4 +1,4 @@
import type { AttributeValue, BuiltTelemetry } from '@n8n/agents';
import type { AgentSnapshot, AttributeValue, BuiltTelemetry } from '@n8n/agents';
import { Telemetry } from '@n8n/agents';
import { AgentsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
@@ -84,14 +84,12 @@ export class AgentRunTracingService {
source: metadata.source,
...(metadata.userId ? { user_id: metadata.userId } : {}),
...(metadata.modelId ? { model_id: metadata.modelId } : {}),
...(isWorkflowTracingMetadata(metadata)
? {
...(metadata.executionId ? { execution_id: metadata.executionId } : {}),
...(metadata.workflowId ? { workflow_id: metadata.workflowId } : {}),
...(metadata.nodeId ? { node_id: metadata.nodeId } : {}),
}
: {}),
};
if (isWorkflowTracingMetadata(metadata)) {
if (metadata.executionId) attributes.execution_id = metadata.executionId;
if (metadata.workflowId) attributes.workflow_id = metadata.workflowId;
if (metadata.nodeId) attributes.node_id = metadata.nodeId;
}
const built = await new Telemetry()
.tracer(this.otelService.getTracer(AGENTS_TRACER_NAME))
@@ -113,9 +111,6 @@ export class AgentRunTracingService {
}
/** Format an agent snapshot's model as `provider/name`, or undefined if either is missing. */
export function modelIdFromSnapshot(model: {
provider: string | null;
name: string | null;
}): string | undefined {
export function modelIdFromSnapshot(model: AgentSnapshot['model']): string | undefined {
return model.provider && model.name ? `${model.provider}/${model.name}` : undefined;
}
@@ -1,6 +1,5 @@
import type { Agent as RuntimeAgent } from '@n8n/agents';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { User } from '@n8n/db';
import { OnPubSubEvent } from '@n8n/decorators';
@@ -8,10 +7,8 @@ import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { IAgentConfigurationTelemetryProperties } from '@/interfaces';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { TtlMap } from '@/utils/ttl-map';
import {
@@ -19,12 +16,16 @@ import {
type AgentSandboxPrincipalHash,
} from './agent-sandbox-principal';
import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service';
import { AgentChangePublisher } from './agent-change-publisher.service';
import { buildAgentConfigurationTelemetry } from './agent-telemetry';
import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service';
import type { UserToolAccessSnapshot } from './agent-runtime-reconstruction.service';
import type {
ReconstructedAgentRuntime,
UserToolAccessSnapshot,
} from './agent-runtime-reconstruction.service';
import type { Agent } from './entities/agent.entity';
import { AgentRepository } from './repositories/agent.repository';
import type { ToolRegistry } from './tool-registry';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
import { getPublishedAgentSnapshot } from './utils/agent-published-snapshot';
@@ -59,12 +60,8 @@ export interface GetRuntimeParams {
*/
const TOOL_ACCESS_RECHECK_INTERVAL_MS = Time.minutes.toMilliseconds;
export interface AgentRuntime {
agent: RuntimeAgent;
export interface AgentRuntime extends ReconstructedAgentRuntime {
agentId: string;
toolRegistry: ToolRegistry;
/** MCP server name -> registry attribution appended to replies that used its tools. */
mcpServerAttributions: Map<string, string>;
projectId: string;
telemetryConfiguration: IAgentConfigurationTelemetryProperties;
/**
@@ -120,8 +117,7 @@ export class AgentRuntimeCacheService {
constructor(
private readonly logger: Logger,
private readonly agentRepository: AgentRepository,
private readonly publisher: Publisher,
private readonly globalConfig: GlobalConfig,
private readonly changePublisher: AgentChangePublisher,
private readonly agentRuntimeReconstructionService: AgentRuntimeReconstructionService,
private readonly credentialsService: CredentialsService,
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
@@ -161,11 +157,10 @@ export class AgentRuntimeCacheService {
*/
clearRuntimes(agentId: string, options: { skipBroadcast?: boolean } = {}): void {
for (const key of this.runtimes.keys()) {
if (this.isRuntimeCacheKeyForAgent(key, agentId)) {
const entry = this.runtimes.get(key);
this.runtimes.delete(key);
if (entry) this.closeAgentResources(entry.agent, agentId);
}
if (!this.isRuntimeCacheKeyForAgent(key, agentId)) continue;
const entry = this.runtimes.get(key);
this.runtimes.delete(key);
if (entry) this.closeAgentResources(entry.agent, agentId);
}
for (const key of this.runtimeInitializations.keys()) {
@@ -175,21 +170,7 @@ export class AgentRuntimeCacheService {
}
if (options.skipBroadcast) return;
if (!this.globalConfig.multiMainSetup.enabled) return;
void this.publisher
.publishCommand({
command: 'agent-config-changed',
payload: { agentId },
})
.catch((error) => {
this.logger.warn(
`[AgentRuntimeCacheService] Failed to publish agent-config-changed for ${agentId}`,
{
error: error instanceof Error ? error.message : String(error),
},
);
});
void this.changePublisher.publish({ command: 'agent-config-changed', payload: { agentId } });
}
/**
@@ -259,49 +240,14 @@ export class AgentRuntimeCacheService {
const cached = this.runtimes.get(cacheKey);
if (cached) {
const accessStillCurrent = await this.toolAccessStillCurrent(cached, params);
const current = this.runtimes.get(cacheKey);
// The awaited re-check may race with cache invalidation or replacement.
if (current !== cached) {
if (current) return this.acquireRuntimeLease(current);
} else if (accessStillCurrent) {
this.runtimes.touch(cacheKey);
return this.acquireRuntimeLease(cached);
} else {
// Revoked grants: retire this runtime and rebuild below so the tool
// list is re-filtered against the user's current access.
this.runtimes.delete(cacheKey);
this.closeAgentResources(cached.agent, params.agentId);
}
const runtime = await this.acquireCachedRuntime(cacheKey, cached, params);
if (runtime) return runtime;
}
const initialization = this.runtimeInitializations.get(cacheKey);
if (initialization) return this.acquireRuntimeLease(await initialization.promise);
const token = Symbol(cacheKey);
const runtimeInitialization: RuntimeInitialization = {
token,
promise: (async () => {
const runtime = await this.reconstructRuntime(params);
if (this.runtimeInitializations.get(cacheKey)?.token !== token) {
this.closeAgentResources(runtime.agent, params.agentId);
throw new Error(`Agent ${params.agentId} runtime initialization was invalidated`);
}
this.runtimes.set(cacheKey, runtime);
const cachedRuntime = this.runtimes.get(cacheKey);
if (!cachedRuntime) throw new Error(`Agent ${params.agentId} failed to reconstruct`);
return cachedRuntime;
})(),
};
runtimeInitialization.promise = runtimeInitialization.promise.finally(() => {
if (this.runtimeInitializations.get(cacheKey)?.token === token) {
this.runtimeInitializations.delete(cacheKey);
}
});
this.runtimeInitializations.set(cacheKey, runtimeInitialization);
return this.acquireRuntimeLease(await runtimeInitialization.promise);
return this.acquireRuntimeLease(await this.initializeRuntime(cacheKey, params).promise);
}
/**
@@ -365,8 +311,12 @@ export class AgentRuntimeCacheService {
previewChat,
} = params;
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agentEntity) throw new NotFoundError(`Agent ${agentId} not found`);
const agentEntity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
`Agent ${agentId} not found`,
);
const agentData: Agent = usePublishedVersion
? getPublishedAgentSnapshot(agentEntity)
@@ -413,4 +363,53 @@ export class AgentRuntimeCacheService {
toolAccessCheckedAt: Date.now(),
};
}
private async acquireCachedRuntime(
cacheKey: string,
cached: AgentRuntime,
params: GetRuntimeParams,
): Promise<AgentRuntime | undefined> {
const accessStillCurrent = await this.toolAccessStillCurrent(cached, params);
const current = this.runtimes.get(cacheKey);
// The access check can race with cache invalidation or replacement.
if (current !== cached) {
if (current) return this.acquireRuntimeLease(current);
return undefined;
}
if (accessStillCurrent) {
this.runtimes.touch(cacheKey);
return this.acquireRuntimeLease(cached);
}
// Rebuild the tool list after a grant is revoked.
this.runtimes.delete(cacheKey);
this.closeAgentResources(cached.agent, params.agentId);
return undefined;
}
private initializeRuntime(cacheKey: string, params: GetRuntimeParams): RuntimeInitialization {
const token = Symbol(cacheKey);
const runtimeInitialization: RuntimeInitialization = {
token,
promise: (async () => {
const runtime = await this.reconstructRuntime(params);
if (this.runtimeInitializations.get(cacheKey)?.token !== token) {
this.closeAgentResources(runtime.agent, params.agentId);
throw new Error(`Agent ${params.agentId} runtime initialization was invalidated`);
}
this.runtimes.set(cacheKey, runtime);
const cachedRuntime = this.runtimes.get(cacheKey);
if (!cachedRuntime) throw new Error(`Agent ${params.agentId} failed to reconstruct`);
return cachedRuntime;
})(),
};
runtimeInitialization.promise = runtimeInitialization.promise.finally(() => {
if (this.runtimeInitializations.get(cacheKey)?.token === token) {
this.runtimeInitializations.delete(cacheKey);
}
});
this.runtimeInitializations.set(cacheKey, runtimeInitialization);
return runtimeInitialization;
}
}
@@ -2,10 +2,12 @@ import type { FetchFn, McpToolCallSettledEvent } from '@n8n/agents';
import type { AgentJsonConfig } from '@n8n/api-types';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import type { ReferencedToolKind } from './tool-registry';
export interface AgentToolInstrumentationContext {
/** Sanitized tool name — the same identifier the model calls and `GenerateResult.toolCalls` reports. */
toolName: string;
toolKind: 'node' | 'workflow';
toolKind: ReferencedToolKind;
}
export interface AgentMcpToolCallSettledContext extends McpToolCallSettledEvent {
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,6 @@ import { OperationalError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { v5 as uuidv5 } from 'uuid';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiService } from '@/services/ai.service';
import { SandboxSettingsService } from '@/services/sandbox-settings.service';
import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
@@ -27,6 +26,7 @@ import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
import { assertKnowledgePathSegment } from './agent-knowledge-storage';
import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal';
import { AgentRepository } from './repositories/agent.repository';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
const WORKSPACE_SANDBOX_NAMESPACE = '38348f53-e947-42c7-8c04-83aa154be385';
const KNOWLEDGE_SANDBOX_NAMESPACE = '51989c13-3ae7-4167-8e64-d874dd068795';
@@ -353,10 +353,7 @@ export class AgentSandboxRuntimeService {
lifecycle: SandboxLifecycle,
startOptions: SandboxStartOptions,
): Promise<AgentSandboxRuntime> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
await getAgentOrThrow(this.agentRepository, agentId, projectId);
const config =
provider === 'daytona'
@@ -318,99 +318,142 @@ function buildExecutionRun(
function buildEventRun(event: TimelineEvent, execution: AgentExecution, path: string): DraftRun {
switch (event.type) {
case 'background-task-signal':
return {
path,
name: 'Background task results received',
runType: 'chain',
startTime: event.timestamp,
endTime: event.timestamp,
inputs: { tasks: event.signal.tasks },
outputs: {},
metadata: {},
children: [],
};
return buildBackgroundTaskSignalRun(event, path);
case 'text':
return {
path,
name: 'Agent response',
runType: 'llm',
startTime: event.timestamp,
endTime: event.endTime ?? event.timestamp,
inputs: {},
outputs: { text: event.content },
metadata: executionUsageMetadata(execution),
children: [],
};
return buildTextRun(event, execution, path);
case 'reasoning':
return {
path,
name: 'Reasoning',
runType: 'chain',
startTime: event.timestamp,
endTime: event.endTime ?? event.timestamp,
inputs: {},
outputs: { content: event.content },
metadata: {},
children: [],
};
return buildReasoningRun(event, path);
case 'suspension':
return {
path,
name: 'Suspension',
runType: 'chain',
startTime: event.timestamp,
endTime: event.timestamp,
inputs: {
toolName: event.toolName,
toolCallId: event.toolCallId,
},
outputs: { status: 'suspended' },
metadata: {},
children: [],
};
return buildSuspensionRun(event, path);
case 'hitl-response':
return {
path,
name: 'HITL response',
runType: 'chain',
startTime: event.timestamp,
endTime: event.timestamp,
inputs: { toolCallId: event.toolCallId },
outputs: toRecord(event.response),
metadata: {},
children: [],
};
return buildHitlResponseRun(event, path);
case 'tool-call':
return {
path,
name: event.name,
runType: 'tool',
startTime: event.startTime,
endTime: event.endTime,
inputs: toRecord(event.input),
outputs: {
...toRecord(event.output),
...(event.childTrace ? { childTrace: event.childTrace } : {}),
},
error: event.success ? undefined : toolError(event.output),
metadata: {
kind: event.kind,
toolCallId: event.toolCallId,
success: event.success,
workflowId: event.workflowId,
workflowName: event.workflowName,
workflowExecutionId: event.workflowExecutionId,
triggerType: event.triggerType,
nodeType: event.nodeType,
nodeTypeVersion: event.nodeTypeVersion,
nodeDisplayName: event.nodeDisplayName,
nodeParameters: event.nodeParameters,
},
children: [],
};
return buildToolCallRun(event, path);
}
}
function buildBackgroundTaskSignalRun(
event: Extract<TimelineEvent, { type: 'background-task-signal' }>,
path: string,
): DraftRun {
return {
path,
name: 'Background task results received',
runType: 'chain',
startTime: event.timestamp,
endTime: event.timestamp,
inputs: { tasks: event.signal.tasks },
outputs: {},
metadata: {},
children: [],
};
}
function buildTextRun(
event: Extract<TimelineEvent, { type: 'text' }>,
execution: AgentExecution,
path: string,
): DraftRun {
return {
path,
name: 'Agent response',
runType: 'llm',
startTime: event.timestamp,
endTime: event.endTime ?? event.timestamp,
inputs: {},
outputs: { text: event.content },
metadata: executionUsageMetadata(execution),
children: [],
};
}
function buildReasoningRun(
event: Extract<TimelineEvent, { type: 'reasoning' }>,
path: string,
): DraftRun {
return {
path,
name: 'Reasoning',
runType: 'chain',
startTime: event.timestamp,
endTime: event.endTime ?? event.timestamp,
inputs: {},
outputs: { content: event.content },
metadata: {},
children: [],
};
}
function buildSuspensionRun(
event: Extract<TimelineEvent, { type: 'suspension' }>,
path: string,
): DraftRun {
return {
path,
name: 'Suspension',
runType: 'chain',
startTime: event.timestamp,
endTime: event.timestamp,
inputs: {
toolName: event.toolName,
toolCallId: event.toolCallId,
},
outputs: { status: 'suspended' },
metadata: {},
children: [],
};
}
function buildHitlResponseRun(
event: Extract<TimelineEvent, { type: 'hitl-response' }>,
path: string,
): DraftRun {
return {
path,
name: 'HITL response',
runType: 'chain',
startTime: event.timestamp,
endTime: event.timestamp,
inputs: { toolCallId: event.toolCallId },
outputs: toRecord(event.response),
metadata: {},
children: [],
};
}
function buildToolCallRun(
event: Extract<TimelineEvent, { type: 'tool-call' }>,
path: string,
): DraftRun {
return {
path,
name: event.name,
runType: 'tool',
startTime: event.startTime,
endTime: event.endTime,
inputs: toRecord(event.input),
outputs: {
...toRecord(event.output),
...(event.childTrace ? { childTrace: event.childTrace } : {}),
},
error: event.success ? undefined : toolError(event.output),
metadata: {
kind: event.kind,
toolCallId: event.toolCallId,
success: event.success,
workflowId: event.workflowId,
workflowName: event.workflowName,
workflowExecutionId: event.workflowExecutionId,
triggerType: event.triggerType,
nodeType: event.nodeType,
nodeTypeVersion: event.nodeTypeVersion,
nodeDisplayName: event.nodeDisplayName,
nodeParameters: event.nodeParameters,
},
children: [],
};
}
function executionMetadata(execution: AgentExecution): Record<string, unknown> {
return {
executionId: execution.id,
@@ -15,14 +15,16 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import {
AgentModificationTelemetryService,
type AgentMutationTelemetryContext,
diffAgentConfigParts,
buildAgentMutationEvent,
captureAgentMutation,
type AgentMutationSnapshot,
} from './agent-modification-telemetry.service';
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
import { Agent } from './entities/agent.entity';
import { AgentRepository } from './repositories/agent.repository';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import { getAgentSkillHash } from './utils/agent-config-hash';
import { isUnconfiguredAgent } from './utils/agent-capabilities';
import { generateAgentResourceId } from './utils/agent-resource-id';
@Service()
@@ -35,8 +37,12 @@ export class AgentSkillsService {
) {}
async listSkills(agentId: string, projectId: string): Promise<Record<string, AgentSkill>> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
return entity.skills ?? {};
}
@@ -100,8 +106,12 @@ export class AgentSkillsService {
throw new UserError('At least one skill is required.');
}
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
if (attach && !entity.schema) throw new UserError('Agent has no JSON config yet.');
for (const skill of skills) {
@@ -109,36 +119,14 @@ export class AgentSkillsService {
}
this.assertBatchSkillNamesAreUnique(entity.skills ?? {}, skills);
const previousSchema = entity.schema ?? null;
const previousIntegrations = entity.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(entity);
const results = skills.map((skill) => ({ id: this.addSkill(entity, skill), skill }));
if (attach) {
for (const { id } of results) this.attachSkillRef(entity, id);
}
markAgentDraftDirty(entity);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.agentUpdateBroadcaster.notify(
{ projectId, agentId, source: context.modifiedBy },
context.pushRef,
);
await this.clearRuntimes(agentId);
this.modificationTelemetry.record({
agent: saved,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
saved.schema,
previousIntegrations,
saved.integrations ?? [],
{ skills: true },
),
wasUnconfigured,
});
const saved = await this.saveSkillChanges(entity, projectId, context, previous);
this.logger.debug(attach ? 'Created and attached agent skill' : 'Created agent skills', {
agentId,
@@ -161,8 +149,12 @@ export class AgentSkillsService {
context: AgentMutationTelemetryContext,
baseSkillHash?: string,
): Promise<AgentSkillMutationResponse> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
const existing = entity.skills?.[skillId];
if (!existing) throw new NotFoundError('Skill not found');
@@ -185,36 +177,14 @@ export class AgentSkillsService {
};
}
const previousSchema = entity.schema ?? null;
const previousIntegrations = entity.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(entity);
entity.skills = {
...(entity.skills ?? {}),
[skillId]: updated,
};
markAgentDraftDirty(entity);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.agentUpdateBroadcaster.notify(
{ projectId, agentId, source: context.modifiedBy },
context.pushRef,
);
await this.clearRuntimes(agentId);
this.modificationTelemetry.record({
agent: saved,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
saved.schema,
previousIntegrations,
saved.integrations ?? [],
{ skills: true },
),
wasUnconfigured,
});
const saved = await this.saveSkillChanges(entity, projectId, context, previous);
this.logger.debug('Updated agent skill', { agentId, projectId, skillId });
@@ -232,15 +202,17 @@ export class AgentSkillsService {
skillId: string,
context: AgentMutationTelemetryContext,
): Promise<void> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
const skills = { ...(entity.skills ?? {}) };
if (!skills[skillId]) throw new NotFoundError('Skill not found');
const previousSchema = entity.schema ?? null;
const previousIntegrations = entity.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(entity);
delete skills[skillId];
entity.skills = skills;
@@ -249,27 +221,7 @@ export class AgentSkillsService {
entity.schema.skills = entity.schema.skills.filter((t) => t.id !== skillId);
}
markAgentDraftDirty(entity);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.agentUpdateBroadcaster.notify(
{ projectId, agentId, source: context.modifiedBy },
context.pushRef,
);
await this.clearRuntimes(agentId);
this.modificationTelemetry.record({
agent: saved,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
saved.schema,
previousIntegrations,
saved.integrations ?? [],
{ skills: true },
),
wasUnconfigured,
});
await this.saveSkillChanges(entity, projectId, context, previous);
this.logger.debug('Deleted agent skill', { agentId, projectId, skillId });
}
@@ -355,4 +307,22 @@ export class AgentSkillsService {
const { AgentRuntimeCacheService } = await import('./agent-runtime-cache.service.js');
Container.get(AgentRuntimeCacheService).clearRuntimes(agentId);
}
private async saveSkillChanges(
entity: Agent,
projectId: string,
context: AgentMutationTelemetryContext,
previous: AgentMutationSnapshot,
): Promise<Agent> {
markAgentDraftDirty(entity);
const saved = await saveAgentDraftFenced(this.agentRepository, entity);
this.agentUpdateBroadcaster.notify(
{ projectId, agentId: entity.id, source: context.modifiedBy },
context.pushRef,
);
await this.clearRuntimes(entity.id);
this.modificationTelemetry.record(
buildAgentMutationEvent(saved, projectId, context, previous, { skills: true }),
);
return saved;
}
}
@@ -13,14 +13,15 @@ import { InstanceSettings, ScheduledTaskManager, type ScheduledTaskGroup } from
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import {
AgentModificationTelemetryService,
type AgentMutationTelemetryContext,
diffAgentConfigParts,
buildAgentMutationEvent,
captureAgentMutation,
} from './agent-modification-telemetry.service';
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
import { AgentChangePublisher } from './agent-change-publisher.service';
import { AgentUpdateBroadcaster } from './agent-update-broadcaster';
import { AgentTaskJobRegistrar } from './scheduling/agent-task-job-registrar';
import { knownTaskTimezone } from './scheduling/task-timezone';
@@ -29,13 +30,13 @@ import { AgentTask } from './entities/agent-task.entity';
import type { AgentTaskSnapshot } from './entities/agent-task-snapshot.entity';
import { isValidCronExpression } from './integrations/cron-validation';
import { AgentRepository } from './repositories/agent.repository';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import {
type AgentTaskRunLockHandle,
AgentTaskRunLockRepository,
} from './repositories/agent-task-run-lock.repository';
import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository';
import { AgentTaskRepository } from './repositories/agent-task.repository';
import { isUnconfiguredAgent } from './utils/agent-capabilities';
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
import { taskRunMemoryResourceId } from './utils/agent-memory-scope';
import { generateAgentResourceId } from './utils/agent-resource-id';
@@ -73,7 +74,7 @@ export class AgentTaskService {
private readonly agentExecutionOrchestratorService: AgentExecutionOrchestratorService,
private readonly instanceSettings: InstanceSettings,
private readonly scheduledTaskManager: ScheduledTaskManager,
private readonly publisher: Publisher,
private readonly changePublisher: AgentChangePublisher,
private readonly modificationTelemetry: AgentModificationTelemetryService,
private readonly durableJobRegistrar: AgentTaskJobRegistrar,
private readonly agentUpdateBroadcaster: AgentUpdateBroadcaster,
@@ -138,13 +139,10 @@ export class AgentTaskService {
this.assertValidTimezone(dto.timezone);
}
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
if (!agent.schema) throw new BadRequestError('Agent has no config yet');
const previousSchema = agent.schema;
const previousIntegrations = agent.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(agent);
const tasks = dtos.map((dto) => {
const taskId = generateAgentResourceId(
@@ -175,20 +173,9 @@ export class AgentTaskService {
context.pushRef,
);
this.modificationTelemetry.record({
agent,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
agent.schema,
previousIntegrations,
agent.integrations ?? [],
{ tasks: true },
),
wasUnconfigured,
});
this.modificationTelemetry.record(
buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }),
);
this.logger.debug('[AgentTaskService] Created tasks', {
agentId,
@@ -213,42 +200,14 @@ export class AgentTaskService {
): Promise<AgentTaskDto> {
const task = await this.getOrThrow(agentId, taskId);
let changed = false;
if (dto.cronExpression !== undefined) {
this.assertValidCron(dto.cronExpression);
if (dto.cronExpression !== task.cronExpression) {
task.cronExpression = dto.cronExpression;
changed = true;
}
}
// `null` resets to the instance timezone; omitting the field keeps the
// current one, so an older client can still update name/objective/cron.
if (dto.timezone !== undefined) {
this.assertValidTimezone(dto.timezone);
const timezone = dto.timezone ?? null;
if (timezone !== task.timezone) {
task.timezone = timezone;
changed = true;
}
}
if (dto.name !== undefined && dto.name !== task.name) {
task.name = dto.name;
changed = true;
}
if (dto.objective !== undefined && dto.objective !== task.objective) {
task.objective = dto.objective;
changed = true;
}
const changed = this.applyTaskUpdates(task, dto);
// Nothing actually changed — skip the agent lookup, draft-dirty bump, and writes.
if (!changed) return this.toDto(task);
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
const previousSchema = agent.schema ?? null;
const previousIntegrations = agent.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(agent);
const saved = await this.agentRepository.manager.transaction(async (em) => {
const savedTask = await em.save(task);
@@ -261,20 +220,9 @@ export class AgentTaskService {
context.pushRef,
);
this.modificationTelemetry.record({
agent,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
agent.schema,
previousIntegrations,
agent.integrations ?? [],
{ tasks: true },
),
wasUnconfigured,
});
this.modificationTelemetry.record(
buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }),
);
return this.toDto(saved);
}
@@ -287,12 +235,9 @@ export class AgentTaskService {
context: AgentMutationTelemetryContext,
): Promise<void> {
const task = await this.getOrThrow(agentId, taskId);
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
const previousSchema = agent.schema ?? null;
const previousIntegrations = agent.integrations ?? [];
const wasUnconfigured = isUnconfiguredAgent(previousSchema, previousIntegrations);
const previous = captureAgentMutation(agent);
if (agent.schema?.tasks) {
agent.schema.tasks = agent.schema.tasks.filter((ref) => ref.id !== taskId);
@@ -308,20 +253,9 @@ export class AgentTaskService {
context.pushRef,
);
this.modificationTelemetry.record({
agent,
projectId,
user: context.user,
by: context.modifiedBy,
changedParts: diffAgentConfigParts(
previousSchema,
agent.schema,
previousIntegrations,
agent.integrations ?? [],
{ tasks: true },
),
wasUnconfigured,
});
this.modificationTelemetry.record(
buildAgentMutationEvent(agent, projectId, context, previous, { tasks: true }),
);
this.logger.debug('[AgentTaskService] Deleted task', { agentId, taskId });
}
@@ -390,15 +324,7 @@ export class AgentTaskService {
/** Broadcast a task reconcile to peer mains (no-op outside multi-main). */
private broadcastTasksChanged(agentId: string): void {
if (!this.globalConfig.multiMainSetup.enabled) return;
void this.publisher
.publishCommand({ command: 'agent-tasks-changed', payload: { agentId } })
.catch((error) =>
this.logger.warn('[AgentTaskService] Failed to publish agent-tasks-changed', {
agentId,
error: error instanceof Error ? error.message : String(error),
}),
);
void this.changePublisher.publish({ command: 'agent-tasks-changed', payload: { agentId } });
}
/** Stop all cron jobs belonging to an agent — called on unpublish/agent delete. */
@@ -564,27 +490,7 @@ export class AgentTaskService {
return 'skipped-active';
}
const renewInterval = this.startTaskRunLockRenewal(lock);
void (async () => {
try {
await this.runTask(agent, snapshot);
} catch (error) {
this.logger.error('[AgentTaskService] Scheduled task run failed', {
taskId,
agentId,
error: error instanceof Error ? error.message : String(error),
});
} finally {
clearInterval(renewInterval);
await this.taskRunLockRepository.release(lock).catch((error) => {
this.logger.warn('[AgentTaskService] Failed to release task run lock', {
taskId,
agentId,
error: error instanceof Error ? error.message : String(error),
});
});
}
})();
void this.runScheduledTask(agent, snapshot, lock);
return 'started';
}
@@ -772,4 +678,63 @@ export class AgentTaskService {
updatedAt: task.updatedAt.toISOString(),
};
}
private applyTaskUpdates(task: AgentTask, dto: UpdateAgentTaskDto): boolean {
let changed = false;
if (dto.cronExpression !== undefined) {
this.assertValidCron(dto.cronExpression);
if (dto.cronExpression !== task.cronExpression) {
task.cronExpression = dto.cronExpression;
changed = true;
}
}
// `null` resets to the instance timezone; omitting the field keeps the
// current one, so an older client can still update name/objective/cron.
if (dto.timezone !== undefined) {
this.assertValidTimezone(dto.timezone);
const timezone = dto.timezone ?? null;
if (timezone !== task.timezone) {
task.timezone = timezone;
changed = true;
}
}
if (dto.name !== undefined && dto.name !== task.name) {
task.name = dto.name;
changed = true;
}
if (dto.objective !== undefined && dto.objective !== task.objective) {
task.objective = dto.objective;
changed = true;
}
return changed;
}
private async runScheduledTask(
agent: Agent,
snapshot: AgentTaskSnapshot,
lock: AgentTaskRunLockHandle,
): Promise<void> {
const agentId = agent.id;
const taskId = snapshot.taskId;
const renewInterval = this.startTaskRunLockRenewal(lock);
try {
await this.runTask(agent, snapshot);
} catch (error) {
this.logger.error('[AgentTaskService] Scheduled task run failed', {
taskId,
agentId,
error: error instanceof Error ? error.message : String(error),
});
} finally {
clearInterval(renewInterval);
await this.taskRunLockRepository.release(lock).catch((error) => {
this.logger.warn('[AgentTaskService] Failed to release task run lock', {
taskId,
agentId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
}
@@ -12,11 +12,9 @@ import {
} from '@n8n/decorators';
import type { Response } from 'express';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AgentTaskService } from './agent-task.service';
import type { Agent } from './entities/agent.entity';
import { AgentRepository } from './repositories/agent.repository';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
@RestController('/projects/:projectId/agents/v2')
export class AgentTasksController {
@@ -25,12 +23,6 @@ export class AgentTasksController {
private readonly agentRepository: AgentRepository,
) {}
private async getAgentOrThrow(agentId: string, projectId: string): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
return agent;
}
@Get('/:agentId/tasks')
@ProjectScope('agent:read')
async listTasks(
@@ -38,7 +30,7 @@ export class AgentTasksController {
_res: Response,
@Param('agentId') agentId: string,
): Promise<AgentTaskDto[]> {
await this.getAgentOrThrow(agentId, req.params.projectId);
await getAgentOrThrow(this.agentRepository, agentId, req.params.projectId);
return await this.agentTaskService.list(agentId);
}
@@ -51,7 +43,7 @@ export class AgentTasksController {
@Body payload: CreateAgentTaskDto,
): Promise<AgentTaskDto> {
const projectId = req.params.projectId;
await this.getAgentOrThrow(agentId, projectId);
await getAgentOrThrow(this.agentRepository, agentId, projectId);
return await this.agentTaskService.create(agentId, projectId, payload, {
user: req.user,
modifiedBy: 'user',
@@ -69,7 +61,7 @@ export class AgentTasksController {
@Body payload: UpdateAgentTaskDto,
): Promise<AgentTaskDto> {
const projectId = req.params.projectId;
await this.getAgentOrThrow(agentId, projectId);
await getAgentOrThrow(this.agentRepository, agentId, projectId);
return await this.agentTaskService.update(agentId, projectId, taskId, payload, {
user: req.user,
modifiedBy: 'user',
@@ -86,7 +78,7 @@ export class AgentTasksController {
@Param('taskId') taskId: string,
): Promise<{ success: true }> {
const projectId = req.params.projectId;
await this.getAgentOrThrow(agentId, projectId);
await getAgentOrThrow(this.agentRepository, agentId, projectId);
await this.agentTaskService.delete(agentId, projectId, taskId, {
user: req.user,
modifiedBy: 'user',
@@ -103,7 +95,7 @@ export class AgentTasksController {
@Param('agentId') agentId: string,
@Param('taskId') taskId: string,
): Promise<{ success: true }> {
await this.getAgentOrThrow(agentId, req.params.projectId);
await getAgentOrThrow(this.agentRepository, agentId, req.params.projectId);
await this.agentTaskService.runNow(agentId, taskId, req.user);
return { success: true };
}
@@ -1,4 +1,4 @@
import type { AgentJsonConfig } from '@n8n/api-types';
import type { AgentIntegrationConfig, AgentJsonConfig } from '@n8n/api-types';
import type {
AgentTelemetryMemoryType,
@@ -6,6 +6,30 @@ import type {
} from '@/interfaces';
import type { Agent } from './entities/agent.entity';
import type { MessageRecord } from './execution-recorder';
import {
capabilityCountTelemetryProperties,
countAgentCapabilities,
} from './utils/agent-capabilities';
export function buildAgentTurnMetrics(record: MessageRecord) {
return {
latency_ms: record.duration,
cost: record.totalCost ?? 0,
token_count: record.usage?.totalTokens ?? 0,
tool_call_count: record.timeline.filter((event) => event.type === 'tool-call').length,
};
}
export function buildAgentCapabilityTelemetryProperties(
config: AgentJsonConfig | null,
integrations: AgentIntegrationConfig[],
) {
const counts = countAgentCapabilities(config, integrations);
// Keep capability counts separate from the runtime tool count, which includes provider tools.
const { model, tool_types } = buildAgentConfigurationTelemetryFromConfig(config, integrations);
return { ...capabilityCountTelemetryProperties(counts), model, tool_types };
}
export function buildAgentConfigurationTelemetry(
agent: Agent,
@@ -43,6 +43,14 @@ export type PrepareDraftRunResult =
| { status: 'session_not_found' }
| { status: 'agent_misconfigured'; missing: string[] };
interface DraftRunState {
response: string;
suspensions: AgentTestRunSuspension[];
errorChunk?: Extract<StreamChunk, { type: 'error' }>;
observerFailed: boolean;
observerError?: unknown;
}
interface DraftRunConsumptionOptions {
errorMode?: 'throw' | 'forward';
onChunk?: (chunk: StreamChunk) => void;
@@ -299,33 +307,7 @@ export class AgentTestRunService {
const continuation = agentTestRunContinuationSchema.safeParse(input.continuation);
if (!continuation.success) throw new InvalidAgentTestRunCheckpointError();
let checkpoint: SerializableAgentState | undefined;
try {
checkpoint = await this.n8nCheckpointStorage.load(continuation.data.runId, input.agentId);
} catch (error) {
if (error instanceof UserError) throw new InvalidAgentTestRunCheckpointError();
throw error;
}
const pendingToolCall = checkpoint?.pendingToolCalls[continuation.data.toolCallId];
const expectedResourceId = draftChatMemoryResourceId(input.user.id);
if (
checkpoint?.status !== 'suspended' ||
checkpoint.persistence?.delegated === true ||
checkpoint.persistence?.threadId !== continuation.data.sessionId ||
checkpoint.persistence?.resourceId !== expectedResourceId ||
!pendingToolCall?.suspended ||
pendingToolCall.runId !== continuation.data.runId ||
!parseStandardApprovalSuspension({
runId: pendingToolCall.runId,
toolCallId: pendingToolCall.toolCallId,
toolName: pendingToolCall.toolName,
suspendPayload: pendingToolCall.suspendPayload,
resumeSchema: pendingToolCall.resumeSchema,
})
) {
throw new InvalidAgentTestRunCheckpointError();
}
await this.validateDraftApprovalCheckpoint(continuation.data, input.agentId, input.user.id);
return await this.resumeDraftRun({
agentId: input.agentId,
@@ -374,56 +356,101 @@ export class AgentTestRunService {
getExecutionId: () => string | undefined,
{ errorMode = 'throw', onChunk }: DraftRunConsumptionOptions,
): Promise<PreparedDraftRunResult> {
let response = initialResponse;
const suspensions: AgentTestRunSuspension[] = [];
let errorChunk: Extract<StreamChunk, { type: 'error' }> | undefined;
let observerFailed = false;
let observerError: unknown;
const state: DraftRunState = {
response: initialResponse,
suspensions: [],
observerFailed: false,
};
try {
for await (const chunk of stream) {
if (!observerFailed && onChunk) {
try {
onChunk(chunk);
} catch (error) {
observerFailed = true;
observerError = error;
}
}
if (chunk.type === 'error' && errorMode === 'throw' && !observerFailed) {
errorChunk ??= chunk;
continue;
}
if (errorChunk) continue;
if (chunk.type === 'text-delta') {
response += chunk.delta;
} else if (chunk.type === 'tool-call-suspended') {
suspensions.push({
runId: chunk.runId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
...(chunk.input !== undefined ? { input: chunk.input } : {}),
...(chunk.suspendPayload !== undefined ? { suspendPayload: chunk.suspendPayload } : {}),
...(chunk.resumeSchema !== undefined ? { resumeSchema: chunk.resumeSchema } : {}),
});
}
this.observeDraftChunk(chunk, onChunk, state);
this.collectDraftChunk(chunk, errorMode, state);
}
} catch (error) {
if (observerFailed) throw observerError;
if (state.observerFailed) throw state.observerError;
throw error;
}
if (observerFailed) throw observerError;
// Draining preserves terminal usage and lets finalization failures take precedence.
if (errorChunk) throw errorChunk.error;
if (state.observerFailed) throw state.observerError;
// Drain first so terminal usage and finalization errors are preserved.
if (state.errorChunk) throw state.errorChunk.error;
const executionId = getExecutionId();
if (!executionId) throw new UnexpectedError('Agent execution completed without a recorded ID');
const metadata = {
response,
executionId,
};
return suspensions.length > 0
? { status: 'suspended', ...metadata, suspensions }
: { status: 'completed', ...metadata };
const metadata = { response: state.response, executionId };
if (state.suspensions.length > 0) {
return { status: 'suspended', ...metadata, suspensions: state.suspensions };
}
return { status: 'completed', ...metadata };
}
private async validateDraftApprovalCheckpoint(
continuation: AgentTestRunContinuation,
agentId: string,
userId: string,
): Promise<void> {
let checkpoint: SerializableAgentState | undefined;
try {
checkpoint = await this.n8nCheckpointStorage.load(continuation.runId, agentId);
} catch (error) {
if (error instanceof UserError) throw new InvalidAgentTestRunCheckpointError();
throw error;
}
const pendingToolCall = checkpoint?.pendingToolCalls[continuation.toolCallId];
const expectedResourceId = draftChatMemoryResourceId(userId);
if (
checkpoint?.status !== 'suspended' ||
checkpoint.persistence?.delegated === true ||
checkpoint.persistence?.threadId !== continuation.sessionId ||
checkpoint.persistence?.resourceId !== expectedResourceId ||
!pendingToolCall?.suspended ||
pendingToolCall.runId !== continuation.runId ||
!parseStandardApprovalSuspension({
runId: pendingToolCall.runId,
toolCallId: pendingToolCall.toolCallId,
toolName: pendingToolCall.toolName,
suspendPayload: pendingToolCall.suspendPayload,
resumeSchema: pendingToolCall.resumeSchema,
})
) {
throw new InvalidAgentTestRunCheckpointError();
}
}
private observeDraftChunk(
chunk: StreamChunk,
onChunk: DraftRunConsumptionOptions['onChunk'],
state: DraftRunState,
): void {
if (state.observerFailed || !onChunk) return;
try {
onChunk(chunk);
} catch (error) {
state.observerFailed = true;
state.observerError = error;
}
}
private collectDraftChunk(
chunk: StreamChunk,
errorMode: DraftRunConsumptionOptions['errorMode'],
state: DraftRunState,
): void {
if (chunk.type === 'error' && errorMode === 'throw' && !state.observerFailed) {
state.errorChunk ??= chunk;
return;
}
if (state.errorChunk) return;
if (chunk.type === 'text-delta') {
state.response += chunk.delta;
} else if (chunk.type === 'tool-call-suspended') {
state.suspensions.push({
runId: chunk.runId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
...(chunk.input !== undefined ? { input: chunk.input } : {}),
...(chunk.suspendPayload !== undefined ? { suspendPayload: chunk.suspendPayload } : {}),
...(chunk.resumeSchema !== undefined ? { resumeSchema: chunk.resumeSchema } : {}),
});
}
}
}
@@ -11,6 +11,7 @@ import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { UnexpectedError } from 'n8n-workflow';
import type { AgentSessionMode } from './utils/agent-thread-access';
import { AgentExecutionRecordingError } from './agent-execution-recording.error';
import { AgentChatExecutionService } from './agent-chat-execution.service';
import {
@@ -25,7 +26,7 @@ import { createAttributionTracker } from './utils/mcp-attribution';
type RecordingContext = Pick<StartExecutionParams, 'projectId' | 'agentId' | 'threadId'>;
type AgentTurnRequest = { recording: StartExecutionParams } & (
export type AgentTurnRequest = { recording: StartExecutionParams } & (
| {
type: 'start';
input: Parameters<RuntimeAgent['stream']>[0];
@@ -54,6 +55,7 @@ interface ExecuteTurnConfig {
}
interface TurnExecutionState {
executionId?: string;
executionStarted: boolean;
executionError?: unknown;
receivedFinish: boolean;
@@ -103,18 +105,17 @@ export class AgentTurnExecutionService {
private readonly chatExecutionService: AgentChatExecutionService,
) {}
async getSessionMode(threadId: string): Promise<'new' | 'existing'> {
async getSessionMode(threadId: string): Promise<AgentSessionMode> {
return await this.agentExecutionService.getSessionMode(threadId);
}
async *execute(config: ExecuteTurnConfig): AsyncGenerator<StreamChunk> {
let executionId: string | undefined;
let turn: AgentTurnRequest | undefined;
let previewControl: PreviewExecutionControl | undefined;
const state: TurnExecutionState = { executionStarted: false, receivedFinish: false };
const recorder = this.createRecorder(
config.toolRegistry,
() => executionId,
() => state.executionId,
config.context,
config.backgroundJobSignal,
);
@@ -126,51 +127,7 @@ export class AgentTurnExecutionService {
previewControl = this.createPreviewExecutionControl(preparedTurn);
preparedTurn.options.abortSignal = previewControl.controller.signal;
}
const recordStart = async () => {
preparedTurn.options.abortSignal?.throwIfAborted();
const id = await this.startExecution(
{
...preparedTurn.recording,
...(config.backgroundJobSignal
? { initialTimeline: structuredClone(recorder.getMessageRecord().timeline) }
: {}),
},
recorder.startedAt,
);
executionId = id;
preparedTurn.options.abortSignal?.throwIfAborted();
return id;
};
const startAcceptedTurn = async (id: string) => {
if (previewControl) {
this.chatExecutionService.register(
{
...config.context,
userId: previewControl.userId,
executionId: id,
},
previewControl.controller,
);
previewControl.detachRequest();
}
config.onExecutionStarted?.(id, config.context.threadId);
preparedTurn.options.abortSignal?.throwIfAborted();
return await this.startTurn(preparedTurn, config, recorder, state);
};
let stream: ReadableStream<StreamChunk>;
if (previewControl && config.automaticPreviewContinuation && preparedTurn.type === 'resume') {
stream = await this.chatExecutionService.admitAutomaticContinuation(
config.context.threadId,
config.context.agentId,
preparedTurn.options.runId,
async () => await startAcceptedTurn(await recordStart()),
);
} else {
executionId = previewControl
? await this.chatExecutionService.admit(config.context.threadId, recordStart)
: await recordStart();
stream = await startAcceptedTurn(executionId);
}
const stream = await this.admitTurn(preparedTurn, config, recorder, state, previewControl);
yield* this.streamTurn(stream, preparedTurn, config, recorder, state);
} catch (error) {
state.executionError = error;
@@ -179,8 +136,8 @@ export class AgentTurnExecutionService {
throw error;
} finally {
previewControl?.detachRequest();
if (turn && executionId) {
await this.settleTurn(turn, config, recorder, executionId, state);
if (turn && state.executionId) {
await this.settleTurn(turn, config, recorder, state.executionId, state);
await config.onSettled?.(recorder.suspended);
}
}
@@ -246,19 +203,7 @@ export class AgentTurnExecutionService {
if (chunk.type === 'finish') state.receivedFinish = true;
if (turn.type === 'start') {
if (chunk.type === 'tool-call-suspended') {
this.logger.info('Chat: tool-call-suspended chunk received', {
agentId: config.context.agentId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
});
}
if (chunk.type === 'finish' && chunk.finishReason === 'max-iterations') {
for (const maxIterationsChunk of getMaxIterationsChunks()) {
recorder.record(maxIterationsChunk);
yield maxIterationsChunk;
}
}
yield* this.streamStartTurnNotices(chunk, config.context.agentId, recorder);
}
for (const attributionChunk of attributionTracker.observe(chunk)) {
@@ -406,4 +351,89 @@ export class AgentTurnExecutionService {
await recordFailure(executionId);
}
}
private async admitTurn(
turn: AgentTurnRequest,
config: ExecuteTurnConfig,
recorder: ExecutionRecorder,
state: TurnExecutionState,
previewControl?: PreviewExecutionControl,
): Promise<ReadableStream<StreamChunk>> {
const recordStart = async () => await this.recordTurnStart(turn, config, recorder, state);
const startAccepted = async (id: string) =>
await this.startAcceptedTurn(id, turn, config, recorder, state, previewControl);
if (previewControl && config.automaticPreviewContinuation && turn.type === 'resume') {
return await this.chatExecutionService.admitAutomaticContinuation(
config.context.threadId,
config.context.agentId,
turn.options.runId,
async () => await startAccepted(await recordStart()),
);
}
const executionId = previewControl
? await this.chatExecutionService.admit(config.context.threadId, recordStart)
: await recordStart();
state.executionId = executionId;
return await startAccepted(executionId);
}
private async recordTurnStart(
turn: AgentTurnRequest,
config: ExecuteTurnConfig,
recorder: ExecutionRecorder,
state: TurnExecutionState,
): Promise<string> {
turn.options.abortSignal?.throwIfAborted();
const id = await this.startExecution(
{
...turn.recording,
...(config.backgroundJobSignal
? { initialTimeline: structuredClone(recorder.getMessageRecord().timeline) }
: {}),
},
recorder.startedAt,
);
state.executionId = id;
turn.options.abortSignal?.throwIfAborted();
return id;
}
private async startAcceptedTurn(
executionId: string,
turn: AgentTurnRequest,
config: ExecuteTurnConfig,
recorder: ExecutionRecorder,
state: TurnExecutionState,
previewControl?: PreviewExecutionControl,
): Promise<ReadableStream<StreamChunk>> {
if (previewControl) {
this.chatExecutionService.register(
{ ...config.context, userId: previewControl.userId, executionId },
previewControl.controller,
);
previewControl.detachRequest();
}
config.onExecutionStarted?.(executionId, config.context.threadId);
turn.options.abortSignal?.throwIfAborted();
return await this.startTurn(turn, config, recorder, state);
}
private *streamStartTurnNotices(
chunk: StreamChunk,
agentId: string,
recorder: ExecutionRecorder,
): Generator<StreamChunk> {
if (chunk.type === 'tool-call-suspended') {
this.logger.info('Chat: tool-call-suspended chunk received', {
agentId,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
});
}
if (chunk.type !== 'finish' || chunk.finishReason !== 'max-iterations') return;
for (const notice of getMaxIterationsChunks()) {
recorder.record(notice);
yield notice;
}
}
}
@@ -1,6 +1,5 @@
import type { PushPayload } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { UserRepository } from '@n8n/db';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@@ -8,7 +7,8 @@ import { InstanceSettings } from 'n8n-core';
import { Push } from '@/push';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { RoleService } from '@/services/role.service';
import { AgentPushRecipientsService } from './agent-push-recipients.service';
type AgentUpdate = PushPayload<'agentUpdated'>;
@@ -16,8 +16,7 @@ type AgentUpdate = PushPayload<'agentUpdated'>;
export class AgentUpdateBroadcaster {
constructor(
private readonly logger: Logger,
private readonly userRepository: UserRepository,
private readonly roleService: RoleService,
private readonly recipients: AgentPushRecipientsService,
private readonly push: Push,
private readonly publisher: Publisher,
private readonly instanceSettings: InstanceSettings,
@@ -35,15 +34,7 @@ export class AgentUpdateBroadcaster {
}
private async broadcast(data: AgentUpdate, excludePushRef?: string): Promise<void> {
const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([
this.roleService.rolesWithScope('global', ['agent:read']),
this.roleService.rolesWithScope('project', ['agent:read']),
]);
const userIds = await this.userRepository.findIdsWithGlobalOrProjectRoles({
projectIds: [data.projectId],
projectRoleSlugs,
globalRoleSlugs,
});
const userIds = await this.recipients.getProjectReaders(data.projectId);
if (userIds.length === 0) return;
this.push.sendToUsers({ type: 'agentUpdated', data }, userIds, { excludePushRef });
@@ -1,4 +1,4 @@
import { type CredentialProvider, type ToolDescriptor } from '@n8n/agents';
import type { CredentialProvider } from '@n8n/agents';
import { getProviderPrefix } from '@n8n/ai-utilities/agent-config';
import { getRequiredNodeCredentialSlots } from '@n8n/ai-utilities/node-catalog';
import {
@@ -22,7 +22,12 @@ import {
} from '@n8n/api-types';
import { WorkflowRepository, type WorkflowEntity } from '@n8n/db';
import { Service } from '@n8n/di';
import { isMcpOAuth2Authentication, NodeHelpers, type INodeParameters } from 'n8n-workflow';
import {
isMcpOAuth2Authentication,
NodeHelpers,
type INodeParameters,
type INodeTypeDescription,
} from 'n8n-workflow';
import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-ids';
import { NodeTypes } from '@/node-types';
@@ -46,7 +51,7 @@ type FindCredential = (
credentialId: string,
) => Promise<Awaited<ReturnType<CredentialProvider['list']>>[number] | undefined>;
type CustomToolEntries = Record<string, { code: string; descriptor: ToolDescriptor }>;
type CustomToolEntries = Agent['tools'];
type TaskBody = AgentTaskConfig;
interface ConfigurationValidationContext {
@@ -389,27 +394,7 @@ export class AgentValidationService {
issues.push(agentIssue('incompatible_credential', 'credential'));
}
// Azure OpenAI classic deployments are user-named in Azure and surfaced in
// the deployment-based URL path. The catalog model id is not the deployment
// id, so a classic endpoint without a deployment name 404s at run time
// ("resource not found"). Surface that at save/publish time instead. Foundry
// and other endpoint types are unaffected.
const provider = model ? getProviderPrefix(model) : undefined;
if (provider === 'azure-openai' && !config.modelDeploymentName?.trim()) {
let endpointType: unknown;
try {
const data = await credentialProvider.resolve(credentialId);
endpointType = data?.endpointType;
} catch {
// A credential that can't be resolved here will already be reported
// elsewhere; don't let a transient resolve failure block publish
// by assuming Classic and requiring a deployment name.
return;
}
if (endpointType !== 'foundry') {
issues.push(agentIssue('missing_required', 'modelDeploymentName'));
}
}
await this.collectAzureDeploymentIssues(config, credentialId, credentialProvider, issues);
}
private collectSubAgentRefIssues(
@@ -538,16 +523,7 @@ export class AgentValidationService {
const tool = tools[index];
if (tool.type === 'custom') {
if (!ctx.customTools[tool.id]) {
issues.push(
issue('missing_reference', `tools.${index}.id`, {
kind: 'tool',
id: tool.id,
index,
toolType: 'custom',
}),
);
}
this.collectCustomToolIssues(tool.id, index, ctx.customTools, issues);
continue;
}
@@ -631,51 +607,16 @@ export class AgentValidationService {
{ typeVersion: tool.node.nodeTypeVersion },
nodeType.description,
) ?? {};
const requiredSlots = getRequiredNodeCredentialSlots(nodeType.description);
for (const slot of requiredSlots) {
const credentialDefinition = nodeType.description.credentials?.find(
(credential) => credential.name === slot.credentialType,
const visibleSlots = this.getVisibleCredentialSlots(tool, nodeType.description, nodeParameters);
for (const slot of visibleSlots) {
await this.collectNodeCredentialIssues(
tool,
index,
slot.credentialType,
nodeParameters,
findCredential,
issues,
);
if (
credentialDefinition &&
!NodeHelpers.displayParameter(
nodeParameters,
credentialDefinition,
{ typeVersion: tool.node.nodeTypeVersion },
nodeType.description,
)
) {
continue;
}
const path = `tools.${index}.node.credentials.${slot.credentialType}`;
const credentialRef = tool.node.credentials?.[slot.credentialType];
if (credentialRef && '__aiGatewayManaged' in credentialRef) {
// Only flag a definitive "gateway does not cover this slot". An
// indeterminate gateway state must not fail closed, mirroring the
// managed main-credential policy in collectMainCredentialIssues.
if (
(await this.gatewayCoversNodeToolSlot(tool.node, slot.credentialType, nodeParameters)) ===
false
) {
issues.push(issue('invalid_credential', path, capabilityBase));
}
continue;
}
const credentialId = credentialRef?.id?.trim();
if (!credentialId) {
issues.push(issue('missing_credential', path, capabilityBase));
continue;
}
const credential = await this.findCredentialSafe(findCredential, credentialId);
if (!credential || credential.type !== slot.credentialType) {
issues.push(issue('invalid_credential', path, capabilityBase));
}
}
}
@@ -782,4 +723,108 @@ export class AgentValidationService {
return undefined;
}
}
private async collectAzureDeploymentIssues(
config: AgentJsonConfig,
credentialId: string,
credentialProvider: CredentialProvider,
issues: AgentConfigValidationIssue[],
): Promise<void> {
const model = config.model?.trim();
const provider = model ? getProviderPrefix(model) : undefined;
if (provider !== 'azure-openai' || config.modelDeploymentName?.trim()) return;
let endpointType: unknown;
try {
const data = await credentialProvider.resolve(credentialId);
endpointType = data?.endpointType;
} catch {
// A credential that can't be resolved here will already be reported
// elsewhere; don't let a transient resolve failure block publish
// by assuming Classic and requiring a deployment name.
return;
}
if (endpointType !== 'foundry') {
issues.push(agentIssue('missing_required', 'modelDeploymentName'));
}
}
private collectCustomToolIssues(
id: string,
index: number,
customTools: CustomToolEntries,
issues: AgentConfigValidationIssue[],
): void {
if (customTools[id]) return;
issues.push(
issue('missing_reference', `tools.${index}.id`, {
kind: 'tool',
id,
index,
toolType: 'custom',
}),
);
}
private async collectNodeCredentialIssues(
tool: AgentJsonNodeToolConfig,
index: number,
credentialType: string,
nodeParameters: INodeParameters,
findCredential: FindCredential,
issues: AgentConfigValidationIssue[],
): Promise<void> {
const capabilityBase: AgentConfigValidationIssue['capability'] = {
kind: 'tool',
id: tool.name,
index,
toolType: 'node',
};
const path = `tools.${index}.node.credentials.${credentialType}`;
const credentialRef = tool.node.credentials?.[credentialType];
if (credentialRef && '__aiGatewayManaged' in credentialRef) {
// Only flag a definitive "gateway does not cover this slot". An
// indeterminate gateway state must not fail closed, mirroring the
// managed main-credential policy in collectMainCredentialIssues.
if (
(await this.gatewayCoversNodeToolSlot(tool.node, credentialType, nodeParameters)) === false
) {
issues.push(issue('invalid_credential', path, capabilityBase));
}
return;
}
const credentialId = credentialRef?.id?.trim();
if (!credentialId) {
issues.push(issue('missing_credential', path, capabilityBase));
return;
}
const credential = await this.findCredentialSafe(findCredential, credentialId);
if (!credential || credential.type !== credentialType) {
issues.push(issue('invalid_credential', path, capabilityBase));
}
}
private getVisibleCredentialSlots(
tool: AgentJsonNodeToolConfig,
description: INodeTypeDescription,
nodeParameters: INodeParameters,
) {
return getRequiredNodeCredentialSlots(description).filter((slot) => {
const definition = description.credentials?.find(
(credential) => credential.name === slot.credentialType,
);
return (
!definition ||
NodeHelpers.displayParameter(
nodeParameters,
definition,
{ typeVersion: tool.node.nodeTypeVersion },
description,
)
);
});
}
}
@@ -56,14 +56,13 @@ async function checkPineconeIndex(
warning: null,
};
}
if (config.namespace) {
const stats = await pc.index(config.indexName).describeIndexStats();
if (!(config.namespace in (stats.namespaces ?? {}))) {
return {
failure: null,
warning: `Namespace "${config.namespace}" has no data yet in index "${config.indexName}". It will appear once data is indexed — double-check the name if you expected existing data.`,
};
}
if (!config.namespace) return none;
const stats = await pc.index(config.indexName).describeIndexStats();
if (!(config.namespace in (stats.namespaces ?? {}))) {
return {
failure: null,
warning: `Namespace "${config.namespace}" has no data yet in index "${config.indexName}". It will appear once data is indexed — double-check the name if you expected existing data.`,
};
}
} catch {
// Introspection is best-effort; the real probe query below reports the actual failure.
@@ -157,26 +156,7 @@ export class AgentVectorStoresService {
throw new Error('Connection test timed out');
}
const embeddingOptions = await resolveEmbeddingProviderOptionsFromCredential(
vectorStore.embedding.credential,
vectorStore.embedding.model,
credentialProvider,
);
const embeddingModel = createEmbeddingModel(vectorStore.embedding.model, embeddingOptions);
const { embed } = await import('ai');
const { embedding } = await embed({ model: embeddingModel, value: TEST_QUERY });
const { failure, warning } = await this.checkProviderCompatibility(
vectorStore,
rawCredential,
embedding.length,
);
if (failure) {
return { success: false, message: failure };
}
await backend.query(embedding, { topK: 1 });
return { success: true, ...(warning ? { warning } : {}) };
return await this.probeBackend(vectorStore, credentialProvider, rawCredential, backend);
}, TEST_TIMEOUT_MS);
} catch (error) {
return { success: false, message: errorMessage(error) };
@@ -210,4 +190,39 @@ export class AgentVectorStoresService {
return { failure: null, warning: null };
}
}
private async embedTestQuery(
vectorStore: AgentJsonVectorStoreConfig,
credentialProvider: AgentsCredentialProvider,
): Promise<number[]> {
const embeddingOptions = await resolveEmbeddingProviderOptionsFromCredential(
vectorStore.embedding.credential,
vectorStore.embedding.model,
credentialProvider,
);
const embeddingModel = createEmbeddingModel(vectorStore.embedding.model, embeddingOptions);
const { embed } = await import('ai');
const { embedding } = await embed({ model: embeddingModel, value: TEST_QUERY });
return embedding;
}
private async probeBackend(
vectorStore: AgentJsonVectorStoreConfig,
credentialProvider: AgentsCredentialProvider,
rawCredential: Record<string, unknown>,
backend: BuiltVectorStoreBackend,
): Promise<VectorStoreTestResult> {
const embedding = await this.embedTestQuery(vectorStore, credentialProvider);
const { failure, warning } = await this.checkProviderCompatibility(
vectorStore,
rawCredential,
embedding.length,
);
if (failure) {
return { success: false, message: failure };
}
await backend.query(embedding, { topK: 1 });
return { success: true, ...(warning ? { warning } : {}) };
}
}
@@ -1,4 +1,10 @@
import type { Agent as RuntimeAgent, BuiltAgent, BuiltTool, CredentialProvider } from '@n8n/agents';
import type {
Agent as RuntimeAgent,
BuiltAgent,
BuiltTool,
CredentialProvider,
StreamChunk,
} from '@n8n/agents';
import type { AgentJsonConfig, AgentSkill } from '@n8n/api-types';
import {
AGENT_WORKFLOW_TRIGGER_TYPE,
@@ -35,6 +41,7 @@ import {
import {
buildAgentConfigurationTelemetry,
buildAgentConfigurationTelemetryFromConfig,
buildAgentTurnMetrics,
} from './agent-telemetry';
import { AgentTurnExecutionService } from './agent-turn-execution.service';
import type { Agent } from './entities/agent.entity';
@@ -55,6 +62,7 @@ import { describeStructuredOutputError } from './utils/structured-output-error';
import {
WorkflowAgentStreamAdapter,
type WorkflowAgentStreamObserver,
type PendingTool,
} from './workflow-agent-stream';
interface WorkflowSandboxScope {
@@ -89,6 +97,27 @@ interface WorkflowAgentStreamConsumption {
executionStarted: boolean;
}
interface WorkflowAgentStreamState extends WorkflowAgentStreamConsumption {
toolInputs: Map<string, PendingTool>;
}
interface WorkflowExecutionContext {
message: string;
executionId: string;
threadId: string;
projectId: string;
telemetryUserId?: string;
outputSchema?: JSONSchema7;
workflowContext?: ExecuteAgentWorkflowContext;
streamObserver?: WorkflowAgentStreamObserver;
}
interface StoredWorkflowExecutionContext extends WorkflowExecutionContext {
agentId: string;
useDraftVersion?: boolean;
sandboxScope?: WorkflowSandboxScope;
}
function getFinalWorkflowResponse(messageRecord: MessageRecord): string {
let lastToolCallIndex = -1;
for (let index = messageRecord.timeline.length - 1; index >= 0; index--) {
@@ -293,124 +322,147 @@ export class AgentWorkflowExecutionService {
recorder: ExecutionRecorder,
streamAdapter: WorkflowAgentStreamAdapter,
): Promise<WorkflowAgentStreamConsumption> {
const {
agentInstance,
message,
threadId,
telemetryAgentId,
telemetryUserId,
runType,
outputSchema,
tracing,
recordingParams,
sandboxScope,
} = params;
let structuredOutput: unknown = null;
const toolCalls: ExecuteAgentData['toolCalls'] = [];
const toolInputs = new Map<string, { toolName: string; input: unknown }>();
let streamError: Error | undefined;
let executionError: unknown;
let executionStarted = false;
const state: WorkflowAgentStreamState = {
structuredOutput: null,
toolCalls: [],
toolInputs: new Map(),
streamError: undefined,
executionError: undefined,
executionStarted: false,
};
try {
// Nest the agent's root span under the calling node's OTel span (rather
// than a disconnected root trace) by running inside its active context.
// Falls back to running unwrapped when no such span is tracked (e.g. otel
// disabled) — `context.with` needs an actual parent context to nest under.
const { tracing } = params;
const parentCtx = tracing.executionId
? this.executionLevelTracer.getActiveContext(tracing.executionId, tracing.nodeName)
: undefined;
const run = async (): Promise<void> => {
// No model_id here: BuiltAgent (unlike the cached-runtime RuntimeAgent
// used by the chat/task paths) doesn't expose a snapshot. The AI SDK's
// own gen_ai.request.model attribute on its model spans still carries
// this regardless.
const telemetry = await this.agentRunTracingService.build({
agentId: telemetryAgentId,
projectId: tracing.projectId,
threadId,
userId: telemetryUserId,
source: 'workflow',
executionId: tracing.executionId,
workflowId: tracing.workflowId,
nodeId: tracing.nodeId,
hasParentContext: parentCtx !== undefined,
});
// Only stored agents have integration tools.
const messageContext = recordingParams
? await this.integrationMessageContextService.getLatest(threadId)
: null;
const options = {
// The memory store scopes message reads by `resourceId` (the
// "per-user scope"; chat integrations pass the chat user id there).
// Workflow runs have no user, so key the scope by the thread
// itself: it is stable across executions, which is what lets a
// caller-supplied session id actually continue the conversation.
// The previous key — the execution id — changed every run and hid
// all prior messages of the thread from the model.
persistence: {
resourceId: threadId,
threadId,
hostMetadata: {
...(sandboxScope ? encodeAgentSandboxHostMetadata(sandboxScope) : {}),
...encodeIntegrationMessageContext(messageContext),
},
},
executionCounter: createAgentExecutionCounter(this.telemetry, {
agentId: telemetryAgentId,
userId: telemetryUserId,
runType,
}),
...modelStreamStallOptions(this.aiConfig),
...(telemetry ? { telemetry } : {}),
};
executionStarted = true;
const resultStream = await agentInstance.stream(message, options);
for await (const value of streamAgentChunks(resultStream.stream)) {
if (value.type === 'error') {
executionError = value.error;
if (outputSchema)
streamError = this.normalizeWorkflowStreamError(value.error, outputSchema);
}
recorder.record(
value.type === 'error' && streamError ? { ...value, error: streamError } : value,
);
await streamAdapter.observe(value);
if (value.type === 'tool-call') {
toolInputs.set(value.toolCallId, { toolName: value.toolName, input: value.input });
} else if (value.type === 'tool-result') {
const pending = toolInputs.get(value.toolCallId);
toolCalls.push({
toolName: value.toolName,
input: pending?.input ?? null,
result: value.output,
});
toolInputs.delete(value.toolCallId);
} else if (value.type === 'finish' && value.structuredOutput !== undefined) {
structuredOutput = value.structuredOutput;
}
}
};
const run = async () =>
await this.runWorkflowAgentStream(
params,
recorder,
streamAdapter,
state,
parentCtx !== undefined,
);
if (parentCtx) {
await context.with(parentCtx, run);
} else {
await run();
}
} catch (error) {
executionError = error;
const normalizedError = this.normalizeWorkflowStreamError(error, outputSchema);
state.executionError = error;
const normalizedError = this.normalizeWorkflowStreamError(error, params.outputSchema);
recorder.record({ type: 'error', error: normalizedError });
recorder.record({ type: 'finish', finishReason: 'error' });
streamError = normalizedError;
state.streamError = normalizedError;
streamAdapter.fail();
}
return state;
}
return { structuredOutput, toolCalls, streamError, executionError, executionStarted };
private async runWorkflowAgentStream(
params: WorkflowAgentStreamParams,
recorder: ExecutionRecorder,
streamAdapter: WorkflowAgentStreamAdapter,
state: WorkflowAgentStreamState,
hasParentContext: boolean,
): Promise<void> {
const options = await this.getWorkflowStreamOptions(params, hasParentContext);
state.executionStarted = true;
const resultStream = await params.agentInstance.stream(params.message, options);
for await (const value of streamAgentChunks(resultStream.stream)) {
this.recordWorkflowChunk(value, params.outputSchema, recorder, state);
await streamAdapter.observe(value);
this.collectWorkflowChunk(value, state);
}
}
private async getWorkflowStreamOptions(
params: WorkflowAgentStreamParams,
hasParentContext: boolean,
) {
const {
telemetryAgentId,
telemetryUserId,
runType,
tracing,
threadId,
recordingParams,
sandboxScope,
} = params;
const telemetry = await this.agentRunTracingService.build({
agentId: telemetryAgentId,
projectId: tracing.projectId,
threadId,
userId: telemetryUserId,
source: 'workflow',
executionId: tracing.executionId,
workflowId: tracing.workflowId,
nodeId: tracing.nodeId,
hasParentContext,
});
// Only stored agents have integration tools.
const messageContext = recordingParams
? await this.integrationMessageContextService.getLatest(threadId)
: null;
return {
// The memory store scopes message reads by `resourceId` (the
// "per-user scope"; chat integrations pass the chat user id there).
// Workflow runs have no user, so key the scope by the thread
// itself: it is stable across executions, which is what lets a
// caller-supplied session id actually continue the conversation.
// The previous key — the execution id — changed every run and hid
// all prior messages of the thread from the model.
persistence: {
resourceId: threadId,
threadId,
hostMetadata: {
...(sandboxScope ? encodeAgentSandboxHostMetadata(sandboxScope) : {}),
...encodeIntegrationMessageContext(messageContext),
},
},
executionCounter: createAgentExecutionCounter(this.telemetry, {
agentId: telemetryAgentId,
userId: telemetryUserId,
runType,
}),
...modelStreamStallOptions(this.aiConfig),
...(telemetry ? { telemetry } : {}),
};
}
private recordWorkflowChunk(
value: StreamChunk,
outputSchema: JSONSchema7 | undefined,
recorder: ExecutionRecorder,
state: WorkflowAgentStreamState,
): void {
if (value.type === 'error') {
state.executionError = value.error;
if (outputSchema)
state.streamError = this.normalizeWorkflowStreamError(value.error, outputSchema);
}
recorder.record(
value.type === 'error' && state.streamError ? { ...value, error: state.streamError } : value,
);
}
private collectWorkflowChunk(value: StreamChunk, state: WorkflowAgentStreamState): void {
switch (value.type) {
case 'tool-call':
state.toolInputs.set(value.toolCallId, { toolName: value.toolName, input: value.input });
break;
case 'tool-result':
state.toolCalls.push({
toolName: value.toolName,
input: state.toolInputs.get(value.toolCallId)?.input ?? null,
result: value.output,
});
state.toolInputs.delete(value.toolCallId);
break;
case 'finish':
if (value.structuredOutput !== undefined) state.structuredOutput = value.structuredOutput;
}
}
/** Stream one workflow-invoked agent run and collect its outcome. */
@@ -467,38 +519,8 @@ export class AgentWorkflowExecutionService {
outputSchema?: JSONSchema7;
}): ExecuteAgentData {
const { run, session, outputSchema } = params;
const { recorder, messageRecord, structuredOutput, toolCalls, streamError } = run;
if (streamError !== undefined) {
throw streamError;
}
if (recorder.suspended) {
throw createWorkflowAgentExecutionError(
'Agent execution suspended waiting for tool approval. ' +
'Suspend/resume is not supported in workflow execution context.',
);
}
if (messageRecord.error) {
if (outputSchema) {
const structuredOutputError = describeStructuredOutputError(messageRecord.error);
if (structuredOutputError) {
throw createWorkflowAgentExecutionError(structuredOutputError);
}
}
throw createWorkflowAgentExecutionError(`Agent execution failed: ${messageRecord.error}`);
}
if (messageRecord.finishReason === 'error') {
throw createWorkflowAgentExecutionError(
outputSchema
? 'Agent execution finished with an error while producing structured output. ' +
"The agent's model or provider may not support JSON Schema structured output."
: 'Agent execution finished with an error.',
);
}
this.assertWorkflowRunSucceeded(run, outputSchema);
const { messageRecord, structuredOutput, toolCalls } = run;
return {
response: messageRecord.assistantResponse,
structuredOutput: structuredOutput ?? null,
@@ -515,6 +537,46 @@ export class AgentWorkflowExecutionService {
};
}
private assertWorkflowRunSucceeded(
run: WorkflowAgentRunOutcome,
outputSchema?: JSONSchema7,
): void {
const { recorder, messageRecord, streamError } = run;
if (streamError !== undefined) {
throw streamError;
}
if (recorder.suspended) {
throw createWorkflowAgentExecutionError(
'Agent execution suspended waiting for tool approval. ' +
'Suspend/resume is not supported in workflow execution context.',
);
}
if (messageRecord.error) {
throw createWorkflowAgentExecutionError(
this.workflowFailureMessage(messageRecord.error, outputSchema),
);
}
if (messageRecord.finishReason === 'error') {
throw createWorkflowAgentExecutionError(
outputSchema
? 'Agent execution finished with an error while producing structured output. ' +
"The agent's model or provider may not support JSON Schema structured output."
: 'Agent execution finished with an error.',
);
}
}
private workflowFailureMessage(message: string, outputSchema?: JSONSchema7): string {
if (outputSchema) {
const structuredOutputError = describeStructuredOutputError(message);
if (structuredOutputError) return structuredOutputError;
}
return `Agent execution failed: ${message}`;
}
async executeForWorkflow(
agentId: string,
message: string,
@@ -528,7 +590,7 @@ export class AgentWorkflowExecutionService {
sandboxScope?: WorkflowSandboxScope,
streamObserver?: WorkflowAgentStreamObserver,
): Promise<ExecuteAgentData> {
return await this.executeForWorkflowInternal(
return await this.executeForWorkflowInternal({
agentId,
message,
executionId,
@@ -540,41 +602,45 @@ export class AgentWorkflowExecutionService {
workflowContext,
sandboxScope,
streamObserver,
);
});
}
private async executeForWorkflowInternal(
agentId: string,
message: string,
executionId: string,
threadId: string,
projectId: string,
telemetryUserId?: string,
useDraftVersion?: boolean,
outputSchema?: JSONSchema7,
workflowContext?: ExecuteAgentWorkflowContext,
sandboxScope?: WorkflowSandboxScope,
streamObserver?: WorkflowAgentStreamObserver,
params: StoredWorkflowExecutionContext,
): Promise<ExecuteAgentData> {
const { agentId, projectId, threadId, outputSchema, sandboxScope } = params;
const { agentInstance, recordingParams, runType } = await this.prepareStoredWorkflowRun(params);
const run = await this.streamCompiledWorkflowAgent(agentInstance, params, {
telemetryAgentId: agentId,
runType,
recordingParams: { ...recordingParams, agentName: agentInstance.name },
...(sandboxScope
? { sandboxScope: { projectId, principalHash: sandboxScope.principalHash } }
: {}),
});
return this.buildWorkflowResult({
run,
// The caller remaps the scoped thread key to its session id.
session: { agentId, projectId, sessionId: threadId, threadId },
outputSchema,
});
}
private async prepareStoredWorkflowRun(params: StoredWorkflowExecutionContext) {
const {
agentId,
projectId,
threadId,
message,
telemetryUserId,
useDraftVersion,
outputSchema,
workflowContext,
sandboxScope,
} = params;
// Keep the original intent if deletion happens during preparation.
const sessionMode = await this.turnExecutionService.getSessionMode(threadId);
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agentEntity) {
throw new OperationalError('Agent not found or not accessible.');
}
const credentialProvider = createAgentCredentialProvider(
this.credentialsService,
projectId,
undefined,
agentId,
);
let agentData: Agent = agentEntity;
if (!useDraftVersion) {
agentData = getPublishedAgentSnapshot(agentEntity);
}
const { agentData, credentialProvider } = await this.loadWorkflowAgent(params);
const telemetryConfiguration = buildAgentConfigurationTelemetry(agentData);
const runType: AgentRunTelemetryType = useDraftVersion ? 'test' : 'production';
@@ -606,16 +672,56 @@ export class AgentWorkflowExecutionService {
await this.turnExecutionService.recordFailedStart(recordingParams, error);
throw error;
}
return { agentInstance: compiled.agent, recordingParams, runType };
}
const agentInstance = compiled.agent;
const run = await this.streamWorkflowAgent({
private async loadWorkflowAgent(params: StoredWorkflowExecutionContext) {
const { agentId, projectId, useDraftVersion } = params;
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agentEntity) {
throw new OperationalError('Agent not found or not accessible.');
}
const credentialProvider = createAgentCredentialProvider(
this.credentialsService,
projectId,
undefined,
agentId,
);
let agentData: Agent = agentEntity;
if (!useDraftVersion) {
agentData = getPublishedAgentSnapshot(agentEntity);
}
return { agentData, credentialProvider };
}
private async streamCompiledWorkflowAgent(
agentInstance: BuiltAgent,
params: WorkflowExecutionContext,
run: Pick<
WorkflowAgentStreamParams,
'telemetryAgentId' | 'runType' | 'recordingParams' | 'sandboxScope'
>,
): Promise<WorkflowAgentRunOutcome> {
const {
message,
threadId,
telemetryUserId,
outputSchema,
projectId,
executionId,
workflowContext,
streamObserver,
} = params;
return await this.streamWorkflowAgent({
agentInstance,
message,
threadId,
telemetryAgentId: agentId,
telemetryUserId,
runType,
outputSchema,
...run,
tracing: {
projectId,
executionId,
@@ -623,24 +729,7 @@ export class AgentWorkflowExecutionService {
nodeId: workflowContext?.callingNodeId,
nodeName: workflowContext?.callingNodeName,
},
recordingParams: { ...recordingParams, agentName: agentInstance.name },
streamObserver,
...(sandboxScope
? {
sandboxScope: {
projectId,
principalHash: sandboxScope.principalHash,
},
}
: {}),
});
return this.buildWorkflowResult({
run,
// sessionId here is still the scoped thread key — executeAgent remaps it
// to the caller-facing id; this method never sees the unscoped one.
session: { agentId, projectId, sessionId: threadId, threadId },
outputSchema,
});
}
@@ -665,6 +754,54 @@ export class AgentWorkflowExecutionService {
workflowContext?: ExecuteAgentWorkflowContext,
streamObserver?: WorkflowAgentStreamObserver,
): Promise<ExecuteAgentData> {
const { runtimeConfig, skills, credentialProvider } = await this.prepareInlineRuntime(
inlineAgent,
projectId,
workflowContext,
);
// For telemetry/logging and memory-owner keying — never persisted, and
// stable enough to aggregate runs of the same node across executions.
const syntheticAgentId = `inline:${workflowContext?.workflowId ?? 'unknown'}:${
workflowContext?.callingNodeName ?? 'unknown'
}`;
const extraTools = this.buildWorkflowExtraTools(workflowContext);
const compiled = await this.compileIsolatedFromSource(
runtimeConfig,
skills,
syntheticAgentId,
projectId,
credentialProvider,
runType,
outputSchema,
extraTools?.length ? extraTools : undefined,
);
if (!compiled.ok || !compiled.agent) {
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
}
const params: WorkflowExecutionContext = {
message,
executionId,
threadId,
projectId,
telemetryUserId,
outputSchema,
workflowContext,
streamObserver,
};
const run = await this.streamCompiledWorkflowAgent(compiled.agent, params, {
telemetryAgentId: syntheticAgentId,
runType,
});
this.trackInlineRun(params, syntheticAgentId, runType, runtimeConfig, run);
return this.buildWorkflowResult({ run, session: null, outputSchema });
}
private async prepareInlineRuntime(
inlineAgent: InlineAgentPayload,
projectId: string,
workflowContext: ExecuteAgentWorkflowContext | undefined,
) {
const { config, skills } = await this.validateInlineAgentConfig(inlineAgent);
// Session memory: when the caller supplied a session id, inline agents run
@@ -701,48 +838,18 @@ export class AgentWorkflowExecutionService {
runtimeConfig.tools,
new Set(accessibleCredentials.map((credential) => credential.type)),
);
return { runtimeConfig, skills, credentialProvider };
}
// For telemetry/logging and memory-owner keying — never persisted, and
// stable enough to aggregate runs of the same node across executions.
const syntheticAgentId = `inline:${workflowContext?.workflowId ?? 'unknown'}:${
workflowContext?.callingNodeName ?? 'unknown'
}`;
const extraTools = this.buildWorkflowExtraTools(workflowContext);
const compiled = await this.compileIsolatedFromSource(
runtimeConfig,
skills,
syntheticAgentId,
projectId,
credentialProvider,
runType,
outputSchema,
extraTools?.length ? extraTools : undefined,
);
if (!compiled.ok || !compiled.agent) {
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
}
const run = await this.streamWorkflowAgent({
agentInstance: compiled.agent,
message,
threadId,
telemetryAgentId: syntheticAgentId,
telemetryUserId,
runType,
outputSchema,
tracing: {
projectId,
executionId,
workflowId: workflowContext?.workflowId,
nodeId: workflowContext?.callingNodeId,
nodeName: workflowContext?.callingNodeName,
},
streamObserver,
});
// No `recordMessage` here: inline runs have no agent entity to attach a
// session to. Emit the turn-finished telemetry directly instead.
private trackInlineRun(
params: WorkflowExecutionContext,
syntheticAgentId: string,
runType: AgentRunTelemetryType,
runtimeConfig: AgentJsonConfig,
run: WorkflowAgentRunOutcome,
): void {
const { telemetryUserId, threadId } = params;
// Inline runs have no stored session, so report completion here.
try {
this.telemetry.trackAgentTurnFinished({
agent_id: syntheticAgentId,
@@ -755,10 +862,7 @@ export class AgentWorkflowExecutionService {
? 'failed'
: 'succeeded',
configuration: buildAgentConfigurationTelemetryFromConfig(runtimeConfig),
latency_ms: run.messageRecord.duration,
cost: run.messageRecord.totalCost ?? 0,
token_count: run.messageRecord.usage?.totalTokens ?? 0,
tool_call_count: run.messageRecord.timeline.filter((t) => t.type === 'tool-call').length,
...buildAgentTurnMetrics(run.messageRecord),
});
} catch (error) {
this.logger.warn('Failed to track inline agent execution telemetry', {
@@ -766,8 +870,6 @@ export class AgentWorkflowExecutionService {
error: error instanceof Error ? error.message : String(error),
});
}
return this.buildWorkflowResult({ run, session: null, outputSchema });
}
/**
@@ -1,3 +1,4 @@
import type { ToolContext } from '@n8n/agents';
import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { UserRepository } from '@n8n/db';
@@ -157,39 +158,13 @@ export class AgentWorkflowToolResumeService {
if (checkpoint.status !== 'active' || checkpoint.checkpoint.status !== 'suspended') return;
const persistence = checkpoint.checkpoint.persistence;
if (!persistence) return;
let messageContext = readIntegrationMessageContext(persistence);
const allowLegacyThreadId = messageContext === undefined;
if (messageContext === undefined) {
try {
messageContext = await this.messageContextService.getLatest(persistence.threadId);
} catch (error) {
this.logger.warn('Could not read the thread message context for an agent resume', {
runId: agentRun.runId,
error: error instanceof Error ? error.message : String(error),
});
messageContext = null;
}
}
const [platform, storedCredentialId] = messageContext?.integrationConnectionId.split(':') ?? [];
let integrationType = agentRun.integrationType;
let credentialId: string | undefined;
if (allowLegacyThreadId) {
if (messageContext?.platform === integrationType && platform === integrationType) {
credentialId = storedCredentialId;
} else {
messageContext = null;
}
} else {
if (!messageContext || messageContext.platform !== platform || !storedCredentialId) {
this.logger.warn('Agent resume has no integration reply context', {
agentId: agentRun.agentId,
runId: agentRun.runId,
});
return;
}
integrationType = messageContext.platform;
credentialId = storedCredentialId;
}
const route = await this.getIntegrationResumeRoute(
agentRun,
agentRun.integrationType,
persistence,
);
if (!route) return;
const { integrationType, credentialId, messageContext, allowLegacyThreadId } = route;
const bridge = this.chatIntegrationService.getBridge(
agentRun.agentId,
integrationType,
@@ -266,4 +241,53 @@ export class AgentWorkflowToolResumeService {
executionId: result.executionId ?? '',
});
}
private async getIntegrationResumeRoute(
agentRun: RelatedAgentRun,
integrationType: string,
persistence: NonNullable<ToolContext['persistence']>,
) {
let messageContext = readIntegrationMessageContext(persistence);
const allowLegacyThreadId = messageContext === undefined;
if (messageContext === undefined) {
messageContext = await this.loadLegacyMessageContext(persistence.threadId, agentRun.runId);
}
const [platform, credentialId] = messageContext?.integrationConnectionId.split(':') ?? [];
if (allowLegacyThreadId) {
if (messageContext?.platform === integrationType && platform === integrationType) {
return { integrationType, credentialId, messageContext, allowLegacyThreadId };
}
return {
integrationType,
credentialId: undefined,
messageContext: null,
allowLegacyThreadId,
};
}
if (!messageContext || messageContext.platform !== platform || !credentialId) {
this.logger.warn('Agent resume has no integration reply context', {
agentId: agentRun.agentId,
runId: agentRun.runId,
});
return undefined;
}
return {
integrationType: messageContext.platform,
credentialId,
messageContext,
allowLegacyThreadId,
};
}
private async loadLegacyMessageContext(threadId: string, runId: string) {
try {
return await this.messageContextService.getLatest(threadId);
} catch (error) {
this.logger.warn('Could not read the thread message context for an agent resume', {
runId,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
}
@@ -1,6 +1,9 @@
import type { BuiltTool, CredentialProvider } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import type { CodeBuilderSearchResult } from '@n8n/ai-utilities/node-catalog';
import type {
CodeBuilderSearchResult,
NodeRequest as CatalogNodeRequest,
} from '@n8n/ai-utilities/node-catalog';
import { AGENT_BUILDER_HIDDEN_AVAILABLE_TOOL_NODE_TYPES } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { isToolType, isTriggerNodeType } from 'n8n-workflow';
@@ -16,13 +19,7 @@ import { MCP_REGISTRY_PACKAGE_NAME } from '../mcp-registry/node-description-tran
type NodeRequest =
| string
| {
nodeId: string;
version?: number;
resource?: string;
operation?: string;
mode?: string;
};
| (Omit<Exclude<CatalogNodeRequest, string>, 'version'> & { version?: number });
/**
* Nodes the agent runtime can execute directly. Triggers are workflow entry
@@ -203,15 +200,7 @@ export class AgentsToolsService {
* The catalog's `getNodeTypes` signature expects `version` as a string (matching the
* builder tool's wire format); adapt at the boundary.
*/
function normalizeNodeRequestForCatalog(req: NodeRequest):
| string
| {
nodeId: string;
version?: string;
resource?: string;
operation?: string;
mode?: string;
} {
function normalizeNodeRequestForCatalog(req: NodeRequest): CatalogNodeRequest {
if (typeof req === 'string') return req;
const { version, ...rest } = req;
return version === undefined ? rest : { ...rest, version: String(version) };
+141 -97
View File
@@ -7,6 +7,7 @@ import {
type AgentCapabilityTool,
type AgentIntegrationConfig,
type AgentJsonConfig,
type AgentModelCredentialConfig,
type AgentSkill,
type ListAgentsQueryDto,
} from '@n8n/api-types';
@@ -22,9 +23,9 @@ import { v4 as uuid } from 'uuid';
// eslint-disable-next-line import-x/no-cycle
import { CredentialsService } from '@/credentials/credentials.service';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
import { AgentExecutionService } from './agent-execution.service';
import { AgentKnowledgeService } from './agent-knowledge.service';
@@ -38,6 +39,7 @@ import { AgentTaskRepository } from './repositories/agent-task.repository';
import {
AgentRepository,
type AgentSummary,
type AgentListResult,
type AgentSummaryFilters,
} from './repositories/agent.repository';
import { SubAgentCleanupService } from './sub-agents/sub-agent-cleanup.service';
@@ -47,7 +49,7 @@ type CreateAgentOptions = {
availableInMCP?: boolean;
id?: string;
adoptOnCollision?: boolean;
defaultModel?: { model: string; credential: string };
defaultModel?: AgentModelCredentialConfig;
/** Create with this config instead of the empty draft, so eval thread seeding
* can recreate an already-built agent in one insert. */
schema?: AgentJsonConfig;
@@ -124,31 +126,11 @@ export class AgentsService {
user,
}: CreateAgentOptions = {},
): Promise<{ agent: Agent; adopted: boolean }> {
const defaultConfig: AgentJsonConfig = {
name,
model: '',
instructions: '',
...(defaultModel ?? {}),
tools: [],
skills: [],
// Seeded at birth so every agent has a distinct tile, and so the builder
// sees an existing icon name when it reads the config — without one it
// invents its own, which the icon tile cannot render.
personalisation: {
icon: DEFAULT_AGENT_PERSONALISATION.icon,
gradient: getRandomAgentPersonalisationGradient(),
},
};
// A user-driven duplicate seeds a full config. Mirror the `updateConfig`
// write path: sanitize the config, then blank any credential the
// duplicating user cannot use in this project. Eval seeding (no `user`)
// inserts the config as-is, as before.
const { schemaConfig, integrations } = schema
? user
? await this.prepareDuplicateConfig(schema, projectId, user)
: decomposeJsonConfig(schema)
: decomposeJsonConfig(defaultConfig);
const { schemaConfig, integrations } = await this.prepareInitialConfig(projectId, name, {
schema,
user,
defaultModel,
});
const agent = this.agentRepository.create({
...(id ? { id } : {}),
@@ -166,16 +148,10 @@ export class AgentsService {
try {
saved = await this.agentRepository.save(agent);
} catch (error) {
if (!id || !isUniqueConstraintError(error)) throw error;
// Never disclose whether the id exists in another project.
const conflict = new ConflictError('An agent with this id already exists');
if (!adoptOnCollision) throw conflict;
// Returned as it stands: the winner may already have configured it, and
// this call's `name`/`schema` describe a draft that never existed.
const existing = await this.agentRepository.findByIdAndProjectId(id, projectId);
if (!existing) throw conflict;
this.logger.debug('Adopted concurrently created SDK agent', { agentId: id, projectId });
return { agent: existing, adopted: true };
return {
agent: await this.adoptExistingAgent(id, projectId, adoptOnCollision, error),
adopted: true,
};
}
this.logger.debug('Created SDK agent', { agentId: saved.id, projectId });
@@ -233,7 +209,7 @@ export class AgentsService {
async findByProjectIdPaginated(
projectId: string,
options: ListAgentsQueryDto,
): Promise<{ count: number; data: Agent[] }> {
): Promise<AgentListResult> {
return await this.agentRepository.findByProjectIdsPaginated([projectId], options);
}
@@ -248,8 +224,12 @@ export class AgentsService {
* the full `AgentJsonConfig`.
*/
async getCapabilitySummary(agentId: string, projectId: string): Promise<AgentCapabilitySummary> {
const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!entity) throw new NotFoundError('Agent not found');
const entity = await getAgentOrThrow(
this.agentRepository,
agentId,
projectId,
'Agent not found',
);
const schema = entity.schema;
@@ -260,28 +240,7 @@ export class AgentsService {
type: integration.type,
}));
const tools = (schema?.tools ?? []).flatMap<AgentCapabilityTool>((tool) => {
switch (tool.type) {
case 'custom':
return [{ type: 'custom', name: entity.tools[tool.id]?.descriptor?.name ?? tool.id }];
case 'workflow':
return [{ type: 'workflow', name: tool.name ?? tool.workflow }];
case 'node':
return [
{
type: 'node',
name: tool.name,
nodeType: tool.node?.nodeType,
nodeTypeVersion: tool.node?.nodeTypeVersion,
},
];
default:
// Unknown tool type from an unvalidated persisted config (import,
// history restore, version skew): drop it rather than emit an
// `undefined` chip the card would choke on.
return [];
}
});
const tools = this.getCapabilityTools(entity);
const mcpServers = (schema?.mcpServers ?? []).map((server) => ({ name: server.name }));
@@ -290,17 +249,7 @@ export class AgentsService {
name: entity.skills[skill.id]?.name ?? skill.id,
}));
const taskRefs = schema?.tasks ?? [];
let taskNamesById: Record<string, string> = {};
if (taskRefs.length > 0) {
const taskBodies = await this.agentTaskRepository.findByAgentId(agentId);
taskNamesById = Object.fromEntries(taskBodies.map((task) => [task.id, task.name]));
}
const tasks = taskRefs.map((task) => ({
id: task.id,
name: taskNamesById[task.id] ?? task.id,
enabled: task.enabled,
}));
const tasks = await this.getCapabilityTasks(entity);
return {
id: entity.id,
@@ -355,10 +304,7 @@ export class AgentsService {
return await this.agentRepository.findByIdInProjects(agentId, projectIds);
}
async findByUserPaginated(
userId: string,
options: ListAgentsQueryDto,
): Promise<{ count: number; data: Agent[] }> {
async findByUserPaginated(userId: string, options: ListAgentsQueryDto): Promise<AgentListResult> {
const projectRelations = await this.projectRelationRepository.findAllByUser(userId);
const projectIds = projectRelations.map((pr) => pr.projectId);
return await this.agentRepository.findByProjectIdsPaginated(projectIds, options);
@@ -390,25 +336,11 @@ export class AgentsService {
return false;
}
try {
await this.agentKnowledgeService.deleteAllFilesForAgent(projectId, agentId);
} catch (error) {
this.logger.warn('Failed to delete knowledge files on agent delete', {
agentId,
error: error instanceof Error ? error.message : error,
});
}
await this.deleteKnowledgeFiles(projectId, agentId);
await this.agentKnowledgeService.destroyKnowledgeSandbox(projectId, agentId);
try {
await this.agentChatAttachmentService.deleteByAgent(agentId);
} catch (error) {
this.logger.warn('Failed to delete chat attachments on agent delete', {
agentId,
error: error instanceof Error ? error.message : error,
});
}
await this.deleteChatAttachments(agentId);
const chatIntegrationService = Container.get(ChatIntegrationService);
for (const integration of agent.integrations ?? []) {
@@ -425,6 +357,120 @@ export class AgentsService {
this.eventService.emit('agent-deleted', { agentId, projectId });
await this.stopDeletedAgentTasks(agentId);
await this.clearDeletedAgentTestChats(agentId);
this.logger.debug('Deleted SDK agent', { agentId, projectId });
return true;
}
private async prepareInitialConfig(
projectId: string,
name: string,
{ schema, user, defaultModel }: CreateAgentOptions,
): Promise<ReturnType<typeof decomposeJsonConfig>> {
const defaultConfig: AgentJsonConfig = {
name,
model: '',
instructions: '',
...(defaultModel ?? {}),
tools: [],
skills: [],
// Seeded at birth so every agent has a distinct tile, and so the builder
// sees an existing icon name when it reads the config — without one it
// invents its own, which the icon tile cannot render.
personalisation: {
icon: DEFAULT_AGENT_PERSONALISATION.icon,
gradient: getRandomAgentPersonalisationGradient(),
},
};
if (!schema) return decomposeJsonConfig(defaultConfig);
if (!user) return decomposeJsonConfig(schema);
return await this.prepareDuplicateConfig(schema, projectId, user);
}
private async adoptExistingAgent(
id: string | undefined,
projectId: string,
adoptOnCollision: boolean,
error: unknown,
): Promise<Agent> {
if (!id || !isUniqueConstraintError(error)) throw error;
// Never disclose whether the id exists in another project.
const conflict = new ConflictError('An agent with this id already exists');
if (!adoptOnCollision) throw conflict;
// Returned as it stands: the winner may already have configured it, and
// this call's `name`/`schema` describe a draft that never existed.
const existing = await this.agentRepository.findByIdAndProjectId(id, projectId);
if (!existing) throw conflict;
this.logger.debug('Adopted concurrently created SDK agent', { agentId: id, projectId });
return existing;
}
private getCapabilityTools(entity: Agent): AgentCapabilityTool[] {
return (entity.schema?.tools ?? []).flatMap<AgentCapabilityTool>((tool) => {
switch (tool.type) {
case 'custom':
return [{ type: 'custom', name: entity.tools[tool.id]?.descriptor?.name ?? tool.id }];
case 'workflow':
return [{ type: 'workflow', name: tool.name ?? tool.workflow }];
case 'node':
return [
{
type: 'node',
name: tool.name,
nodeType: tool.node?.nodeType,
nodeTypeVersion: tool.node?.nodeTypeVersion,
},
];
default:
// Unknown tool type from an unvalidated persisted config (import,
// history restore, version skew): drop it rather than emit an
// `undefined` chip the card would choke on.
return [];
}
});
}
private async getCapabilityTasks(entity: Agent): Promise<AgentCapabilitySummary['tasks']> {
const taskRefs = entity.schema?.tasks ?? [];
let taskNamesById: Record<string, string> = {};
if (taskRefs.length > 0) {
const taskBodies = await this.agentTaskRepository.findByAgentId(entity.id);
taskNamesById = Object.fromEntries(taskBodies.map((task) => [task.id, task.name]));
}
return taskRefs.map((task) => ({
id: task.id,
name: taskNamesById[task.id] ?? task.id,
enabled: task.enabled,
}));
}
private async deleteKnowledgeFiles(projectId: string, agentId: string): Promise<void> {
try {
await this.agentKnowledgeService.deleteAllFilesForAgent(projectId, agentId);
} catch (error) {
this.logger.warn('Failed to delete knowledge files on agent delete', {
agentId,
error: error instanceof Error ? error.message : error,
});
}
}
private async deleteChatAttachments(agentId: string): Promise<void> {
try {
await this.agentChatAttachmentService.deleteByAgent(agentId);
} catch (error) {
this.logger.warn('Failed to delete chat attachments on agent delete', {
agentId,
error: error instanceof Error ? error.message : error,
});
}
}
private async stopDeletedAgentTasks(agentId: string): Promise<void> {
try {
const { AgentTaskService } = await import('./agent-task.service.js');
await Container.get(AgentTaskService).requestReconcile(agentId);
@@ -434,7 +480,9 @@ export class AgentsService {
error: error instanceof Error ? error.message : error,
});
}
}
private async clearDeletedAgentTestChats(agentId: string): Promise<void> {
try {
await this.testChatService.clearAllTestChatMessages(agentId);
} catch (error) {
@@ -443,9 +491,5 @@ export class AgentsService {
error: error instanceof Error ? error.message : error,
});
}
this.logger.debug('Deleted SDK agent', { agentId, projectId });
return true;
}
}
@@ -194,14 +194,12 @@ export class AgentBackgroundJobService {
async settle(jobId: string, settlement: AgentBackgroundJobSettlement): Promise<boolean> {
try {
const settled = await this.jobRepository.settleIfRunning(jobId, settlement);
if (settled) {
const job = await this.findJob(jobId);
if (job) {
this.notifyJobUpdate(job);
await this.requestWakeSafely(job.parentThreadId);
}
}
return settled;
if (!settled) return false;
const job = await this.findJob(jobId);
if (!job) return true;
this.notifyJobUpdate(job);
await this.requestWakeSafely(job.parentThreadId);
return true;
} finally {
// Drop the handle even when the write throws — a leaked entry would
// shield the still-running row from orphan reconciliation forever.
@@ -416,33 +414,7 @@ export class AgentBackgroundJobService {
private async cancelWorkflowJob(
job: AgentBackgroundJob,
): Promise<'cancelled' | 'already-settled'> {
if (job.childExecutionId !== null && job.workflowId !== null) {
// Lazy: ExecutionService is a heavy dependency this service otherwise
// never needs — workers load this class for the settle path alone.
const { ExecutionService } = await import('@/executions/execution.service.js');
const { MissingExecutionStopError } = await import(
'@/errors/missing-execution-stop.error.js'
);
try {
await Container.get(ExecutionService).stop(job.childExecutionId, [job.workflowId]);
} catch (error) {
if (error instanceof MissingExecutionStopError || error instanceof WorkflowOperationError) {
this.logger.debug('Workflow job execution was already beyond stopping', {
jobId: job.id,
executionId: job.childExecutionId,
});
return 'already-settled';
}
this.logger.error('Failed to stop a workflow job execution — it may still be running', {
jobId: job.id,
executionId: job.childExecutionId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
if (!(await this.stopWorkflowJobExecution(job))) return 'already-settled';
// The stopped execution's settle hook may have written `cancelled` first;
// either way the job is cancelled.
@@ -497,34 +469,9 @@ export class AgentBackgroundJobService {
let settledAny = false;
for (const job of candidates) {
const executionId = job.childExecutionId;
const executionStatus = statuses.get(executionId);
try {
if (executionStatus === undefined) {
settledAny =
(await this.settle(job.id, {
status: 'failed',
error: EXECUTION_OUTCOME_UNKNOWN_ERROR,
})) || settledAny;
continue;
}
if (!isTerminalExecutionStatus(executionStatus)) continue;
const status = settlementStatusForExecution(executionStatus);
settledAny =
(await this.settle(job.id, {
status,
result: status === 'completed' ? await this.loadExecutionResult(executionId) : null,
error: executionStatus === 'success' ? null : `Execution ${executionStatus}`,
})) || settledAny;
} catch (error) {
this.logger.error('Failed to reconcile workflow background job', {
jobId: job.id,
executionId,
error,
});
}
settledAny =
(await this.settleFinishedWorkflowJob(job, statuses.get(job.childExecutionId))) ||
settledAny;
}
return settledAny;
@@ -614,4 +561,61 @@ export class AgentBackgroundJobService {
}
return settledAny;
}
private async stopWorkflowJobExecution(job: AgentBackgroundJob): Promise<boolean> {
if (job.childExecutionId === null || job.workflowId === null) return true;
// Lazy: ExecutionService is a heavy dependency this service otherwise
// never needs — workers load this class for the settle path alone.
const { ExecutionService } = await import('@/executions/execution.service.js');
const { MissingExecutionStopError } = await import('@/errors/missing-execution-stop.error.js');
try {
await Container.get(ExecutionService).stop(job.childExecutionId, [job.workflowId]);
} catch (error) {
if (error instanceof MissingExecutionStopError || error instanceof WorkflowOperationError) {
this.logger.debug('Workflow job execution was already beyond stopping', {
jobId: job.id,
executionId: job.childExecutionId,
});
return false;
}
this.logger.error('Failed to stop a workflow job execution — it may still be running', {
jobId: job.id,
executionId: job.childExecutionId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
return true;
}
private async settleFinishedWorkflowJob(
job: AgentBackgroundJob & { childExecutionId: string },
executionStatus: ExecutionStatus | undefined,
): Promise<boolean> {
const executionId = job.childExecutionId;
try {
if (executionStatus === undefined) {
return await this.settle(job.id, {
status: 'failed',
error: EXECUTION_OUTCOME_UNKNOWN_ERROR,
});
}
if (!isTerminalExecutionStatus(executionStatus)) return false;
const status = settlementStatusForExecution(executionStatus);
return await this.settle(job.id, {
status,
result: status === 'completed' ? await this.loadExecutionResult(executionId) : null,
error: executionStatus === 'success' ? null : `Execution ${executionStatus}`,
});
} catch (error) {
this.logger.error('Failed to reconcile workflow background job', {
jobId: job.id,
executionId,
error,
});
return false;
}
}
}
@@ -21,6 +21,7 @@ import {
AGENT_BACKGROUND_UPDATES_OPEN_TAG,
formatWakeMessage,
} from './background-job-messages';
import type { Agent } from '../entities/agent.entity';
import type { AgentBackgroundJob } from '../entities/agent-background-job.entity';
import { ChatIntegrationRegistry } from '../integrations/agent-chat-integration';
import { AgentBackgroundJobRepository } from '../repositories/agent-background-job.repository';
@@ -173,47 +174,12 @@ export class AgentWakeService {
return;
}
const agent = await this.agentRepository.findById(first.parentAgentId);
if (!agent) {
this.recordFailure(threadId, generation, 'Background job parent agent no longer exists');
return;
}
let identity: ExecuteForWakeConfig['identity'];
try {
identity = await this.resolveIdentity(
first.parentResourceId,
first.parentPrincipalHash,
agent.projectId,
);
} catch (error) {
this.recordFailure(
threadId,
generation,
error instanceof Error ? error.message : String(error),
);
return;
}
const target = await this.resolveWakeTarget(first, threadId, generation);
if (!target) return;
const { agent, identity } = target;
try {
this.activeWakes.add(threadId);
try {
await this.orchestrator.executeForWake({
agentId: agent.id,
projectId: agent.projectId,
message: formatWakeMessage(jobs),
backgroundJobSignal: {
tasks: jobs.flatMap(({ id, title, kind, status }) =>
status === 'running' ? [] : [{ id, title, kind, status }],
),
},
memory: { threadId, resourceId: first.parentResourceId },
identity,
abortSignal: signal,
});
} finally {
this.activeWakes.delete(threadId);
}
await this.runWake(agent, identity, jobs, threadId, first.parentResourceId, signal);
if (signal.aborted) return;
await this.backgroundJobService.markMailConsumed(
@@ -282,4 +248,57 @@ export class AgentWakeService {
reason,
});
}
private async resolveWakeTarget(first: AgentBackgroundJob, threadId: string, generation: string) {
const agent = await this.agentRepository.findById(first.parentAgentId);
if (!agent) {
this.recordFailure(threadId, generation, 'Background job parent agent no longer exists');
return undefined;
}
let identity: ExecuteForWakeConfig['identity'];
try {
identity = await this.resolveIdentity(
first.parentResourceId,
first.parentPrincipalHash,
agent.projectId,
);
} catch (error) {
this.recordFailure(
threadId,
generation,
error instanceof Error ? error.message : String(error),
);
return undefined;
}
return { agent, identity };
}
private async runWake(
agent: Agent,
identity: ExecuteForWakeConfig['identity'],
jobs: AgentBackgroundJob[],
threadId: string,
resourceId: string,
signal: AbortSignal,
): Promise<void> {
this.activeWakes.add(threadId);
try {
await this.orchestrator.executeForWake({
agentId: agent.id,
projectId: agent.projectId,
message: formatWakeMessage(jobs),
backgroundJobSignal: {
tasks: jobs.flatMap(({ id, title, kind, status }) =>
status === 'running' ? [] : [{ id, title, kind, status }],
),
},
memory: { threadId, resourceId },
identity,
abortSignal: signal,
});
} finally {
this.activeWakes.delete(threadId);
}
}
}
@@ -1,4 +1,4 @@
import type { BuiltTool, ToolContext } from '@n8n/agents';
import type { BuiltTool, CreateDelegateSubAgentToolOptions, ToolContext } from '@n8n/agents';
import { INLINE_SUB_AGENT_ID } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import { SUB_AGENT_TASK_DIFFICULTIES, type SubAgentSource } from '@n8n/api-types';
@@ -7,8 +7,10 @@ import { z } from 'zod';
import { decodeAgentSandboxHostMetadata } from '../agent-sandbox-principal';
import { isTaskRunMemoryResourceId } from '../utils/agent-memory-scope';
import type { AgentBackgroundJobService } from './agent-background-job.service';
import type { SubAgentBackgroundRunner } from './sub-agent-background-runner';
import type { SubAgentRunContext } from '../sub-agents/sub-agent-runner';
import type {
BackgroundSubAgentRunContext,
SubAgentBackgroundRunner,
} from './sub-agent-background-runner';
/** Cap on the result text echoed to the model; the full text stays on the row. */
const RESULT_ECHO_MAX_CHARS = 8000;
@@ -17,20 +19,12 @@ export interface BackgroundJobToolsOptions {
jobService: AgentBackgroundJobService;
backgroundRunner: SubAgentBackgroundRunner;
sourcesById: Record<string, SubAgentSource>;
availableSubAgents: Array<{ id: string; name: string; useWhen?: string }>;
availableSubAgents: NonNullable<CreateDelegateSubAgentToolOptions['availableSubAgents']>;
projectId: string;
parentAgentId: string;
// The workspace handle is principal-scoped, not thread-scoped. The sandbox
// outlives the parent turn, so capture it when the tool is built.
runContext: Pick<
SubAgentRunContext,
| 'credentialProvider'
| 'runType'
| 'workflowToolExecutionMode'
| 'user'
| 'instrumentation'
| 'parentWorkspaceHandle'
>;
runContext: BackgroundSubAgentRunContext;
}
/**
@@ -17,6 +17,16 @@ import {
type SubAgentRunResult,
} from '../sub-agents/sub-agent-runner';
export type BackgroundSubAgentRunContext = Pick<
SubAgentRunContext,
| 'credentialProvider'
| 'runType'
| 'workflowToolExecutionMode'
| 'user'
| 'instrumentation'
| 'parentWorkspaceHandle'
>;
export interface BackgroundSpawnRequest {
subAgentId: string;
source: SubAgentSource;
@@ -55,15 +65,7 @@ export class SubAgentBackgroundRunner {
context: {
projectId: string;
parentAgentId: string;
} & Pick<
SubAgentRunContext,
| 'credentialProvider'
| 'runType'
| 'workflowToolExecutionMode'
| 'user'
| 'instrumentation'
| 'parentWorkspaceHandle'
>,
} & BackgroundSubAgentRunContext,
): Promise<BackgroundJobReceipt> {
// Throws on an unusable task name — before the job row exists, so a bad
// name cannot leave a phantom `running` row holding a thread slot.
File diff suppressed because it is too large Load Diff
@@ -36,7 +36,7 @@ import { modelStreamStallOptions } from '../model-stream-stall-options';
import { buildAgentPreviewPath } from './agent-builder-preview-path';
import { getModelRecommendationsSection } from './agents-builder-model-recommendations';
import { buildBuilderPrompt } from './agents-builder-prompts';
import { AgentsBuilderToolsService } from './agents-builder-tools.service';
import { AgentsBuilderToolsService, type BuilderTools } from './agents-builder-tools.service';
import { BuilderCheckpointUnavailableError } from './errors';
import {
BUILDER_PLANNER_TODOS_DESCRIPTION,
@@ -242,14 +242,7 @@ export class AgentsBuilderService {
// always runs on it directly.
const modelConfig = session.modelConfig;
const modelRecommendationsSection = await getModelRecommendationsSection();
const instructions = buildBuilderPrompt({
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
modelRecommendationsSection,
});
const finalInstructions = session.instructionsAddendum
? `${instructions}\n\n${session.instructionsAddendum}`
: instructions;
const finalInstructions = await this.createBuilderInstructions(projectId, agentId, session);
const runtimeSkills = getBuilderRuntimeSkills();
const tools = this.agentsBuilderToolsService.getTools(
@@ -265,36 +258,8 @@ export class AgentsBuilderService {
},
);
const { Agent, Memory, Tool, createPlannerTodosTool } = await import('@n8n/agents');
const onMemoryUsage = async (report: MemoryTaskUsageReport) => {
try {
const items = tokenUsageToBuilderUsageItems(report.model, report.usage);
if (items.length === 0) return;
await this.instanceAiCreditService.claimRunUsage(
user,
session.hostThreadId,
`${session.runId}:agent-builder:${agentId}:memory:${report.task}:${report.reportId}`,
items,
'completed',
);
} catch (error) {
this.logger.warn('Failed to claim agent-builder observational-memory usage', {
agentId,
hostThreadId: session.hostThreadId,
runId: session.runId,
task: report.task,
error: error instanceof Error ? error.message : String(error),
});
}
};
const builderMemory = new Memory()
.storage(this.n8nMemory.getImplementation(agentId))
.observationalMemory({
observe: createObservationLogObserveFn(modelConfig, { onUsage: onMemoryUsage }),
reflect: createObservationLogReflectFn(modelConfig, { onUsage: onMemoryUsage }),
});
const { Agent } = await import('@n8n/agents');
const builderMemory = await this.createBuilderMemory(agentId, user, session);
const builder = new Agent('agent-builder')
.model(modelConfig)
@@ -311,46 +276,7 @@ export class AgentsBuilderService {
if (session.telemetry) builder.telemetry(session.telemetry);
if (session.memoryTaskObserver) builder.memoryTaskObserver(session.memoryTaskObserver);
const plannerTodosTool = createPlannerTodosTool({
description: BUILDER_PLANNER_TODOS_DESCRIPTION,
systemInstruction: BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
});
const reportRequiredArtifactTool = session.onRequiredArtifact
? new Tool(REPORT_REQUIRED_ARTIFACT_TOOL_NAME)
.description(
'Report a workflow or data table that Instance AI must create outside the target Agent. ' +
'Use relationship "agent-entrypoint" for a channel bridge that invokes the Agent; it will not be attached as an Agent tool.',
)
.input(reportRequiredArtifactInputSchema)
.handler(async (input: ReportRequiredArtifactInput) => {
session.onRequiredArtifact?.(input.artifact);
return { ok: true };
})
.build()
: undefined;
const builderTools = [
...tools.json,
...tools.shared,
plannerTodosTool,
...(reportRequiredArtifactTool ? [reportRequiredArtifactTool] : []),
];
const claimedToolNames = new Set(builderTools.map((tool) => tool.name));
for (const tool of builderTools) {
builder.tool(tool);
}
for (const [toolName, tool] of session.mcpTools ?? []) {
if (claimedToolNames.has(toolName)) {
this.logger.warn('Skipped MCP tool that conflicts with an agent builder tool', {
toolName,
agentId,
});
continue;
}
claimedToolNames.add(toolName);
builder.tool(tool);
}
await this.registerBuilderTools(builder, tools, agentId, session);
builder.reasoning(resolveAIAReasoning(modelConfig));
@@ -382,4 +308,115 @@ export class AgentsBuilderService {
): Promise<SerializableAgentState | null> {
return await this.n8nCheckpointStorage.findSuspendedForThread(agentId, threadId);
}
private async claimMemoryUsage(
report: MemoryTaskUsageReport,
agentId: string,
user: User,
session: InstanceAiBuilderSessionOptions,
): Promise<void> {
try {
const items = tokenUsageToBuilderUsageItems(report.model, report.usage);
if (items.length === 0) return;
await this.instanceAiCreditService.claimRunUsage(
user,
session.hostThreadId,
`${session.runId}:agent-builder:${agentId}:memory:${report.task}:${report.reportId}`,
items,
'completed',
);
} catch (error) {
this.logger.warn('Failed to claim agent-builder observational-memory usage', {
agentId,
hostThreadId: session.hostThreadId,
runId: session.runId,
task: report.task,
error: error instanceof Error ? error.message : String(error),
});
}
}
private async createBuilderInstructions(
projectId: string,
agentId: string,
session: InstanceAiBuilderSessionOptions,
): Promise<string> {
const modelRecommendationsSection = await getModelRecommendationsSection();
const instructions = buildBuilderPrompt({
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
modelRecommendationsSection,
});
return session.instructionsAddendum
? `${instructions}\n\n${session.instructionsAddendum}`
: instructions;
}
private async createBuilderMemory(
agentId: string,
user: User,
session: InstanceAiBuilderSessionOptions,
) {
const { Memory } = await import('@n8n/agents');
const onMemoryUsage = async (report: MemoryTaskUsageReport) =>
await this.claimMemoryUsage(report, agentId, user, session);
return new Memory().storage(this.n8nMemory.getImplementation(agentId)).observationalMemory({
observe: createObservationLogObserveFn(session.modelConfig, { onUsage: onMemoryUsage }),
reflect: createObservationLogReflectFn(session.modelConfig, { onUsage: onMemoryUsage }),
});
}
private async registerBuilderTools(
builder: RuntimeAgent,
tools: BuilderTools,
agentId: string,
session: InstanceAiBuilderSessionOptions,
): Promise<void> {
const { createPlannerTodosTool } = await import('@n8n/agents');
const plannerTodosTool = createPlannerTodosTool({
description: BUILDER_PLANNER_TODOS_DESCRIPTION,
systemInstruction: BUILDER_PLANNER_TODOS_SYSTEM_INSTRUCTION,
});
const reportRequiredArtifactTool = await this.createRequiredArtifactTool(session);
const builderTools = [
...tools.json,
...tools.shared,
plannerTodosTool,
...(reportRequiredArtifactTool ? [reportRequiredArtifactTool] : []),
];
const claimedToolNames = new Set(builderTools.map((tool) => tool.name));
for (const tool of builderTools) {
builder.tool(tool);
}
for (const [toolName, tool] of session.mcpTools ?? []) {
if (claimedToolNames.has(toolName)) {
this.logger.warn('Skipped MCP tool that conflicts with an agent builder tool', {
toolName,
agentId,
});
continue;
}
claimedToolNames.add(toolName);
builder.tool(tool);
}
}
private async createRequiredArtifactTool(session: InstanceAiBuilderSessionOptions) {
if (!session.onRequiredArtifact) return undefined;
const { Tool } = await import('@n8n/agents');
return new Tool(REPORT_REQUIRED_ARTIFACT_TOOL_NAME)
.description(
'Report a workflow or data table that Instance AI must create outside the target Agent. ' +
'Use relationship "agent-entrypoint" for a channel bridge that invokes the Agent; it will not be attached as an Agent tool.',
)
.input(reportRequiredArtifactInputSchema)
.handler(async (input: ReportRequiredArtifactInput) => {
session.onRequiredArtifact?.(input.artifact);
return { ok: true };
})
.build();
}
}
@@ -2,22 +2,23 @@ import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { CredentialsHelper } from '@/credentials-helper';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { createAiProxyFetch } from '@/utils/ai-proxy-fetch';
import type { ModelChoice } from './model-lookup.types';
import { mapCredentialForProvider } from '../json-config/credential-field-mapping';
import { LLM_PROVIDER_DEFAULTS } from '../llm-provider-defaults';
import { decryptAgentCredential } from '../utils/decrypt-agent-credential';
export type ModelCatalogPolicy = 'curated' | 'endpoint-only' | 'managed';
export type LiveModelLookupResult =
| {
status: 'success';
models: Array<{ name: string; value: string }>;
models: ModelChoice[];
policy: ModelCatalogPolicy;
}
| { status: 'unavailable'; error: unknown; policy: ModelCatalogPolicy };
@@ -58,7 +59,7 @@ export class BuilderModelLiveLookupService {
credentialType: string,
provider: string,
options?: ModelLookupOptions,
): Promise<Array<{ name: string; value: string }>> {
): Promise<ModelChoice[]> {
const result = await this.lookup(
user,
projectId,
@@ -98,7 +99,10 @@ export class BuilderModelLiveLookupService {
return this.getEvalModelCatalog(credentialType, provider, 'curated');
}
const credentialData = await this.decryptWithExpressions(usable, projectId, user);
const credentialData = await decryptAgentCredential(this.credentialsHelper, usable, {
projectId,
userId: user.id,
});
return await this.discoverModels(provider, credentialData);
}
@@ -121,34 +125,6 @@ export class BuilderModelLiveLookupService {
};
}
/**
* Decrypt the way node execution does, so credential defaults are applied and
* expressions resolved. A base URL computed from sibling fields (region,
* workspace) is otherwise absent or still literal expression text.
*
* There is no workflow or execution here, so `additionalData` comes from
* `getBase()` and the mode is `internal` — which skips dynamic-credential
* resolution, leaving a per-user credential on its static stored data. Same
* as `AgentsCredentialProvider`.
*/
private async decryptWithExpressions(
credential: { id: string; name: string; type: string },
projectId: string,
user: User,
): Promise<ICredentialDataDecryptedObject> {
// Imported lazily: pulls in the execution stack, and model lookup is not on
// every request.
const { getBase } = await import('@/workflow-execute-additional-data.js');
const additionalData = await getBase({ userId: user.id, projectId });
return await this.credentialsHelper.getDecrypted(
additionalData,
{ id: credential.id, name: credential.name },
credential.type,
'internal',
);
}
/**
* Looks up the chat models n8n Connect (AI Gateway) allows for a provider.
* Gateway resolution or discovery failures are returned as managed-policy
@@ -243,12 +219,12 @@ export class BuilderModelLiveLookupService {
typeof credentialData.headerValue === 'string'
) {
const normalizedName = credentialData.headerName.toLowerCase();
const headerName =
normalizedName === 'authorization'
? 'Authorization'
: normalizedName === 'openai-organization'
? 'OpenAI-Organization'
: credentialData.headerName;
let headerName = credentialData.headerName;
if (normalizedName === 'authorization') {
headerName = 'Authorization';
} else if (normalizedName === 'openai-organization') {
headerName = 'OpenAI-Organization';
}
headers[headerName] = credentialData.headerValue;
}
@@ -7,9 +7,9 @@ import { mock } from 'vitest-mock-extended';
import {
buildAskCredentialTool,
buildAskEmbeddingCredentialTool,
type AskCredentialToolDeps,
type AskEmbeddingCredentialToolDeps,
} from '../ask-credential.tool';
import type { CredentialSetupDeps } from '../setup-tool.types';
interface TestCtx {
resumeData?: unknown;
@@ -37,8 +37,8 @@ function expectListed(credentialService: InstanceAiCredentialService, credential
let track: Mock;
function askCredentialTool(deps: Omit<AskCredentialToolDeps, 'track' | 'projectId'>) {
const merged: AskCredentialToolDeps = { projectId: 'project-1', ...deps, track };
function askCredentialTool(deps: Omit<CredentialSetupDeps, 'track' | 'projectId'>) {
const merged: CredentialSetupDeps = { projectId: 'project-1', ...deps, track };
return buildAskCredentialTool(merged);
}
@@ -1,4 +1,4 @@
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import type { BuiltTool, CredentialListItem, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
ASK_CREDENTIAL_TOOL_NAME,
@@ -16,22 +16,9 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { nanoid } from 'nanoid';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import type { BuilderTrackFn } from '../builder-config-telemetry';
import type { CredentialSetupDeps } from './setup-tool.types';
export interface AskCredentialToolDeps {
credentialService: InstanceAiCredentialService;
/** Project the agent lives in — scopes the FE credential picker. */
projectId: string;
isCredentialTypeKnown?: (credentialType: string) => boolean;
/**
* Credential ids of the agent's configured chat channel integrations. When
* one of them matches the requested credential type, it is reused for the
* tool instead of asking the user to pick a credential.
*/
listIntegrationCredentialIds?: () => Promise<string[]>;
track: BuilderTrackFn;
}
export interface AskEmbeddingCredentialToolDeps extends AskCredentialToolDeps {
export interface AskEmbeddingCredentialToolDeps extends CredentialSetupDeps {
isAssistantProxyEnabled: () => boolean;
}
@@ -40,7 +27,7 @@ type AskCredentialToolResult =
| {
credentialId: string;
credentialName: string;
credentials: Record<string, { id: string; name: string }>;
credentials: Record<string, Pick<CredentialListItem, 'id' | 'name'>>;
};
function withNodeCredentialMap(
@@ -63,7 +50,7 @@ async function listExistingCredentials(
credentialService: InstanceAiCredentialService,
projectId: string,
credentialType: string,
): Promise<Array<{ id: string; name: string }>> {
): Promise<Array<Pick<CredentialListItem, 'id' | 'name'>>> {
const all = await credentialService.list({ type: credentialType, projectId });
return all.map((c) => ({ id: c.id, name: c.name }));
}
@@ -109,7 +96,7 @@ async function resolveResume(
async function resolveCredentialSelection(
input: AskCredentialInput,
ctx: InterruptibleToolContext<CredentialSuspendPayload, CredentialResumeData>,
deps: AskCredentialToolDeps,
deps: CredentialSetupDeps,
): Promise<AskCredentialToolResult> {
if (ctx.resumeData !== undefined && ctx.resumeData !== null) {
return await resolveResume(
@@ -172,7 +159,7 @@ async function resolveCredentialSelection(
});
}
export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool {
export function buildAskCredentialTool(deps: CredentialSetupDeps): BuiltTool {
return new Tool(ASK_CREDENTIAL_TOOL_NAME)
.description(
'Show a credential picker card in the chat UI and suspend until the user selects ' +
@@ -11,7 +11,7 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import type { BuilderTrackFn } from '../builder-config-telemetry';
import type { ChannelSetupDeps } from './setup-tool.types';
const configureChannelInputSchema = z.object({
integrationType: z.string().describe('Chat platform type from list_integration_types'),
@@ -19,15 +19,7 @@ const configureChannelInputSchema = z.object({
type ConfigureChannelInput = z.infer<typeof configureChannelInputSchema>;
export interface ConfigureChannelToolDeps {
agentId: string;
projectId: string;
/** Wraps `AgentIntegrationPersistenceService.listChatIntegrations()`. */
listChatIntegrationTypes: () => string[];
track: BuilderTrackFn;
}
export function buildConfigureChannelTool(deps: ConfigureChannelToolDeps): BuiltTool {
export function buildConfigureChannelTool(deps: ChannelSetupDeps): BuiltTool {
return new Tool(CONFIGURE_CHANNEL_TOOL_NAME)
.description(
'Configure one available chat channel for the target agent. First call ' +
@@ -1,4 +1,4 @@
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import type { BuiltTool, CredentialListItem, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
channelSuspendPayloadSchema,
@@ -14,14 +14,14 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import type { BuilderTrackFn } from '../builder-config-telemetry';
import { BUILDER_TOOLS } from '../builder-tool-names';
import type { ChannelSetupDeps, CredentialSetupDeps } from './setup-tool.types';
/** Filter an already-fetched credential list down to one type, in the shape setup cards need. */
function credentialsOfType(
all: Array<{ id: string; name: string; type: string }>,
all: CredentialListItem[],
credentialType: string,
): Array<{ id: string; name: string }> {
): Array<Pick<CredentialListItem, 'id' | 'name'>> {
return all.filter((c) => c.type === credentialType).map((c) => ({ id: c.id, name: c.name }));
}
@@ -38,16 +38,7 @@ async function credentialNameById(
}
}
export interface FinishSetupToolDeps {
credentialService: InstanceAiCredentialService;
agentId: string;
projectId: string;
track: BuilderTrackFn;
isCredentialTypeKnown?: (credentialType: string) => boolean;
/** Credential ids of the agent's configured chat channel integrations — reused for a matching credential slot. */
listIntegrationCredentialIds?: () => Promise<string[]>;
/** Wraps `AgentIntegrationPersistenceService.listChatIntegrations()`. */
listChatIntegrationTypes: () => string[];
export interface FinishSetupToolDeps extends CredentialSetupDeps, ChannelSetupDeps {
/**
* Credential types whose every required node-tool slot is already served by an
* n8n Connect managed credential — a card for these is redundant. A type still
@@ -363,7 +354,7 @@ async function suspendForPhase(params: {
const credentialRequests: Array<{
credentialType: string;
reason: string;
existingCredentials: Array<{ id: string; name: string }>;
existingCredentials: Array<Pick<CredentialListItem, 'id' | 'name'>>;
}> = [];
for (const slot of phase.slots) {
if (seenTypes.has(slot.credentialType)) continue;
@@ -12,16 +12,13 @@ import {
} from '../../llm-provider-defaults';
import { findVerifiedModelId, normalizeProviderModelId } from '../../utils/provider-model-id';
import { BUILDER_TOOLS } from '../builder-tool-names';
import type { ModelChoice } from '../model-lookup.types';
/** User-facing name written for an n8n credits (AI Gateway managed) model credential. */
const N8N_CONNECT_CREDENTIAL_NAME = 'Gateway credits';
export interface ModelLookup {
list(
credentialId: string,
credentialType: string,
provider: string,
): Promise<Array<{ name: string; value: string }>>;
list(credentialId: string, credentialType: string, provider: string): Promise<ModelChoice[]>;
}
/** Provisions free OpenAI credits on demand for a zero-credential builder session. */
@@ -112,11 +109,6 @@ function toLlmResolution(
};
}
interface CallableModel {
name: string;
value: string;
}
/**
* How many models an `unknown_model` result carries. A provider catalog runs to
* hundreds of ids (OpenRouter alone), and the whole list would land in the
@@ -126,7 +118,7 @@ interface CallableModel {
*/
const MAX_AVAILABLE_MODELS = 25;
function unknownModel(provider: string, requestedModel: string, availableModels: CallableModel[]) {
function unknownModel(provider: string, requestedModel: string, availableModels: ModelChoice[]) {
const shown = availableModels.slice(0, MAX_AVAILABLE_MODELS);
return {
ok: false as const,
@@ -162,7 +154,7 @@ async function listCallableModels(
credential: CredentialListItem,
provider: string,
modelLookup: ModelLookup,
): Promise<{ ok: true; models: CallableModel[] } | { ok: false; error: unknown }> {
): Promise<{ ok: true; models: ModelChoice[] } | { ok: false; error: unknown }> {
try {
const models = await modelLookup.list(credential.id, credential.type, provider);
return {
@@ -0,0 +1,22 @@
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import type { BuilderTrackFn } from '../builder-config-telemetry';
interface SetupToolDeps {
/** Scopes setup to the agent's project. */
projectId: string;
track: BuilderTrackFn;
}
export interface CredentialSetupDeps extends SetupToolDeps {
credentialService: InstanceAiCredentialService;
isCredentialTypeKnown?: (credentialType: string) => boolean;
/** Returns configured channel credential IDs for credential reuse. */
listIntegrationCredentialIds?: () => Promise<string[]>;
}
export interface ChannelSetupDeps extends SetupToolDeps {
agentId: string;
/** Lists the channel types that setup can offer. */
listChatIntegrationTypes: () => string[];
}
@@ -0,0 +1,4 @@
export interface ModelChoice {
name: string;
value: string;
}
@@ -1,8 +1,9 @@
import type { AgentBackgroundJobDto } from '@n8n/api-types';
import { DateTimeColumn, WithTimestampsAndStringId } from '@n8n/db';
import { Column, Entity, Index } from '@n8n/typeorm';
export type AgentBackgroundJobKind = 'subagent' | 'workflow';
export type AgentBackgroundJobStatus = 'running' | 'completed' | 'failed' | 'cancelled';
export type AgentBackgroundJobKind = AgentBackgroundJobDto['kind'];
export type AgentBackgroundJobStatus = AgentBackgroundJobDto['status'];
/**
* Durable registry of background jobs dispatched by top-level agents: detached
@@ -1,4 +1,4 @@
import type { AgentMessageAuthor } from '@n8n/api-types';
import type { AgentExecutionStatus, AgentMessageAuthor } from '@n8n/api-types';
import {
DateTimeColumn,
JsonColumn,
@@ -9,9 +9,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm';
import { AgentExecutionThread } from './agent-execution-thread.entity';
import type { TimelineEvent } from '../execution-recorder';
import type { StoredAttachmentRef } from '../types/agent-chat-attachment';
import type { AgentExecutionFailureSummary } from '../utils/execution-failure-summary';
export type AgentExecutionStatus = 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
export type AgentExecutionHitlStatus = 'suspended' | 'resumed';
/**
@@ -62,12 +62,7 @@ export class AgentExecution extends WithTimestampsAndStringId {
/** Metadata of files attached to the user turn ({id, fileName, mimeType, sizeBytes}[]); bytes live in BinaryDataService. */
@JsonColumn({ nullable: true })
attachments: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}> | null;
attachments: StoredAttachmentRef[] | null;
@Column({ type: 'varchar', length: 255, nullable: true })
model: string | null;
@@ -1,4 +1,3 @@
import type { ToolDescriptor } from '@n8n/agents';
import { type AgentJsonConfig, type AgentSkill } from '@n8n/api-types';
import { JsonColumn, WithTimestamps } from '@n8n/db';
import {
@@ -35,13 +34,7 @@ export class AgentHistory extends WithTimestamps {
schema: AgentJsonConfig | null;
@JsonColumn({ nullable: true, default: null })
tools: Record<
string,
{
code: string;
descriptor: ToolDescriptor;
}
> | null;
tools: Agent['tools'] | null;
@JsonColumn({ nullable: true, default: null })
skills: Record<string, AgentSkill> | null;
@@ -1,9 +1,7 @@
import type { JSONObject } from '@n8n/agents';
import type { EpisodicMemoryStatus, JSONObject } from '@n8n/agents';
import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from '@n8n/db';
import { Column, Entity, Index } from '@n8n/typeorm';
export type MemoryEntryStatus = 'active' | 'superseded' | 'dropped';
@Entity({ name: 'agents_memory_entries' })
@Index(['agentId', 'resourceId', 'status', 'createdAt', 'id'])
@Index(['agentId', 'resourceId', 'contentHash'], { unique: true })
@@ -23,7 +21,7 @@ export class AgentMemoryEntryEntity extends WithTimestampsAndStringId {
contentHash: string;
@Column({ type: 'varchar', length: 16 })
status: MemoryEntryStatus;
status: EpisodicMemoryStatus;
@Column({ type: 'varchar', length: 36, nullable: true })
supersededBy: string | null;
@@ -1,8 +1,7 @@
import type { ObservationLogTaskKind } from '@n8n/agents';
import { DateTimeColumn, WithTimestamps } from '@n8n/db';
import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm';
export type ObservationTaskKind = 'observer' | 'reflector';
@Entity({ name: 'agents_observation_locks' })
@Index(['observationScopeId'])
export class AgentObservationLockEntity extends WithTimestamps {
@@ -13,7 +12,7 @@ export class AgentObservationLockEntity extends WithTimestamps {
observationScopeId: string;
@PrimaryColumn({ type: 'varchar', length: 20 })
taskKind: ObservationTaskKind;
taskKind: ObservationLogTaskKind;
@Column({ type: 'varchar', length: 64 })
holderId: string;
@@ -1,9 +1,7 @@
import type { ObservationLogMarker, ObservationLogStatus } from '@n8n/agents';
import { WithTimestampsAndStringId } from '@n8n/db';
import { Column, Entity, Index } from '@n8n/typeorm';
export type ObservationMarker = 'critical' | 'important' | 'info' | 'completion';
export type ObservationStatus = 'active' | 'superseded' | 'dropped';
@Entity({ name: 'agents_observations' })
@Index(['agentId', 'observationScopeId', 'status', 'createdAt', 'id'])
@Index(['agentId', 'observationScopeId', 'createdAt', 'id'])
@@ -18,7 +16,7 @@ export class AgentObservationEntity extends WithTimestampsAndStringId {
observationScopeId: string;
@Column({ type: 'varchar', length: 16 })
marker: ObservationMarker;
marker: ObservationLogMarker;
@Column({ type: 'text' })
text: string;
@@ -30,7 +28,7 @@ export class AgentObservationEntity extends WithTimestampsAndStringId {
tokenCount: number;
@Column({ type: 'varchar', length: 16 })
status: ObservationStatus;
status: ObservationLogStatus;
@Column({ type: 'varchar', length: 36, nullable: true })
supersededBy: string | null;
@@ -11,7 +11,7 @@ import { isSensitiveKey } from '@n8n/utils/redaction/sensitive-key';
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import { extractFromAICalls, isFromAIOnlyExpression } from 'n8n-workflow';
import type { ToolRegistry } from './tool-registry';
import type { ToolRegistry, ToolRegistryEntry } from './tool-registry';
/** Cap on child trace characters persisted per delegation. Tighter than the
* live forwarding budget because this is written into every parent execution row. */
@@ -203,7 +203,7 @@ function sanitizeExecutionLogRecord(value: unknown): Record<string, unknown> | u
export interface ToolCallDetails {
toolName: string;
displayName?: string;
kind: 'tool' | 'workflow' | 'node';
kind: ToolRegistryEntry['kind'];
input: unknown;
node?: {
type: string;
@@ -267,7 +267,7 @@ export type TimelineEvent =
| { type: 'reasoning'; content: string; timestamp: number; endTime?: number }
| {
type: 'tool-call';
kind: 'tool' | 'workflow' | 'node';
kind: ToolRegistryEntry['kind'];
name: string;
toolCallId: string;
input: unknown;
@@ -6,12 +6,10 @@ import { Time } from '@n8n/constants';
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { AgentChannelRef } from '../../utils/agent-channel';
import type { Agent } from '../../entities/agent.entity';
import type { AgentChannelStatus } from '../../entities/agent-channel-status.entity';
import type {
AgentChannelRef,
AgentChannelStatusRepository,
} from '../../repositories/agent-channel-status.repository';
import type { AgentChannelStatusRepository } from '../../repositories/agent-channel-status.repository';
import type { AgentRepository } from '../../repositories/agent.repository';
import { AgentChannelReconciler } from '../agent-channel-reconciler.service';
import { AgentChannelStatusReporter } from '../agent-channel-status-reporter';
@@ -6,6 +6,7 @@ import type { StateAdapter, Lock, QueueEntry } from 'chat';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { AgentChangePublisher } from '../../agent-change-publisher.service';
import { AgentChatSubscriptionStateService } from '../agent-chat-subscription-state.service';
import type { AgentChatSubscriptionRepository } from '../../repositories/agent-chat-subscription.repository';
import type { AgentIntegrationConfig } from '@n8n/api-types';
@@ -55,10 +56,8 @@ function makeService(multiMainEnabled = true) {
} as Partial<GlobalConfig>);
const logger = mockLogger();
const service = new AgentChatSubscriptionStateService(
logger,
repository,
publisher,
globalConfig,
new AgentChangePublisher(publisher, globalConfig, logger),
);
return { service, repository, publisher, logger };
@@ -380,7 +379,7 @@ describe('AgentChatSubscriptionStateService', () => {
);
expect(delegate.subscribe).toHaveBeenCalledWith('thread-1');
expect(logger.warn).toHaveBeenCalledWith(
'[AgentChatSubscriptionStateService] Failed to publish subscription change',
'Failed to publish agent-chat-subscription-changed',
expect.objectContaining({ threadId: 'thread-1', action: 'subscribe', error: 'redis down' }),
);
});
@@ -12,6 +12,7 @@ import type { CredentialsService } from '@/credentials/credentials.service';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { UrlService } from '@/services/url.service';
import { AgentChangePublisher } from '../../agent-change-publisher.service';
import { AgentExecutionOrchestratorService } from '../../agent-execution-orchestrator.service';
import type { Agent } from '../../entities/agent.entity';
import type { AgentChannelStatusReporter } from '../agent-channel-status-reporter';
@@ -153,7 +154,7 @@ function buildServiceWith(
urlService,
registry,
instanceSettings,
publisher,
new AgentChangePublisher(publisher, globalConfig, logger),
globalConfig,
chatSubscriptionStateService,
statusReporter,
@@ -277,7 +278,7 @@ describe('ChatIntegrationService', () => {
mock(),
mock(),
mock<InstanceSettings>({ isLeader: true }),
mock(),
mock<AgentChangePublisher>(),
mock<GlobalConfig>({ multiMainSetup: { enabled: false } } as Partial<GlobalConfig>),
mock<AgentChatSubscriptionStateService>(),
mock<AgentChannelStatusReporter>(),
@@ -238,7 +238,7 @@ export function createReplayContextSetup<TChat extends ChatInstance>(params: {
);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(params.chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(params.chat);
const actionExecutor = new ChatIntegrationActionExecutor(
chatIntegrationService,
registry,
@@ -135,7 +135,7 @@ describe('ChatIntegrationActionExecutor', () => {
chat.thread.mockReturnValue(sentThread as never);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -150,7 +150,7 @@ describe('ChatIntegrationActionExecutor', () => {
awaitResponse: false,
});
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledWith('agent-1', {
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledWith('agent-1', {
type: 'slack',
credentialId: 'cred-a',
});
@@ -188,7 +188,7 @@ describe('ChatIntegrationActionExecutor', () => {
});
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -233,7 +233,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.openDM.mockResolvedValue(thread as never);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(undefined);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
@@ -284,7 +284,7 @@ describe('ChatIntegrationActionExecutor', () => {
chat.openDM.mockResolvedValue(dmThread as never);
chat.thread.mockReturnValue(sentThread as never);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -320,7 +320,7 @@ describe('ChatIntegrationActionExecutor', () => {
chat.channel.mockReturnValue(channel as never);
chat.thread.mockReturnValue(sentThread as never);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -358,7 +358,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.thread.mockReturnValue(thread as never);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const registry = buildRegistry();
Container.set(ChatIntegrationRegistry, registry);
const executor = new ChatIntegrationActionExecutor(
@@ -424,7 +424,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.thread.mockReturnValue(thread as never);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const shortenCallback = vi.fn(async (_actionId: string, _value: string, _label?: string) => ({
id: 'short1234',
value: '',
@@ -499,7 +499,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ editMessage });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const registry = buildRegistry();
registry.register(new ShortCallbackTelegramIntegration());
const executor = new ChatIntegrationActionExecutor(
@@ -544,7 +544,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ editMessage });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const registry = buildRegistry();
registry.register(new ShortCallbackTelegramIntegration());
const executor = new ChatIntegrationActionExecutor(
@@ -584,7 +584,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({});
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const registry = buildRegistry();
registry.register(new ShortCallbackTelegramIntegration());
const executor = new ChatIntegrationActionExecutor(
@@ -627,7 +627,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ editMessage });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const shortenCallback = vi.fn(async (_actionId: string, _value: string) => ({
id: 'short1234',
value: '',
@@ -705,7 +705,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -760,7 +760,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -810,7 +810,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ addReaction });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -880,7 +880,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ addReaction });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -943,7 +943,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -970,7 +970,7 @@ describe('ChatIntegrationActionExecutor', () => {
it('returns a structured error when the selected connection is unavailable', async () => {
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(undefined);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -1026,7 +1026,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -1130,7 +1130,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -1201,7 +1201,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
buildRegistry(),
@@ -1284,7 +1284,7 @@ describe('ChatIntegrationActionExecutor', () => {
const chat = mock<ChatInstance>();
chat.thread.mockReturnValue(thread as never);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const registry = buildRegistry();
Container.set(ChatIntegrationRegistry, registry);
const executor = new ChatIntegrationActionExecutor(
@@ -1406,7 +1406,7 @@ describe('ChatIntegrationActionExecutor', () => {
silent: true,
note: expect.stringContaining('No reply will be sent'),
});
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
});
it('rejects when the reply expectation is required', async () => {
@@ -1430,7 +1430,7 @@ describe('ChatIntegrationActionExecutor', () => {
ok: false,
error: { code: 'REPLY_REQUIRED', message: expect.stringContaining('reply is expected') },
});
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
});
it('rejects when the reply expectation is unset or the message context is missing', async () => {
@@ -1456,7 +1456,7 @@ describe('ChatIntegrationActionExecutor', () => {
error: { code: 'REPLY_REQUIRED', message: expect.any(String) },
});
}
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
});
});
});
@@ -1475,7 +1475,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const guard = new ChannelRateLimitGuard();
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
@@ -1521,7 +1521,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const guard = new ChannelRateLimitGuard();
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
@@ -1563,7 +1563,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
});
expect(slackAdapter.addReaction).toHaveBeenCalledTimes(1);
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledTimes(1);
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledTimes(1);
expect(result2).toEqual({
ok: false,
error: {
@@ -1580,7 +1580,7 @@ describe('ChatIntegrationActionExecutor — rate-limit handling', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const guard = new ChannelRateLimitGuard();
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
@@ -63,7 +63,6 @@ describe('ChatIntegrationContextQueryExecutor', () => {
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
@@ -78,10 +77,6 @@ describe('ChatIntegrationContextQueryExecutor', () => {
input: { query: 'Michael Drury' },
});
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledWith('agent-1', {
type: 'slack',
credentialId: 'cred-a',
});
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledWith('agent-1', slack);
expect(chat.getAdapter).toHaveBeenCalledWith('slack');
expect(usersList).toHaveBeenCalledWith({ limit: 10, token: 'xoxb-token' });
@@ -134,7 +129,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -190,7 +185,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -204,7 +199,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
input: { userId: 'user-1' },
});
expect(chatIntegrationService.getChatInstance).toHaveBeenCalledWith('agent-1', {
expect(chatIntegrationService.getChatInstanceForTools).toHaveBeenCalledWith('agent-1', {
type: 'linear',
credentialId: 'cred-b',
});
@@ -250,7 +245,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -317,7 +312,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -414,7 +409,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -562,7 +557,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -638,7 +633,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -697,7 +692,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -742,7 +737,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue({ client: linearClient });
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -778,7 +773,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -816,7 +811,7 @@ describe('ChatIntegrationContextQueryExecutor', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(chat);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const executor = new ChatIntegrationContextQueryExecutor(
chatIntegrationService,
buildRegistry(),
@@ -856,7 +851,6 @@ describe('ChatIntegrationContextQueryExecutor — rate-limit handling', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const guard = new ChannelRateLimitGuard();
const executor = new ChatIntegrationContextQueryExecutor(
@@ -893,7 +887,6 @@ describe('ChatIntegrationContextQueryExecutor — rate-limit handling', () => {
const chat = mock<ChatInstance>();
chat.getAdapter.mockReturnValue(slackAdapter);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.getChatInstance.mockReturnValue(undefined);
chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat);
const guard = new ChannelRateLimitGuard();
const executor = new ChatIntegrationContextQueryExecutor(
@@ -26,7 +26,6 @@ describe('IntegrationMessageContextService', () => {
}
const threadRepository = mock<AgentThreadRepository>();
const resourceRepository = mock<AgentResourceRepository>();
resourceRepository.existsBy.mockResolvedValue(true);
threadRepository.findOneBy.mockImplementation(async ({ id }: { id: string }) =>
threads.has(id) ? (threads.get(id) as never) : (null as never),
);
@@ -210,7 +210,7 @@ describe('internal integration dispatch', () => {
expect(tool.description).toContain('NEVER call respond with only message.text');
});
it('action executor skips getChatInstance and routes respond to the integration', async () => {
it('action executor routes respond to the integration without a chat instance', async () => {
const executor = new ChatIntegrationActionExecutor(
chatIntegrationService,
registry,
@@ -224,7 +224,7 @@ describe('internal integration dispatch', () => {
currentMessageContext: makeContext(),
});
expect(result).toMatchObject({ ok: true });
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
});
it('action executor returns UNSUPPORTED_ACTION for send_dm on n8n_chat', async () => {
@@ -240,10 +240,10 @@ describe('internal integration dispatch', () => {
awaitResponse: false,
});
expect(result).toMatchObject({ ok: false, error: { code: 'UNSUPPORTED_ACTION' } });
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
});
it('context executor skips getChatInstance for internal integrations', async () => {
it('context executor queries internal integrations without a chat instance', async () => {
userRepository.findOneBy.mockResolvedValue({
id: 'user-1',
firstName: 'Ada',
@@ -261,6 +261,6 @@ describe('internal integration dispatch', () => {
input: { userId: 'user-1' },
});
expect(result).toMatchObject({ ok: true });
expect(chatIntegrationService.getChatInstance).not.toHaveBeenCalled();
expect(chatIntegrationService.getChatInstanceForTools).not.toHaveBeenCalled();
});
});
@@ -20,7 +20,7 @@ import type { AgentMessageRepository } from '../../repositories/agent-message.re
import type { AgentObservationCursorRepository } from '../../repositories/agent-observation-cursor.repository';
import type { AgentObservationLockRepository } from '../../repositories/agent-observation-lock.repository';
import type { AgentObservationRepository } from '../../repositories/agent-observation.repository';
import type { AgentResourceRepository } from '../../repositories/agent-resource.repository';
import { AgentResourceRepository } from '../../repositories/agent-resource.repository';
import type { AgentThreadRepository } from '../../repositories/agent-thread.repository';
import { N8nMemory } from '../n8n-memory';
@@ -85,6 +85,9 @@ describe('N8nMemory', () => {
execute: vi.fn().mockResolvedValue({ raw: {}, generatedMaps: [], identifiers: [] }),
};
resourceRepository.createQueryBuilder.mockReturnValue(resourceInsertQueryBuilder as never);
resourceRepository.ensureExists.mockImplementation(
AgentResourceRepository.prototype.ensureExists.bind(resourceRepository),
);
transactionDelete = vi.fn().mockResolvedValue({ affected: 1, raw: {} });
transactionObservationCreate = vi.fn((input) => ({ ...input }) as AgentObservationEntity);
transactionObservationFind = vi.fn().mockResolvedValue([]);
@@ -15,9 +15,9 @@ import type { UrlService } from '@/services/url.service';
import type { AgentIntegrationManagementService } from '../../agent-integration-management.service';
import type { AgentRepository } from '../../repositories/agent.repository';
import type { AgentIntegrationRemovalContext } from '../agent-chat-integration';
import {
SlackManagedSetupService,
type DeleteManagedSlackAppOptions,
type FinalizeSlackManagerCredentialOptions,
type GetManagedSetupStateOptions,
type GetManagedSlackAppSettingsOptions,
@@ -125,7 +125,7 @@ describe('Slack setup services', () => {
options: UpdateManagedSlackAppSettingsOptions,
) => ReturnType<SlackManagedSetupService['updateAppSettings']>;
deleteManagedAppForCredential: (
options: DeleteManagedSlackAppOptions,
options: AgentIntegrationRemovalContext,
) => ReturnType<SlackManagedSetupService['deleteAppForCredential']>;
};
@@ -190,7 +190,6 @@ describe('Slack setup services', () => {
methods,
userRepository,
cacheService,
cipher,
projectService,
);
const managedService = new SlackManagedSetupService(
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import type { UserRepository } from '@n8n/db';
import type { Cipher } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { CacheService } from '@/services/cache/cache.service';
@@ -15,21 +14,13 @@ describe('SlackManualSetupService', () => {
const methods = mock<SlackMethodsService>();
const userRepository = mock<UserRepository>();
const cacheService = mock<CacheService>();
const cipher = mock<Cipher>();
const projectService = mock<ProjectService>();
projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
return {
service: new SlackManualSetupService(
methods,
userRepository,
cacheService,
cipher,
projectService,
),
service: new SlackManualSetupService(methods, userRepository, cacheService, projectService),
methods,
userRepository,
cacheService,
cipher,
projectService,
};
}
@@ -87,7 +78,7 @@ describe('SlackManualSetupService', () => {
});
it('completes OAuth by atomically consuming state and creating the bot credential', async () => {
const { service, methods, userRepository, cacheService, cipher } = makeService();
const { service, methods, userRepository, cacheService } = makeService();
const user = { id: 'user-1' };
const agent = {
id: 'agent-1',
@@ -105,7 +96,7 @@ describe('SlackManualSetupService', () => {
redirectUrl: 'https://n8n.example/callback',
};
cacheService.take.mockResolvedValue('encrypted-session');
cipher.decryptV2.mockResolvedValue(JSON.stringify(session));
methods.decodeSession.mockResolvedValue(session);
userRepository.findOne.mockResolvedValue(user as never);
methods.getAgent.mockResolvedValue(agent as never);
methods.callSlackApi.mockResolvedValue({
@@ -130,20 +121,18 @@ describe('SlackManualSetupService', () => {
});
it('rejects callback state for a different project or agent', async () => {
const { service, methods, cacheService, cipher } = makeService();
const { service, methods, cacheService } = makeService();
cacheService.take.mockResolvedValue('encrypted-session');
cipher.decryptV2.mockResolvedValue(
JSON.stringify({
projectId: 'project-1',
agentId: 'agent-1',
userId: 'user-1',
appId: 'app-1',
clientId: 'client-1',
clientSecret: 'secret-1',
signingSecret: 'signing-1',
redirectUrl: 'https://n8n.example/callback',
}),
);
methods.decodeSession.mockResolvedValue({
projectId: 'project-1',
agentId: 'agent-1',
userId: 'user-1',
appId: 'app-1',
clientId: 'client-1',
clientSecret: 'secret-1',
signingSecret: 'signing-1',
redirectUrl: 'https://n8n.example/callback',
});
await expect(
service.completeInstall({
@@ -7,21 +7,19 @@ import { Service } from '@n8n/di';
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import { ErrorReporter, InstanceSettings } from 'n8n-core';
import { agentChannelKey, agentChannelRef, type AgentChannelRef } from '../utils/agent-channel';
import { AgentChannelStatusReporter } from './agent-channel-status-reporter';
import { ChatIntegrationRegistry } from './agent-chat-integration';
import { ChatIntegrationService } from './chat-integration.service';
import type { Agent } from '../entities/agent.entity';
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
import {
AgentChannelStatusRepository,
type AgentChannelRef,
} from '../repositories/agent-channel-status.repository';
import { AgentChannelStatusRepository } from '../repositories/agent-channel-status.repository';
import { AgentRepository } from '../repositories/agent.repository';
/** Why a pass is running. Only the periodic one waits out a channel's backoff. */
export type ChannelReconcileReason = 'startup' | 'leader-takeover' | 'interval';
/** Channels a pass should see running, keyed by {@link channelKey}. */
/** Channels a pass should see running, keyed by {@link agentChannelKey}. */
type WantedChannels = Map<string, { agent: Agent; integration: AgentIntegrationConfig }>;
/**
@@ -31,10 +29,6 @@ type WantedChannels = Map<string, { agent: Agent; integration: AgentIntegrationC
*/
const SHUTDOWN_SETTLE_MS = 5 * Time.seconds.toMilliseconds;
function channelKey(ref: AgentChannelRef): string {
return `${ref.agentId}:${ref.integrationType}:${ref.credentialId}`;
}
/**
* Keeps the channels this main runs in line with the channels the published
* configuration asks for, and keeps its account of them current.
@@ -206,23 +200,7 @@ export class AgentChannelReconciler {
const agents = await this.agentRepository.findPublished();
const ownStatuses = await this.channelStatusRepository.findOwnAll();
const wantedHere: WantedChannels = new Map();
for (const agent of agents) {
for (const integration of agent.integrations ?? []) {
// A draft entry has no credential to connect with. The builder writes
// it so the panel can show a needs-setup chip, and publishing rejects
// it, so it can only be here mid-setup.
if (isDraftIntegration(integration)) continue;
const key = channelKey({
agentId: agent.id,
integrationType: integration.type,
credentialId: integration.credentialId,
});
if (this.runsHere(integration)) wantedHere.set(key, { agent, integration });
}
}
const wantedHere = this.collectWantedChannels(agents);
await this.settleWanted(wantedHere, ownStatuses, reason);
await this.releaseGhosts(wantedHere);
@@ -252,68 +230,13 @@ export class AgentChannelReconciler {
ownStatuses: AgentChannelStatus[],
reason: ChannelReconcileReason,
): Promise<void> {
const ownByChannel = new Map(ownStatuses.map((status) => [channelKey(status), status]));
const ownByChannel = new Map(ownStatuses.map((status) => [agentChannelKey(status), status]));
const now = new Date();
for (const [key, { agent, integration }] of wantedHere) {
if (this.isShuttingDown) return;
const ref = this.refOf(agent, integration);
const own = ownByChannel.get(key);
// `wantedHere` was decided when the pass began, and a stepdown since then
// makes a leader-only channel someone else's. Checked before the branches
// below so a demoted main neither starts it — putting a polling loop on a
// follower, the one thing the role gate exists to prevent — nor goes on
// affirming a row for it. The row is withdrawn here because
// `forgetOwnOrphans` reads the same stale snapshot and would leave it
// standing, reported as this instance's, until the next pass.
if (!this.runsHere(integration)) {
await this.statusReporter.withdraw(ref);
continue;
}
if (this.chatIntegrationService.hasLiveChannel(ref)) {
await this.affirmRunning(ref, own);
continue;
}
// Startup and takeover always try: the backoff was set by an earlier life
// of this process or by whatever it inherited, and a restart or a
// promotion is exactly when the cause may have gone away.
if (reason === 'interval' && !this.statusReporter.isRetryReady(own, now)) {
// Waiting is still this main standing behind what it said, so the lease
// is kept alive. From the third consecutive failure on the backoff
// outgrows a lease (four intervals against three), and letting the row
// expire mid-wait would have the sweep delete it: the channel would
// report as `starting` with no reason given, and the next pass — seeing
// no row — would retry at once and count from one again, so the backoff
// could never grow past that point.
await this.statusReporter.refreshLease(ref);
continue;
}
try {
await this.chatIntegrationService.startChannel(agent, integration);
this.logger.info('[AgentChannelReconciler] Started channel', {
agentId: agent.id,
type: integration.type,
reason,
});
} catch (error) {
// `connect` has already recorded why, which is what the user sees.
// Logged at warn rather than error because a retry is scheduled and the
// state is reported — this is not the last word on the channel.
this.logger.warn('[AgentChannelReconciler] Could not start channel', {
agentId: agent.id,
type: integration.type,
attempts: (own?.attempts ?? 0) + 1,
// Scrubbed for the same reason `recordFailure` scrubs it: a platform
// error can quote the credential it failed with, and a Telegram API
// URL carries the bot token in its path.
error: scrubSecretsInText(error instanceof Error ? error.message : String(error)),
});
}
await this.settleChannel(agent, integration, ownByChannel.get(key), reason, now);
}
}
@@ -352,7 +275,7 @@ export class AgentChannelReconciler {
private async releaseGhosts(wantedHere: WantedChannels): Promise<void> {
for (const ref of this.chatIntegrationService.listLiveChannels()) {
if (this.isShuttingDown) return;
if (wantedHere.has(channelKey(ref))) continue;
if (wantedHere.has(agentChannelKey(ref))) continue;
try {
await this.chatIntegrationService.releaseChannelLocally(ref.agentId, {
@@ -386,7 +309,7 @@ export class AgentChannelReconciler {
): Promise<void> {
for (const status of ownStatuses) {
if (this.isShuttingDown) return;
if (wantedHere.has(channelKey(status))) continue;
if (wantedHere.has(agentChannelKey(status))) continue;
await this.statusReporter.withdraw({
agentId: status.agentId,
@@ -422,11 +345,70 @@ export class AgentChannelReconciler {
}
}
private refOf(agent: Agent, integration: AgentIntegrationConfig): AgentChannelRef {
return {
agentId: agent.id,
integrationType: integration.type,
credentialId: integration.credentialId,
};
private collectWantedChannels(agents: Agent[]): WantedChannels {
const wanted: WantedChannels = new Map();
for (const agent of agents) {
const integrations = (agent.integrations ?? []).filter(
(integration) => !isDraftIntegration(integration) && this.runsHere(integration),
);
for (const integration of integrations) {
wanted.set(agentChannelKey(agentChannelRef(agent.id, integration)), { agent, integration });
}
}
return wanted;
}
private async settleChannel(
agent: Agent,
integration: AgentIntegrationConfig,
own: AgentChannelStatus | undefined,
reason: ChannelReconcileReason,
now: Date,
): Promise<void> {
const ref = agentChannelRef(agent.id, integration);
// A leader stepdown can invalidate the set selected at the start of the pass.
if (!this.runsHere(integration)) {
await this.statusReporter.withdraw(ref);
return;
}
if (this.chatIntegrationService.hasLiveChannel(ref)) {
await this.affirmRunning(ref, own);
return;
}
if (reason === 'interval' && !this.statusReporter.isRetryReady(own, now)) {
// Keep the failure and its backoff alive until the next attempt.
await this.statusReporter.refreshLease(ref);
return;
}
await this.startWantedChannel(agent, integration, own, reason);
}
private async startWantedChannel(
agent: Agent,
integration: AgentIntegrationConfig,
own: AgentChannelStatus | undefined,
reason: ChannelReconcileReason,
): Promise<void> {
try {
await this.chatIntegrationService.startChannel(agent, integration);
this.logger.info('[AgentChannelReconciler] Started channel', {
agentId: agent.id,
type: integration.type,
reason,
});
} catch (error) {
// `connect` has already recorded why, which is what the user sees.
// Logged at warn rather than error because a retry is scheduled and the
// state is reported — this is not the last word on the channel.
this.logger.warn('[AgentChannelReconciler] Could not start channel', {
agentId: agent.id,
type: integration.type,
attempts: (own?.attempts ?? 0) + 1,
// Scrubbed for the same reason `recordFailure` scrubs it: a platform
// error can quote the credential it failed with, and a Telegram API
// URL carries the bot token in its path.
error: scrubSecretsInText(error instanceof Error ? error.message : String(error)),
});
}
}
}
@@ -4,11 +4,9 @@ import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import type { AgentChannelRef } from '../utils/agent-channel';
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
import {
AgentChannelStatusRepository,
type AgentChannelRef,
} from '../repositories/agent-channel-status.repository';
import { AgentChannelStatusRepository } from '../repositories/agent-channel-status.repository';
/**
* How long a row counts for, as a multiple of the reconcile interval. Three
@@ -16,10 +16,8 @@ import { UserError, type Logger } from 'n8n-workflow';
import { CacheService } from '@/services/cache/cache.service';
import {
AgentChatAttachmentService,
type StoredAttachmentRef,
} from '../agent-chat-attachment.service';
import { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import type { StoredAttachmentRef } from '../types/agent-chat-attachment';
import { AgentConversationStateService } from '../agent-conversation-state.service';
import type {
AgentExecutionOrchestratorService,
@@ -86,6 +84,51 @@ interface SessionGenerationState {
lastActivityAt: number;
}
interface InboundMessage {
/** Prepared, trimmed text. It can be empty when the message has attachments. */
text: string;
attachments: Attachment[];
}
interface TurnSession {
/** The active platform session, with the `/new` or idle generation applied. */
activeThreadId: InternalThread;
/** The session that the turn runs in. A task-session binding can redirect it. */
memory: { threadId: InternalThread; resourceId: string };
sessionMode: AgentSessionMode;
}
/**
* Detects `/new` for adapters that deliver it as plain text rather than a
* slash command (i.e. everything but Telegram — see the `onSlashCommand`
* registration). Only treated as the reset command alone: a `/new`
* sent together with an attachment falls through to a normal turn instead
* of silently dropping the attachment along with the reset. Returns
* whether the caller must reset the session instead of running a turn.
*/
function isResetCommand(inbound: InboundMessage): boolean {
return inbound.text.toLowerCase() === RESET_SESSION_COMMAND && inbound.attachments.length === 0;
}
function buildModelMessage(
labelledText: string,
bridgeExecutionContext: BridgeExecutionContext,
): string {
const modelContext: string[] = [];
if (bridgeExecutionContext.historyContext) {
modelContext.push(bridgeExecutionContext.historyContext);
}
if (bridgeExecutionContext.platformMessage) {
modelContext.push(formatPlatformMessageContext(bridgeExecutionContext.platformMessage));
}
modelContext.push(
bridgeExecutionContext.platformMessage
? `The actual user message follows.\n${labelledText}`
: labelledText,
);
return modelContext.join('\n\n');
}
/**
* Reply sent when a message arrives while the run is parked. Leads with the
* suspension card's own title (e.g. `Waiting on "Approval workflow"`) so the
@@ -344,7 +387,7 @@ export class AgentChatBridge {
if (shouldSubscribe) {
await anchoredThread.subscribe();
}
await this.executeAndStream(anchoredThread, message, { isNewMention: true });
await this.handleInboundMessage(anchoredThread, message, { isNewMention: true });
} catch (error) {
await this.postErrorToThread(thread, error);
}
@@ -354,7 +397,7 @@ export class AgentChatBridge {
try {
if (!this.canUserAccess(message.author)) return;
const anchoredThread = this.anchorInboundThread(thread, message);
await this.executeAndStream(anchoredThread, message, { isNewMention: false });
await this.handleInboundMessage(anchoredThread, message, { isNewMention: false });
} catch (error) {
await this.postErrorToThread(thread, error);
}
@@ -488,28 +531,10 @@ export class AgentChatBridge {
return toInternalThreadId(id);
}
/**
* Handles `/new` for adapters that deliver it as plain text rather than a
* slash command (i.e. everything but Telegram — see the `onSlashCommand`
* registration above). Only treated as the reset command alone: a `/new`
* sent together with an attachment falls through to a normal turn instead
* of silently dropping the attachment along with the reset. Returns
* whether it was handled — the caller must not run a turn when it was.
*/
private async handleResetCommand(
thread: Thread,
text: string,
inboundAttachments: Attachment[],
): Promise<boolean> {
if (text.toLowerCase() !== RESET_SESSION_COMMAND || inboundAttachments.length > 0) return false;
await this.resetSession(thread);
return true;
}
/**
* Rotates to a brand-new session for `thread` and confirms it there.
* Unbinding a task-run session (see {@link resolveSession} in
* `executeAndStream`) and rotating the generation happen inside the same
* `resolveTurnSession`) and rotating the generation happen inside the same
* critical section, in that order, as one unit:
* - Same critical section: splitting them would let a concurrent message
* land in between and read the just-rotated generation while the old
@@ -626,23 +651,64 @@ export class AgentChatBridge {
// Core execution pipeline
// ---------------------------------------------------------------------------
private async executeAndStream(
private async handleInboundMessage(
thread: Thread,
message: Message,
options: { isNewMention: boolean },
): Promise<void> {
const { isNewMention } = options;
const inbound = await this.readInboundMessage(message);
if (!inbound) return;
if (isResetCommand(inbound)) {
await this.resetSession(thread);
return;
}
await this.runTurn(thread, message, inbound, options);
}
private async readInboundMessage(message: Message): Promise<InboundMessage | null> {
const platformAgentContext = this.getPlatformAgentContext();
const text = this.prepareInboundText(
await this.getInboundText(message),
platformAgentContext,
).trim();
// `?? []` guards rehydrated/serialized messages that predate the field.
const inboundAttachments = message.attachments ?? [];
if (!text && inboundAttachments.length === 0) return;
if (await this.handleResetCommand(thread, text, inboundAttachments)) return;
const attachments = message.attachments ?? [];
if (!text && attachments.length === 0) return null;
return { text, attachments };
}
const threadId = await this.resolveActiveThreadId(thread);
private async runTurn(
thread: Thread,
message: Message,
inbound: InboundMessage,
options: { isNewMention: boolean },
): Promise<void> {
const platformAgentContext = this.getPlatformAgentContext();
const session = await this.resolveTurnSession(thread, message);
// The run parks against the session it executes in, which for a bound reply
// is the task's thread rather than the platform one — so this has to come
// after the binding is resolved, and before anything is stored for a turn
// that is not going to run.
if (await this.postStillWaitingReply(thread, session.memory.threadId.id)) return;
const { attachments, attachmentNotes } = await this.storeInboundAttachments(
inbound.attachments,
session.memory.threadId.id,
session.memory.resourceId,
);
await this.deliverTurn({
thread,
message,
text: inbound.text,
session,
attachments,
attachmentNotes,
isNewMention: options.isNewMention,
platformAgentContext,
});
}
private async resolveTurnSession(thread: Thread, message: Message): Promise<TurnSession> {
const activeThreadId = await this.resolveActiveThreadId(thread);
const resourceId = integrationMemoryResourceId(this.integration.type, message.author.userId);
// If this thread was established by an outbound task send, continue that
// task's session instead of starting a fresh one. Attachments are stored
@@ -653,21 +719,42 @@ export class AgentChatBridge {
// so it has to be looked up the same way, not by whatever generation is
// currently active.
const sessionOrigin = await this.messageContextBridge.resolveSession(this.baseThreadId(thread));
const memoryThreadId = sessionOrigin ? toInternalThreadId(sessionOrigin.threadId) : threadId;
const memoryResourceId = sessionOrigin?.resourceId ?? resourceId;
const sessionMode = sessionOrigin
? 'existing'
: ((await this.agentService.getSessionMode?.(memoryThreadId.id)) ?? 'new');
// The run parks against the session it executes in, which for a bound reply
// is the task's thread rather than the platform one — so this has to come
// after the binding is resolved, and before anything is stored for a turn
// that is not going to run.
if (await this.postStillWaitingReply(thread, memoryThreadId.id)) return;
const { attachments, attachmentNotes } = await this.storeInboundAttachments(
inboundAttachments,
memoryThreadId.id,
memoryResourceId,
);
if (sessionOrigin) {
return {
activeThreadId,
memory: {
threadId: toInternalThreadId(sessionOrigin.threadId),
resourceId: sessionOrigin.resourceId,
},
sessionMode: 'existing',
};
}
return {
activeThreadId,
memory: { threadId: activeThreadId, resourceId },
sessionMode: (await this.agentService.getSessionMode?.(activeThreadId.id)) ?? 'new',
};
}
private async deliverTurn({
thread,
message,
text,
session,
attachments,
attachmentNotes,
isNewMention,
platformAgentContext,
}: {
thread: Thread;
message: Message;
text: string;
session: TurnSession;
attachments: StoredAttachmentRef[];
attachmentNotes: string[];
isNewMention: boolean;
platformAgentContext: PlatformAgentContext;
}): Promise<void> {
const statusRetry = new AbortController();
const replyExpectation =
this.integrationImpl?.getReplyExpectation?.({
@@ -711,33 +798,21 @@ export class AgentChatBridge {
const author = toMessageAuthor(message.author);
const textWithNotes = [text, ...attachmentNotes].filter(Boolean).join('\n');
const labelledText = `[${author.name} (${author.id})]: ${textWithNotes}`;
const modelContext: string[] = [];
if (bridgeExecutionContext.historyContext) {
modelContext.push(bridgeExecutionContext.historyContext);
}
if (bridgeExecutionContext.platformMessage) {
modelContext.push(formatPlatformMessageContext(bridgeExecutionContext.platformMessage));
}
modelContext.push(
bridgeExecutionContext.platformMessage
? `The actual user message follows.\n${labelledText}`
: labelledText,
);
const modelMessage = modelContext.join('\n\n');
const modelMessage = buildModelMessage(labelledText, bridgeExecutionContext);
const stream = this.agentService.executeForChatPublished({
messageContext,
contextConversation: { threadId: threadId.id, resourceId: message.author.userId },
contextConversation: {
threadId: session.activeThreadId.id,
resourceId: message.author.userId,
},
agentId: this.agentId,
projectId: this.n8nProjectId,
message: textWithNotes,
modelMessage,
author,
attachments: attachments.length > 0 ? attachments : undefined,
memory: {
threadId: memoryThreadId,
resourceId: memoryResourceId,
},
sessionMode,
memory: session.memory,
sessionMode: session.sessionMode,
integrationType: this.integration.type,
sandboxPrincipalHash: hashAgentSandboxPrincipal({
type: 'integration-thread',
@@ -17,12 +17,12 @@ import {
import type {
IntegrationAction,
IntegrationActionDefinition,
IntegrationActionParams,
IntegrationActionResult,
IntegrationContextQuery,
IntegrationContextQueryDefinition,
IntegrationMessageContext,
IntegrationContextQueryParams,
IntegrationPlatformMessageContext,
IntegrationToolConnectionDescriptor,
ReplyExpectation,
} from './integration-tools';
@@ -514,22 +514,17 @@ export abstract class AgentChatIntegration {
}
/** Per-platform context-query execution params. */
export interface PlatformContextQueryParams {
export interface PlatformContextQueryParams
extends Omit<IntegrationContextQueryParams, 'persistence'> {
/** `undefined` only for integrations with `requiresChatInstance === false`. */
chat: ChatInstance | undefined;
descriptor: IntegrationToolConnectionDescriptor;
query: IntegrationContextQuery;
input: Record<string, unknown>;
}
/** Per-platform action-execution params. */
export interface PlatformActionParams {
export interface PlatformActionParams
extends Omit<IntegrationActionParams, 'awaitResponse' | 'runId' | 'toolCallId'> {
/** `undefined` only for integrations with `requiresChatInstance === false`. */
chat: ChatInstance | undefined;
descriptor: IntegrationToolConnectionDescriptor;
action: IntegrationAction;
input: Record<string, unknown>;
currentMessageContext?: IntegrationMessageContext;
}
/**
@@ -3,26 +3,20 @@ import type { Lock, QueueEntry, StateAdapter } from 'chat';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import {
type AgentChatSubscriptionRepository,
type AgentChatSubscriptionScope,
} from '../repositories/agent-chat-subscription.repository';
import type { AgentChatSubscriptionRepository } from '../repositories/agent-chat-subscription.repository';
import { agentChannelKey, type AgentChannelRef } from '../utils/agent-channel';
export type SubscriptionAction = PubSubCommandMap['agent-chat-subscription-changed']['action'];
const NEGATIVE_SUBSCRIPTION_CACHE_TTL_MS = 30_000;
export function scopeKey(scope: AgentChatSubscriptionScope): string {
return `${scope.agentId}:${scope.integrationType}:${scope.credentialId}`;
}
export class AgentChatSubscriptionStateAdapter implements StateAdapter {
private connected = false;
private readonly negativeSubscriptionCache = new Map<string, number>();
constructor(
private readonly scope: AgentChatSubscriptionScope,
private readonly scope: AgentChannelRef,
private readonly integration: AgentIntegrationConfig,
private readonly delegate: StateAdapter,
private readonly repository: AgentChatSubscriptionRepository,
@@ -35,7 +29,7 @@ export class AgentChatSubscriptionStateAdapter implements StateAdapter {
) {}
get key(): string {
return scopeKey(this.scope);
return agentChannelKey(this.scope);
}
async connect(): Promise<void> {
@@ -1,22 +1,14 @@
import type { AgentIntegrationConfig } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import type { StateAdapter } from 'chat';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import {
AgentChatSubscriptionStateAdapter,
scopeKey,
type SubscriptionAction,
} from './agent-chat-subscription-state.adapter';
import {
AgentChatSubscriptionRepository,
type AgentChatSubscriptionScope,
} from '../repositories/agent-chat-subscription.repository';
import { AgentChatSubscriptionStateAdapter } from './agent-chat-subscription-state.adapter';
import { AgentChangePublisher } from '../agent-change-publisher.service';
import { AgentChatSubscriptionRepository } from '../repositories/agent-chat-subscription.repository';
import { agentChannelKey, agentChannelRef } from '../utils/agent-channel';
interface CreateStateAdapterOptions {
agentId: string;
@@ -24,34 +16,27 @@ interface CreateStateAdapterOptions {
delegate: StateAdapter;
}
function toScope(agentId: string, integration: AgentIntegrationConfig): AgentChatSubscriptionScope {
return {
agentId,
integrationType: integration.type,
credentialId: integration.credentialId,
};
}
@Service()
export class AgentChatSubscriptionStateService {
private readonly activeStates = new Map<string, AgentChatSubscriptionStateAdapter>();
constructor(
private readonly logger: Logger,
private readonly repository: AgentChatSubscriptionRepository,
private readonly publisher: Publisher,
private readonly globalConfig: GlobalConfig,
private readonly changePublisher: AgentChangePublisher,
) {}
createStateAdapter(options: CreateStateAdapterOptions): StateAdapter {
const scope = toScope(options.agentId, options.integration);
const scope = agentChannelRef(options.agentId, options.integration);
const state = new AgentChatSubscriptionStateAdapter(
scope,
options.integration,
options.delegate,
this.repository,
async (integration, threadId, action) =>
await this.publishSubscriptionChanged(options.agentId, integration, threadId, action),
await this.changePublisher.publish({
command: 'agent-chat-subscription-changed',
payload: { agentId: options.agentId, integration, threadId, action },
}),
(disconnectedState) => this.unregister(disconnectedState),
);
this.activeStates.set(state.key, state);
@@ -62,14 +47,16 @@ export class AgentChatSubscriptionStateService {
agentId: string,
integration: AgentIntegrationConfig,
): Promise<void> {
await this.repository.deleteForConnection(toScope(agentId, integration));
await this.repository.deleteForConnection(agentChannelRef(agentId, integration));
}
@OnPubSubEvent('agent-chat-subscription-changed', { instanceType: 'main' })
async handleSubscriptionChanged(
payload: PubSubCommandMap['agent-chat-subscription-changed'],
): Promise<void> {
const state = this.activeStates.get(scopeKey(toScope(payload.agentId, payload.integration)));
const state = this.activeStates.get(
agentChannelKey(agentChannelRef(payload.agentId, payload.integration)),
);
await state?.applyRemoteChange(payload.threadId, payload.action);
}
@@ -78,31 +65,4 @@ export class AgentChatSubscriptionStateService {
this.activeStates.delete(state.key);
}
}
private async publishSubscriptionChanged(
agentId: string,
integration: AgentIntegrationConfig,
threadId: string,
action: SubscriptionAction,
): Promise<void> {
if (!this.globalConfig.multiMainSetup.enabled) return;
try {
await this.publisher.publishCommand({
command: 'agent-chat-subscription-changed',
payload: { agentId, integration, threadId, action },
});
} catch (error) {
this.logger.warn(
'[AgentChatSubscriptionStateService] Failed to publish subscription change',
{
agentId,
integrationType: integration.type,
credentialId: integration.credentialId,
threadId,
action,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
}
@@ -9,7 +9,6 @@ import { InstanceSettings } from 'n8n-core';
import { OperationalError, UnexpectedError } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import { UrlService } from '@/services/url.service';
@@ -31,7 +30,13 @@ import {
import { channelIntegrationRecorder } from './recording/channel-integration-recorder';
import { recordAdapterCalls } from './recording/recording-adapter';
import type { Agent } from '../entities/agent.entity';
import type { AgentChannelRef } from '../repositories/agent-channel-status.repository';
import { AgentChangePublisher } from '../agent-change-publisher.service';
import {
agentChannelKey,
agentChannelRef,
type AgentChannelRef,
type IntegrationRef,
} from '../utils/agent-channel';
import { AgentRepository } from '../repositories/agent.repository';
import { AgentChannelStatusReporter } from './agent-channel-status-reporter';
@@ -141,7 +146,7 @@ export class ChatIntegrationService {
private readonly urlService: UrlService,
private readonly integrationRegistry: ChatIntegrationRegistry,
private readonly instanceSettings: InstanceSettings,
private readonly publisher: Publisher,
private readonly changePublisher: AgentChangePublisher,
private readonly globalConfig: GlobalConfig,
private readonly chatSubscriptionStateService: AgentChatSubscriptionStateService,
private readonly statusReporter: AgentChannelStatusReporter,
@@ -161,22 +166,10 @@ export class ChatIntegrationService {
integration: AgentIntegrationConfig,
action: 'connect' | 'disconnect',
): Promise<void> {
if (!this.globalConfig.multiMainSetup.enabled) return;
try {
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 ${integration.type} on agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private connectionKey(agentId: string, type: string, credentialId: string): string {
return `${agentId}:${type}:${credentialId}`;
await this.changePublisher.publish({
command: 'agent-chat-integration-changed',
payload: { agentId, integration, action },
});
}
private integrationFromConnectionKey(key: string): AgentChatIntegration | undefined {
@@ -302,7 +295,7 @@ export class ChatIntegrationService {
return;
}
const ref = this.channelRef(agentId, integration);
const ref = agentChannelRef(agentId, integration);
try {
await this.establishConnection(agentId, integration, projectId, options);
} catch (error) {
@@ -324,7 +317,7 @@ export class ChatIntegrationService {
projectId: string,
options: ConnectOptions = {},
): Promise<void> {
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
const key = agentChannelKey(agentChannelRef(agentId, integration));
const ingressEnabled = options.ingressEnabled ?? true;
if (ingressEnabled) {
@@ -337,21 +330,7 @@ export class ChatIntegrationService {
const integrationImpl = this.integrationRegistry.require(integration.type);
// Decrypt the integration credential to get platform tokens
const decryptedData = await this.decryptCredentialForProject(
integration.credentialId,
projectId,
);
const ctx: AgentChatIntegrationContext = {
agentId,
projectId,
integration,
credentialId: integration.credentialId,
credential: decryptedData,
ingressEnabled,
webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform),
};
const ctx = await this.createConnectionContext(agentId, projectId, integration, ingressEnabled);
// Pre-connect hook — webhook-based platforms use this to detect
// credential conflicts (e.g. a Telegram bot token already in use) and
@@ -360,114 +339,20 @@ export class ChatIntegrationService {
await integrationImpl.onBeforeConnect(ctx);
}
let state: StateAdapter | undefined;
let chat!: ChatSdk;
let bridge: AgentChatBridge | undefined;
let initializeStarted = false;
// Initialize the Chat instance (connects adapters, state adapter, etc.) and
// run post-initialize hooks (e.g. Telegram setWebhook) once it is live.
// If setup throws after registering subscription state but before
// initialization starts, disconnect the state directly. Once initialize()
// starts, chat.shutdown() owns cleanup for adapters, timers, and state.
try {
// Delegate adapter construction to the platform implementation.
const adapter = recordAdapterCalls(
integration.type,
await integrationImpl.createAdapter(ctx),
);
channelIntegrationRecorder.startFetchRecording();
// Dynamic imports — chat packages are ESM-only, use loader to bypass CJS transform
const { Chat } = await loadChatSdk();
const { createMemoryState } = await loadMemoryState();
const memoryState = createMemoryState();
state = ingressEnabled
? this.chatSubscriptionStateService.createStateAdapter({
agentId,
integration,
delegate: memoryState,
})
: memoryState;
chat = new Chat({
userName: `n8n-agent-${agentId}`,
// Use the platform type as the adapter key (e.g. 'slack') so that
// bot.webhooks.slack maps correctly to the handler.
adapters: { [integration.type]: adapter } as Record<string, never>,
state,
});
if (ingressEnabled) {
const componentMapper = new ComponentMapper();
const agentExecutionOrchestratorService = await getAgentExecutionOrchestratorService();
bridge = AgentChatBridge.create(
chat,
agentId,
agentExecutionOrchestratorService,
componentMapper,
this.logger,
projectId,
integration,
);
}
initializeStarted = true;
await chat.initialize();
if (ingressEnabled && integrationImpl.onAfterConnect && !options.skipExternalHooks) {
await integrationImpl.onAfterConnect(ctx);
}
} catch (error) {
if (initializeStarted) {
await chat.shutdown().catch((shutdownError: unknown) => {
this.logger.warn(
`[ChatIntegrationService] Shutdown after failed connect threw: ${shutdownError instanceof Error ? shutdownError.message : String(shutdownError)}`,
);
});
} else {
await state?.disconnect().catch((disconnectError: unknown) => {
this.logger.warn(
`[ChatIntegrationService] State cleanup after failed setup threw: ${disconnectError instanceof Error ? disconnectError.message : String(disconnectError)}`,
);
});
}
// Mirror of the `onConnected` call below. A platform that stashed
// per-connection state during `createAdapter` — Discord keeps the
// decrypted bot token there — must get the chance to release it, or a
// failed connect strands it for the life of the process.
await this.runDisconnectedHook(integrationImpl, ctx, `${key} after failed connect`);
throw error;
}
// The `chat` variable is returned by `new Chat(...)` from the ESM-only
// package. Its runtime shape matches our local `ChatInstance` interface.
// We validate the required methods exist before storing.
const chatInstance = chat as ChatInstance;
const { chat, bridge } = await this.initializeConnection(ctx, integrationImpl, options, key);
const targetConnections = ingressEnabled ? this.connections : this.outboundConnections;
targetConnections.set(key, {
chat: chatInstance,
chat,
bridge,
context: ctx,
ref: this.channelRef(agentId, integration),
ref: agentChannelRef(agentId, integration),
});
// Runs on every main, never gated on `skipExternalHooks`: this builds
// local runtime state each main owns for itself (e.g. Discord's
// leader-gated Gateway socket), not cluster-wide external state.
if (integrationImpl.onConnected) {
try {
await integrationImpl.onConnected(ctx);
} catch (error) {
this.logger.warn(
`[ChatIntegrationService] onConnected failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
await this.runConnectedHook(integrationImpl, ctx, key);
this.logger.info(
`[ChatIntegrationService] ${ingressEnabled ? 'Connected' : 'Outbound connected'}: ${key}`,
@@ -488,7 +373,7 @@ export class ChatIntegrationService {
*/
async disconnect(
agentId: string,
integration?: { credentialId: string; type: string },
integration?: IntegrationRef,
options: DisconnectOptions = {},
): Promise<void> {
if (!integration) {
@@ -529,10 +414,7 @@ export class ChatIntegrationService {
* through {@link disconnect} instead would relay a teardown to the leader and
* stop a channel that is meant to keep running.
*/
async releaseChannelLocally(
agentId: string,
integration: { credentialId: string; type: string },
): Promise<void> {
async releaseChannelLocally(agentId: string, integration: IntegrationRef): Promise<void> {
await this.disconnectLocal(agentId, integration, { skipExternalHooks: true });
}
@@ -660,7 +542,7 @@ export class ChatIntegrationService {
}
for (const integration of additions) {
const key = this.connectionKey(agent.id, integration.type, integration.credentialId);
const key = agentChannelKey(agentChannelRef(agent.id, integration));
if (this.connections.has(key)) continue;
try {
@@ -680,14 +562,9 @@ export class ChatIntegrationService {
/**
* Return the first live Chat instance for an agent, or undefined if not connected.
*/
getChatInstance(
agentId: string,
integration?: { type: string; credentialId: string },
): ChatInstance | undefined {
getChatInstance(agentId: string, integration?: IntegrationRef): ChatInstance | undefined {
if (integration) {
return this.connections.get(
this.connectionKey(agentId, integration.type, integration.credentialId),
)?.chat;
return this.connections.get(agentChannelKey(agentChannelRef(agentId, integration)))?.chat;
}
return this.findConnection(agentId)?.chat;
}
@@ -705,7 +582,7 @@ export class ChatIntegrationService {
// connections on one platform would otherwise reply into the wrong workspace.
// No fallback in that case, for the same reason.
if (credentialId !== undefined) {
return this.connections.get(this.connectionKey(agentId, integrationType, credentialId))
return this.connections.get(agentChannelKey({ agentId, integrationType, credentialId }))
?.bridge;
}
return this.findConnection(agentId, integrationType, (c) => c.bridge !== undefined)?.bridge;
@@ -734,7 +611,7 @@ export class ChatIntegrationService {
* a follower must neither restart a channel the leader just started nor tear
* down one it cannot see — but a caller needing certainty has to ask the leader.
*/
isChannelLive(agentId: string, integration: { type: string; credentialId: string }): boolean {
isChannelLive(agentId: string, integration: IntegrationRef): boolean {
if (this.shouldRouteToLeader(integration.type, true)) return true;
return this.getChatInstance(agentId, integration) !== undefined;
}
@@ -745,24 +622,14 @@ export class ChatIntegrationService {
*/
async getChatInstanceForTools(
agentId: string,
integration: { type: string; credentialId: string },
integration: IntegrationRef,
): Promise<ChatInstance | undefined> {
const live = this.getChatInstance(agentId, integration);
if (live) return live;
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
const handleInitializationError = (error: unknown) => {
this.logger.warn(
'[ChatIntegrationService] Could not initialize outbound integration for Preview',
{
agentId,
type: integration.type,
credentialId: integration.credentialId,
error,
},
);
return undefined;
};
const key = agentChannelKey(agentChannelRef(agentId, integration));
const handleInitializationError = (error: unknown) =>
this.reportOutboundError(agentId, integration, error);
const agent = await this.agentRepository
.findOne({ where: { id: agentId } })
.catch(handleInitializationError);
@@ -781,32 +648,16 @@ export class ChatIntegrationService {
const outbound = this.outboundConnections.get(key)?.chat;
if (outbound) return outbound;
const pending = this.outboundConnectionInitializations.get(key);
if (pending) return await pending;
const initialization = this.connect(agentId, persistedIntegration, agent.projectId, {
ingressEnabled: false,
})
.then(() => this.outboundConnections.get(key)?.chat)
.catch(handleInitializationError);
this.outboundConnectionInitializations.set(key, initialization);
try {
return await initialization;
} finally {
if (this.outboundConnectionInitializations.get(key) === initialization) {
this.outboundConnectionInitializations.delete(key);
}
}
return await this.initializeOutboundConnection(key, agent, persistedIntegration);
}
getShortenCallback(
agentId: string,
integration: { type: string; credentialId: string },
integration: IntegrationRef,
metadata?: CallbackMetadata,
): ShortenCallback | undefined {
return this.connections
.get(this.connectionKey(agentId, integration.type, integration.credentialId))
.get(agentChannelKey(agentChannelRef(agentId, integration)))
?.bridge?.getShortenCallback(metadata);
}
@@ -847,9 +698,7 @@ export class ChatIntegrationService {
}
hasLiveChannel(ref: AgentChannelRef): boolean {
return this.connections.has(
this.connectionKey(ref.agentId, ref.integrationType, ref.credentialId),
);
return this.connections.has(agentChannelKey(ref));
}
/**
@@ -912,7 +761,7 @@ export class ChatIntegrationService {
return;
}
const key = this.connectionKey(agentId, type, credentialId);
const key = agentChannelKey({ agentId, integrationType: type, credentialId });
if (this.connections.has(key)) return;
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
@@ -953,35 +802,14 @@ export class ChatIntegrationService {
payload: PubSubCommandMap['agent-chat-leader-channel-request'],
): Promise<void> {
const { agentId, integration, action } = payload;
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
const key = agentChannelKey(agentChannelRef(agentId, integration));
try {
await this.runLeaderOperation(key, action, async () => {
if (action === 'disconnect') {
await this.disconnectLocal(agentId, integration);
return;
}
// No "already connected, nothing to do" shortcut: the connection key
// excludes settings, so a settings-only save arrives on a live key and
// has to rebuild — same as a local connect does. Duplicate requests are
// deduped by `runLeaderOperation`, not by inspecting the runtime.
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
if (!agent) {
throw new UnexpectedError(`Agent ${agentId} not found on the leader instance`);
}
await this.connectLocal(agentId, integration, agent.projectId);
// Leadership can change during startup. A poller must not outlive our
// term, and the requester has to hear that its channel is not running.
if (!this.instanceSettings.isLeader) {
await this.disconnectOne(key, { skipExternalHooks: true });
throw new OperationalError(
'This instance stopped being the leader while the channel was starting up',
);
}
});
await this.runLeaderOperation(
key,
action,
async () => await this.applyLeaderChannelRequest(payload, key),
);
await this.leaderChannelRelay.respond(payload);
} catch (error) {
const failure = ensureError(error);
@@ -996,14 +824,6 @@ export class ChatIntegrationService {
// Private helpers
// ---------------------------------------------------------------------------
private channelRef(agentId: string, integration: AgentIntegrationConfig): AgentChannelRef {
return {
agentId,
integrationType: integration.type,
credentialId: integration.credentialId,
};
}
/**
* Whether this main has to hand the operation to the leader instead of running
* it locally. Whether ingress makes a connection leader-bound is the
@@ -1085,10 +905,10 @@ export class ChatIntegrationService {
private async disconnectLocal(
agentId: string,
integration: { credentialId: string; type: string },
integration: IntegrationRef,
options: DisconnectOptions = {},
): Promise<void> {
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
const key = agentChannelKey(agentChannelRef(agentId, integration));
await this.disconnectOne(key, options);
await this.disconnectOutboundOne(key);
}
@@ -1132,18 +952,7 @@ export class ChatIntegrationService {
// `onAfterConnect`, which runs after `chat.initialize()`. Errors are
// logged but never re-thrown: local teardown must always complete so a
// transient remote failure can't leak in-process resources.
if (!options.skipExternalHooks) {
const integration = this.integrationFromConnectionKey(key);
if (integration?.onBeforeDisconnect) {
try {
await integration.onBeforeDisconnect(conn.context);
} catch (error) {
this.logger.warn(
`[ChatIntegrationService] onBeforeDisconnect failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
if (!options.skipExternalHooks) await this.runBeforeDisconnectHook(key, conn.context);
try {
await conn.chat.shutdown();
@@ -1218,4 +1027,241 @@ export class ChatIntegrationService {
? { skipExternalHooks, settings: integration.settings }
: { skipExternalHooks };
}
private async createConnectionContext(
agentId: string,
projectId: string,
integration: AgentIntegrationConfig,
ingressEnabled: boolean,
): Promise<AgentChatIntegrationContext> {
// Decrypt the integration credential to get platform tokens
const decryptedData = await this.decryptCredentialForProject(
integration.credentialId,
projectId,
);
return {
agentId,
projectId,
integration,
credentialId: integration.credentialId,
credential: decryptedData,
ingressEnabled,
webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform),
};
}
private async initializeConnection(
ctx: AgentChatIntegrationContext,
integrationImpl: AgentChatIntegration,
options: ConnectOptions,
key: string,
): Promise<{ chat: ChatInstance; bridge?: AgentChatBridge }> {
const { agentId, integration, ingressEnabled } = ctx;
let state: StateAdapter | undefined;
let chat!: ChatSdk;
let bridge: AgentChatBridge | undefined;
let initializeStarted = false;
// Initialize the Chat instance (connects adapters, state adapter, etc.) and
// run post-initialize hooks (e.g. Telegram setWebhook) once it is live.
// If setup throws after registering subscription state but before
// initialization starts, disconnect the state directly. Once initialize()
// starts, chat.shutdown() owns cleanup for adapters, timers, and state.
try {
// Delegate adapter construction to the platform implementation.
const adapter = recordAdapterCalls(
integration.type,
await integrationImpl.createAdapter(ctx),
);
channelIntegrationRecorder.startFetchRecording();
// Dynamic imports — chat packages are ESM-only, use loader to bypass CJS transform
const { Chat } = await loadChatSdk();
const { createMemoryState } = await loadMemoryState();
const memoryState = createMemoryState();
state = ingressEnabled
? this.chatSubscriptionStateService.createStateAdapter({
agentId,
integration,
delegate: memoryState,
})
: memoryState;
chat = new Chat({
userName: `n8n-agent-${agentId}`,
// Use the platform type as the adapter key (e.g. 'slack') so that
// bot.webhooks.slack maps correctly to the handler.
adapters: { [integration.type]: adapter } as Record<string, never>,
state,
});
if (ingressEnabled) bridge = await this.createChatBridge(chat, ctx);
initializeStarted = true;
await chat.initialize();
if (ingressEnabled && integrationImpl.onAfterConnect && !options.skipExternalHooks) {
await integrationImpl.onAfterConnect(ctx);
}
} catch (error) {
await this.cleanupFailedConnection(chat, state, initializeStarted);
// Mirror of the `onConnected` call below. A platform that stashed
// per-connection state during `createAdapter` — Discord keeps the
// decrypted bot token there — must get the chance to release it, or a
// failed connect strands it for the life of the process.
await this.runDisconnectedHook(integrationImpl, ctx, `${key} after failed connect`);
throw error;
}
// The `chat` variable is returned by `new Chat(...)` from the ESM-only
// package. Its runtime shape matches our local `ChatInstance` interface.
// We validate the required methods exist before storing.
return { chat: chat as ChatInstance, bridge };
}
private async createChatBridge(
chat: ChatSdk,
ctx: AgentChatIntegrationContext,
): Promise<AgentChatBridge> {
const { agentId, projectId, integration } = ctx;
const componentMapper = new ComponentMapper();
const agentExecutionOrchestratorService = await getAgentExecutionOrchestratorService();
return AgentChatBridge.create(
chat,
agentId,
agentExecutionOrchestratorService,
componentMapper,
this.logger,
projectId,
integration,
);
}
private async cleanupFailedConnection(
chat: ChatSdk,
state: StateAdapter | undefined,
initializeStarted: boolean,
): Promise<void> {
if (initializeStarted) {
await chat.shutdown().catch((shutdownError: unknown) => {
this.logger.warn(
`[ChatIntegrationService] Shutdown after failed connect threw: ${shutdownError instanceof Error ? shutdownError.message : String(shutdownError)}`,
);
});
} else {
await state?.disconnect().catch((disconnectError: unknown) => {
this.logger.warn(
`[ChatIntegrationService] State cleanup after failed setup threw: ${disconnectError instanceof Error ? disconnectError.message : String(disconnectError)}`,
);
});
}
}
private async runConnectedHook(
integrationImpl: AgentChatIntegration,
ctx: AgentChatIntegrationContext,
key: string,
): Promise<void> {
if (!integrationImpl.onConnected) return;
try {
await integrationImpl.onConnected(ctx);
} catch (error) {
this.logger.warn(
`[ChatIntegrationService] onConnected failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private reportOutboundError(
agentId: string,
integration: IntegrationRef,
error: unknown,
): undefined {
this.logger.warn(
'[ChatIntegrationService] Could not initialize outbound integration for Preview',
{
agentId,
type: integration.type,
credentialId: integration.credentialId,
error,
},
);
return undefined;
}
private async initializeOutboundConnection(
key: string,
agent: Agent,
integration: AgentIntegrationConfig,
): Promise<ChatInstance | undefined> {
const agentId = agent.id;
const handleInitializationError = (error: unknown) =>
this.reportOutboundError(agentId, integration, error);
const pending = this.outboundConnectionInitializations.get(key);
if (pending) return await pending;
const initialization = this.connect(agentId, integration, agent.projectId, {
ingressEnabled: false,
})
.then(() => this.outboundConnections.get(key)?.chat)
.catch(handleInitializationError);
this.outboundConnectionInitializations.set(key, initialization);
try {
return await initialization;
} finally {
if (this.outboundConnectionInitializations.get(key) === initialization) {
this.outboundConnectionInitializations.delete(key);
}
}
}
private async applyLeaderChannelRequest(
{ agentId, integration, action }: PubSubCommandMap['agent-chat-leader-channel-request'],
key: string,
): Promise<void> {
if (action === 'disconnect') {
await this.disconnectLocal(agentId, integration);
return;
}
// No "already connected, nothing to do" shortcut: the connection key
// excludes settings, so a settings-only save arrives on a live key and
// has to rebuild — same as a local connect does. Duplicate requests are
// deduped by `runLeaderOperation`, not by inspecting the runtime.
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
if (!agent) {
throw new UnexpectedError(`Agent ${agentId} not found on the leader instance`);
}
await this.connectLocal(agentId, integration, agent.projectId);
// Leadership can change during startup. A poller must not outlive our
// term, and the requester has to hear that its channel is not running.
if (!this.instanceSettings.isLeader) {
await this.disconnectOne(key, { skipExternalHooks: true });
throw new OperationalError(
'This instance stopped being the leader while the channel was starting up',
);
}
}
private async runBeforeDisconnectHook(
key: string,
context: AgentChatIntegrationContext,
): Promise<void> {
const integration = this.integrationFromConnectionKey(key);
if (!integration?.onBeforeDisconnect) return;
try {
await integration.onBeforeDisconnect(context);
} catch (error) {
this.logger.warn(
`[ChatIntegrationService] onBeforeDisconnect failed for ${key}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@@ -21,8 +21,8 @@ import {
rateLimitExceeded,
} from './integration-helpers';
import type {
IntegrationAction,
IntegrationActionExecutor,
IntegrationActionParams,
IntegrationActionResult,
IntegrationMessageContext,
IntegrationToolConnectionDescriptor,
@@ -73,15 +73,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
private readonly channelRateLimitGuard: ChannelRateLimitGuard,
) {}
async execute(params: {
descriptor: IntegrationToolConnectionDescriptor;
action: IntegrationAction;
input: Record<string, unknown>;
awaitResponse: boolean;
runId?: string;
toolCallId?: string;
currentMessageContext?: IntegrationMessageContext;
}): Promise<IntegrationActionResult> {
async execute(params: IntegrationActionParams): Promise<IntegrationActionResult> {
if (!params.descriptor.agentId) return connectionUnavailable();
if (params.action === 'do_not_respond') {
@@ -123,11 +115,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
const { credentialId } = params.descriptor.integration;
if (!credentialId) return connectionUnavailable();
let chat = this.chatIntegrationService.getChatInstance(params.descriptor.agentId, {
type: params.descriptor.integration.type,
credentialId,
});
chat ??= await this.chatIntegrationService.getChatInstanceForTools(
const chat = await this.chatIntegrationService.getChatInstanceForTools(
params.descriptor.agentId,
params.descriptor.integration,
);
@@ -178,7 +166,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
* End the turn without posting anything. Allowed only when the latest
* inbound message marked the reply as optional.
*/
private doNotRespond(params: ExecuteParams): IntegrationActionResult {
private doNotRespond(params: IntegrationActionParams): IntegrationActionResult {
if (params.currentMessageContext?.replyExpectation !== 'optional') {
return integrationError(
INTEGRATION_ERROR_CODES.REPLY_REQUIRED,
@@ -195,7 +183,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
private async addReactionToMessage(
chat: ChatInstance,
params: ExecuteParams,
params: IntegrationActionParams,
): Promise<IntegrationActionResult> {
const adapter = chat.getAdapter(params.descriptor.integration.type);
if (!supportsAddReaction(adapter)) {
@@ -246,7 +234,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
private async respondInCurrentThread(
chat: ChatInstance,
params: ExecuteParams,
params: IntegrationActionParams,
): Promise<IntegrationActionResult> {
const input = respondInputSchema.parse(params.input);
const threadId = params.currentMessageContext?.target.threadId;
@@ -284,7 +272,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
private async sendDirectMessage(
chat: ChatInstance,
params: ExecuteParams,
params: IntegrationActionParams,
): Promise<IntegrationActionResult> {
const input = sendDmInputSchema.parse(params.input);
const thread = await chat.openDM(input.userId);
@@ -308,7 +296,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
private async editMessageInCurrentThread(
chat: ChatInstance,
params: ExecuteParams,
params: IntegrationActionParams,
): Promise<IntegrationActionResult> {
const input = editMessageInputSchema.parse(params.input);
const currentMessageContext = params.currentMessageContext;
@@ -346,7 +334,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
private async sendChannelMessage(
chat: ChatInstance,
params: ExecuteParams,
params: IntegrationActionParams,
): Promise<IntegrationActionResult> {
const input = sendChannelMessageInputSchema.parse(params.input);
const channelId = normalizePlatformId(params.descriptor.integration.type, input.channelId);
@@ -453,16 +441,6 @@ function supportsAddReaction(adapter: unknown): adapter is Pick<Adapter, 'addRea
return isRecord(adapter) && typeof adapter.addReaction === 'function';
}
interface ExecuteParams {
descriptor: IntegrationToolConnectionDescriptor;
action: IntegrationAction;
input: Record<string, unknown>;
awaitResponse: boolean;
runId?: string;
toolCallId?: string;
currentMessageContext?: IntegrationMessageContext;
}
function buildMessageContextFromSentMessage(params: {
descriptor: IntegrationToolConnectionDescriptor;
sent: SentMessage;
@@ -5,9 +5,8 @@ import { ChatIntegrationService } from './chat-integration.service';
import { INTEGRATION_ERROR_CODES } from './integration-error-codes';
import { connectionUnavailable, integrationError, rateLimitExceeded } from './integration-helpers';
import type {
IntegrationContextQuery,
IntegrationContextQueryExecutor,
IntegrationToolConnectionDescriptor,
IntegrationContextQueryParams,
} from './integration-tools';
import { ChannelRateLimitGuard } from './channel-rate-limit.guard';
import { caughtIntegrationError, channelRateLimitMessage } from './channel-rate-limit';
@@ -25,12 +24,7 @@ export class ChatIntegrationContextQueryExecutor implements IntegrationContextQu
private readonly channelRateLimitGuard: ChannelRateLimitGuard,
) {}
async execute(params: {
descriptor: IntegrationToolConnectionDescriptor;
query: IntegrationContextQuery;
input: Record<string, unknown>;
persistence?: { threadId: string; resourceId: string };
}): Promise<unknown> {
async execute(params: IntegrationContextQueryParams): Promise<unknown> {
if (!params.descriptor.agentId) return connectionUnavailable();
if (this.channelRateLimitGuard.isBlocked(params.descriptor.integrationConnectionId)) {
@@ -65,11 +59,7 @@ export class ChatIntegrationContextQueryExecutor implements IntegrationContextQu
const { credentialId } = params.descriptor.integration;
if (!credentialId) return connectionUnavailable();
let chat = this.chatIntegrationService.getChatInstance(params.descriptor.agentId, {
type: params.descriptor.integration.type,
credentialId,
});
chat ??= await this.chatIntegrationService.getChatInstanceForTools(
const chat = await this.chatIntegrationService.getChatInstanceForTools(
params.descriptor.agentId,
params.descriptor.integration,
);
@@ -1,14 +1,9 @@
import { isRecord } from '@n8n/utils/is-record';
import { INTEGRATION_ERROR_CODES, type IntegrationErrorCode } from './integration-error-codes';
import type { IntegrationActionResult } from './integration-tool-types';
export interface IntegrationErrorResponse {
ok: false;
error: {
code: IntegrationErrorCode;
message: string;
};
}
export type IntegrationErrorResponse = Extract<IntegrationActionResult, { ok: false }>;
export function integrationError(
code: IntegrationErrorCode,
@@ -130,7 +130,7 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
await this.threadRepository.save(existing);
return;
}
await this.ensureResource(origin.resourceId);
await this.resourceRepository.ensureExists(origin.resourceId);
await this.threadRepository.save(
this.threadRepository.create({
id: derivedThreadId,
@@ -154,7 +154,7 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
await this.threadRepository.save(existing);
return;
}
await this.ensureResource(originThreadId);
await this.resourceRepository.ensureExists(originThreadId);
await this.threadRepository.save(
this.threadRepository.create({
id: originThreadId,
@@ -182,7 +182,7 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
return;
}
await this.ensureResource(resourceId ?? threadId);
await this.resourceRepository.ensureExists(resourceId ?? threadId);
await this.threadRepository.save(
this.threadRepository.create({
id: threadId,
@@ -193,15 +193,6 @@ export class IntegrationMessageContextService implements IntegrationMessageConte
);
}
private async ensureResource(resourceId: string): Promise<void> {
const exists = await this.resourceRepository.existsBy({ id: resourceId });
if (!exists) {
await this.resourceRepository.save(
this.resourceRepository.create({ id: resourceId, metadata: null }),
);
}
}
private parseMetadata(value: string | null | undefined): Record<string, unknown> {
if (!value) return {};
try {
@@ -170,25 +170,29 @@ export interface SessionBinding {
resourceId: string;
}
export interface IntegrationContextQueryParams {
descriptor: IntegrationToolConnectionDescriptor;
query: IntegrationContextQuery;
input: Record<string, unknown>;
persistence?: { threadId: string; resourceId: string };
}
export interface IntegrationContextQueryExecutor {
execute(params: {
descriptor: IntegrationToolConnectionDescriptor;
query: IntegrationContextQuery;
input: Record<string, unknown>;
persistence?: { threadId: string; resourceId: string };
}): Promise<unknown>;
execute(params: IntegrationContextQueryParams): Promise<unknown>;
}
export interface IntegrationActionParams {
descriptor: IntegrationToolConnectionDescriptor;
action: IntegrationAction;
input: Record<string, unknown>;
awaitResponse: boolean;
runId?: string;
toolCallId?: string;
currentMessageContext?: IntegrationMessageContext;
}
export interface IntegrationActionExecutor {
execute(params: {
descriptor: IntegrationToolConnectionDescriptor;
action: IntegrationAction;
input: Record<string, unknown>;
awaitResponse: boolean;
runId?: string;
toolCallId?: string;
currentMessageContext?: IntegrationMessageContext;
}): Promise<IntegrationActionResult>;
execute(params: IntegrationActionParams): Promise<IntegrationActionResult>;
}
export type IntegrationActionResult =
@@ -59,7 +59,6 @@ import type { AgentMessageEntity } from '../entities/agent-message.entity';
import { AgentObservationCursorEntity } from '../entities/agent-observation-cursor.entity';
import { AgentObservationLockEntity } from '../entities/agent-observation-lock.entity';
import { AgentObservationEntity } from '../entities/agent-observation.entity';
import { AgentResourceEntity } from '../entities/agent-resource.entity';
import { AgentThreadEntity } from '../entities/agent-thread.entity';
import { AgentMemoryEntryCandidateRepository } from '../repositories/agent-memory-entry-candidate.repository';
import { AgentMemoryEntryLockRepository } from '../repositories/agent-memory-entry-lock.repository';
@@ -165,7 +164,7 @@ export class N8nMemoryImpl
}
async saveThread(thread: Omit<Thread, 'createdAt' | 'updatedAt'>): Promise<Thread> {
await this.ensureResource(thread.resourceId);
await this.resourceRepository.ensureExists(thread.resourceId);
const existing = await this.threadRepository.findOneBy({ id: thread.id });
@@ -194,17 +193,6 @@ export class N8nMemoryImpl
return this.toThread(saved);
}
private async ensureResource(resourceId: string): Promise<void> {
// Two callers can create a new thread scope at the same time. Ignore the loser.
await this.resourceRepository
.createQueryBuilder()
.insert()
.into(AgentResourceEntity)
.values({ id: resourceId, metadata: null })
.orIgnore()
.execute();
}
async deleteThread(threadId: string, ctx: OperationContext = {}): Promise<void> {
await this.threadRepository.runInTransaction(ctx, async (trx) => {
await this.dropEpisodicEntriesWithoutSources(trx, threadId);
@@ -602,7 +590,7 @@ export class N8nMemoryImpl
candidate: NewEpisodicMemoryCaptureCandidate,
): Promise<EpisodicMemoryCaptureCandidate> {
const resourceId = episodicMemoryWriteScopeId(candidate);
await this.ensureResource(resourceId);
await this.resourceRepository.ensureExists(resourceId);
const entity = await this.memoryEntryCandidateRepository.enqueueCandidate({
agentId: this.agentId,
...candidate,
@@ -643,7 +631,7 @@ export class N8nMemoryImpl
opts: { ttlMs: number; holderId: string },
): Promise<EpisodicMemoryTaskLockHandle | null> {
const resourceId = episodicMemoryWriteScopeId(scope);
await this.ensureResource(resourceId);
await this.resourceRepository.ensureExists(resourceId);
const now = new Date();
const heldUntil = new Date(now.getTime() + opts.ttlMs);
@@ -700,7 +688,7 @@ export class N8nMemoryImpl
const resourceId = sourceThreadId
? episodicMemoryWriteScopeId({ resourceId: entry.resourceId, threadId: sourceThreadId })
: entry.resourceId;
await this.ensureResource(resourceId);
await this.resourceRepository.ensureExists(resourceId);
return await this.memoryEntryRepository.manager.transaction(async (trx) => {
const entryRepo = trx.getRepository(AgentMemoryEntryEntity);
@@ -12,7 +12,6 @@ import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { Cipher } from 'n8n-core';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import { randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
@@ -27,6 +26,7 @@ import { CacheService } from '@/services/cache/cache.service';
import { SlackMethodsService } from './slack-methods.service';
import {
childRecord,
parseSlackAppSetupResponse,
managedSlackAppCacheKey,
SLACK_APP_SETUP_TTL_MS,
SLACK_BOT_SCOPES,
@@ -35,6 +35,7 @@ import {
} from './slack-setup.types';
import type { Agent } from '../../../entities/agent.entity';
import { AgentRepository } from '../../../repositories/agent.repository';
import type { AgentIntegrationRemovalContext } from '../../agent-chat-integration';
import { stringProperty } from '../../integration-helpers';
const SLACK_MANAGER_CREDENTIAL_TYPE = 'slackManagerOAuth2Api';
@@ -79,14 +80,6 @@ export interface UpdateManagedSlackAppSettingsOptions extends GetManagedSlackApp
alwaysOnline: boolean;
}
export interface DeleteManagedSlackAppOptions {
projectId: string;
agentId: string;
credentialId: string;
user: User;
deleteExternalResource?: boolean;
}
export interface ManagedSlackAppDeletionWarning {
integrationType: 'slack';
code: 'app_not_deleted';
@@ -144,23 +137,10 @@ function stringsFromScope(value: unknown): Set<string> {
return new Set(value.split(/[\s,]+/).filter(Boolean));
}
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
const keys: Array<keyof SlackAppSetupSession> = [
'projectId',
'agentId',
'userId',
'appId',
'clientId',
'clientSecret',
'signingSecret',
'redirectUrl',
];
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
}
function hasManagedSessionShape(value: unknown): value is ManagedSlackAppSession {
function hasManagedSessionShape(
value: SlackAppSetupSession | undefined,
): value is ManagedSlackAppSession {
return (
hasSessionShape(value) &&
isRecord(value) &&
typeof value.managerCredentialId === 'string' &&
typeof value.teamId === 'string' &&
@@ -211,39 +191,12 @@ export class SlackManagedSetupService {
for (const usableCredential of usableCredentials) {
if (usableCredential.type !== SLACK_MANAGER_CREDENTIAL_TYPE) continue;
const credential = await this.credentialsFinderService.findCredentialForUser(
const summary = await this.getManagerCredentialSummary(
usableCredential.id,
integrations,
options.user,
['credential:read'],
);
if (!credential) continue;
const rawData = await this.credentialsService.decrypt(credential, true);
if (!this.usesManagedSlackAuth(rawData)) continue;
const oauthTokenData = childRecord(rawData, 'oauthTokenData');
const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined;
const accessToken = stringProperty(authedUser, 'access_token');
const grantedScopes = stringsFromScope(
stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'),
);
const reconnectRequired =
!!accessToken && REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope));
const workspaces =
accessToken && oauthTokenData
? await this.getWorkspacesFromContext(
{ credential, rawData, oauthTokenData, accessToken },
integrations,
options.user,
false,
)
: [];
managerCredentials.push({
id: credential.id,
name: credential.name,
connected: !!accessToken,
reconnectRequired,
workspaces,
});
if (summary) managerCredentials.push(summary);
}
return { managedSetupAvailable: true, managerCredentials };
@@ -315,20 +268,7 @@ export class SlackManagedSetupService {
return { status: 'connected', appId: existing.appId, credentialId: existing.credentialId };
}
const manager = await this.getManagerCredentialContext(
options.managerCredentialId,
options.projectId,
options.user,
);
const workspaces = await this.getWorkspacesFromContext(
manager,
agent.integrations ?? [],
options.user,
);
const workspace = workspaces.find(({ id }) => id === options.workspaceId);
if (!workspace) {
throw new NotFoundError('Slack workspace is not available to this credential');
}
const { manager, workspace } = await this.getInstallWorkspace(options, agent);
const { session, created } = await this.getOrCreateManagedAppSession(
options,
@@ -342,22 +282,8 @@ export class SlackManagedSetupService {
bot_scopes: SLACK_BOT_SCOPES.join(','),
});
if (response.ok) {
const botAccessToken = stringProperty(
childRecord(response, 'api_access_tokens'),
'bot_access_token',
);
if (!botAccessToken?.startsWith('xoxb-')) {
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
}
const credentialId = await this.methods.connectBotCredential(
agent,
options.user,
botAccessToken,
session,
);
return { status: 'connected', appId: session.appId, credentialId };
}
if (response.ok)
return await this.connectManagedInstallation(options, agent, session, response);
const error = stringProperty(response, 'error') ?? 'unknown_error';
if (MANAGED_INSTALL_APPROVAL_ERRORS.has(error)) {
@@ -365,16 +291,7 @@ export class SlackManagedSetupService {
}
const responseOauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
if (responseOauthAuthorizeUrl || MANAGED_INSTALL_FALLBACK_ERRORS.has(error)) {
const oauthAuthorizeUrl = responseOauthAuthorizeUrl ?? session.oauthAuthorizeUrl;
const teamId = stringProperty(response, 'team_id') ?? session.teamId;
const updatedSession: ManagedSlackAppSession = { ...session, oauthAuthorizeUrl, teamId };
const state = randomBytes(32).toString('hex');
await this.methods.storeSession(state, updatedSession);
return {
status: 'manual_install_required',
appId: session.appId,
installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, session.redirectUrl),
};
return await this.prepareManualInstall(session, response, responseOauthAuthorizeUrl);
}
if (created) {
@@ -449,7 +366,7 @@ export class SlackManagedSetupService {
}
async deleteAppForCredential(
options: DeleteManagedSlackAppOptions,
options: AgentIntegrationRemovalContext,
): Promise<ManagedSlackAppDeletionWarning | undefined> {
const credential = await this.credentialsFinderService.findCredentialForUser(
options.credentialId,
@@ -468,30 +385,7 @@ export class SlackManagedSetupService {
let warning: ManagedSlackAppDeletionWarning | undefined;
if (managedAppId && managerCredentialId && shouldDeleteExternalResource) {
try {
const manager = await this.getManagerCredentialContext(
managerCredentialId,
options.projectId,
options.user,
);
const response = await this.callManagerSlackApi(manager, 'apps.manifest.delete', {
app_id: managedAppId,
});
if (!response.ok && stringProperty(response, 'error') !== 'app_not_found') {
throw this.methods.slackError('delete the Slack app', response);
}
} catch (error) {
if (!(error instanceof NotFoundError)) throw error;
warning = {
integrationType: 'slack',
code: 'app_not_deleted',
action: {
type: 'open_url',
url: `https://api.slack.com/apps/${encodeURIComponent(managedAppId)}`,
},
details: { appId: managedAppId },
};
}
warning = await this.deleteManagedExternalApp(options, managedAppId, managerCredentialId);
}
if (managerCredentialId && typeof data.teamId === 'string') {
@@ -641,34 +535,8 @@ export class SlackManagedSetupService {
user: User,
allowRefresh = true,
): Promise<SlackManagedWorkspaceSummary[]> {
const team = childRecord(manager.oauthTokenData, 'team');
const enterprise = childRecord(manager.oauthTokenData, 'enterprise');
const isEnterpriseInstall = manager.oauthTokenData.is_enterprise_install === true;
const workspaceRecords: Array<Record<string, unknown>> = [];
if (isEnterpriseInstall) {
let cursor = '';
do {
const response = allowRefresh
? await this.callManagerSlackApi(manager, 'auth.teams.list', {
limit: '100',
...(cursor ? { cursor } : {}),
})
: await this.methods.callSlackApi('auth.teams.list', {
token: manager.accessToken,
limit: '100',
...(cursor ? { cursor } : {}),
});
if (!response.ok) break;
if (Array.isArray(response.teams)) {
workspaceRecords.push(...response.teams.filter(isRecord));
}
cursor =
stringProperty(childRecord(response, 'response_metadata'), 'next_cursor')?.trim() ?? '';
} while (cursor);
} else if (team) {
workspaceRecords.push(team);
}
const workspaceRecords = await this.listWorkspaceRecords(manager, allowRefresh);
const enterpriseId = stringProperty(enterprise, 'id');
const result: SlackManagedWorkspaceSummary[] = [];
@@ -732,8 +600,8 @@ export class SlackManagedSetupService {
const key = managedSlackAppCacheKey({ ...options, userId: options.user.id });
const cached = await this.cacheService.get<unknown>(key);
if (typeof cached === 'string') {
const session = await this.decryptManagedAppSession(cached);
if (session) {
const session = await this.methods.decodeSession(cached);
if (hasManagedSessionShape(session)) {
return {
session: { ...session, teamName: session.teamName ?? workspaceName },
created: false,
@@ -743,6 +611,206 @@ export class SlackManagedSetupService {
await this.cacheService.delete(key);
}
return await this.createManagedAppSession(options, agent, manager, workspaceName, key);
}
private async callManagerSlackApi<T extends { [key: string]: unknown }>(
manager: ManagerCredentialContext,
method: string,
params: Record<string, string> | SlackApiParamsFactory,
): Promise<({ ok: true } & T) | { ok: false; error: string }> {
const paramsForToken = (accessToken: string): SlackApiParams =>
typeof params === 'function' ? params(accessToken) : { ...params, token: accessToken };
const response = await this.methods.callSlackApi(method, paramsForToken(manager.accessToken));
const error = stringProperty(response, 'error');
if (!['invalid_auth', 'token_expired'].includes(error ?? '')) return response;
const accessToken = await this.refreshManagerAccessToken(manager);
if (!accessToken) return response;
return await this.methods.callSlackApi(method, paramsForToken(accessToken));
}
private async setManagedAppIcon(
manager: ManagerCredentialContext,
managedAppId: string,
): Promise<void> {
const image = await readFile(join(__dirname, 'assets', 'n8n-bot-icon.png'));
const response = await this.callManagerSlackApi(manager, 'apps.icon.set', (accessToken) => {
const formData = new FormData();
formData.set('token', accessToken);
formData.set('app_id', managedAppId);
formData.set(
'file',
new Blob([new Uint8Array(image)], { type: 'image/png' }),
'n8n-bot-icon.png',
);
return formData;
});
if (!response.ok) {
throw this.methods.slackError('set the Slack app icon', response);
}
}
private async getManagerCredentialSummary(
credentialId: string,
integrations: AgentIntegrationConfig[],
user: User,
): Promise<SlackManagerCredentialSummary | undefined> {
const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId,
user,
['credential:read'],
);
if (!credential) return undefined;
const rawData = await this.credentialsService.decrypt(credential, true);
if (!this.usesManagedSlackAuth(rawData)) return undefined;
const oauthTokenData = childRecord(rawData, 'oauthTokenData');
const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined;
const accessToken = stringProperty(authedUser, 'access_token');
const grantedScopes = stringsFromScope(
stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'),
);
const reconnectRequired =
!!accessToken && REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope));
const workspaces =
accessToken && oauthTokenData
? await this.getWorkspacesFromContext(
{ credential, rawData, oauthTokenData, accessToken },
integrations,
user,
false,
)
: [];
return {
id: credential.id,
name: credential.name,
connected: !!accessToken,
reconnectRequired,
workspaces,
};
}
private async listWorkspaceRecords(manager: ManagerCredentialContext, allowRefresh: boolean) {
const team = childRecord(manager.oauthTokenData, 'team');
if (manager.oauthTokenData.is_enterprise_install !== true) return team ? [team] : [];
const records: Array<Record<string, unknown>> = [];
let cursor = '';
do {
const params = { limit: '100', ...(cursor ? { cursor } : {}) };
let response;
if (allowRefresh) {
response = await this.callManagerSlackApi(manager, 'auth.teams.list', params);
} else {
response = await this.methods.callSlackApi('auth.teams.list', {
token: manager.accessToken,
...params,
});
}
if (!response.ok) break;
if (Array.isArray(response.teams)) records.push(...response.teams.filter(isRecord));
cursor =
stringProperty(childRecord(response, 'response_metadata'), 'next_cursor')?.trim() ?? '';
} while (cursor);
return records;
}
private async getInstallWorkspace(options: InstallManagedSlackAppOptions, agent: Agent) {
const manager = await this.getManagerCredentialContext(
options.managerCredentialId,
options.projectId,
options.user,
);
const workspaces = await this.getWorkspacesFromContext(
manager,
agent.integrations ?? [],
options.user,
);
const workspace = workspaces.find(({ id }) => id === options.workspaceId);
if (!workspace) {
throw new NotFoundError('Slack workspace is not available to this credential');
}
return { manager, workspace };
}
private async connectManagedInstallation(
options: InstallManagedSlackAppOptions,
agent: Agent,
session: ManagedSlackAppSession,
response: Record<string, unknown>,
): Promise<InstallSlackManagedAppResponse> {
const botAccessToken = stringProperty(
childRecord(response, 'api_access_tokens'),
'bot_access_token',
);
if (!botAccessToken?.startsWith('xoxb-')) {
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
}
const credentialId = await this.methods.connectBotCredential(
agent,
options.user,
botAccessToken,
session,
);
return { status: 'connected', appId: session.appId, credentialId };
}
private async prepareManualInstall(
session: ManagedSlackAppSession,
response: Record<string, unknown>,
responseOauthAuthorizeUrl: string | undefined,
): Promise<InstallSlackManagedAppResponse> {
const oauthAuthorizeUrl = responseOauthAuthorizeUrl ?? session.oauthAuthorizeUrl;
const teamId = stringProperty(response, 'team_id') ?? session.teamId;
const updatedSession: ManagedSlackAppSession = { ...session, oauthAuthorizeUrl, teamId };
const state = randomBytes(32).toString('hex');
await this.methods.storeSession(state, updatedSession);
return {
status: 'manual_install_required',
appId: session.appId,
installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, session.redirectUrl),
};
}
private async deleteManagedExternalApp(
options: AgentIntegrationRemovalContext,
managedAppId: string,
managerCredentialId: string,
): Promise<ManagedSlackAppDeletionWarning | undefined> {
try {
const manager = await this.getManagerCredentialContext(
managerCredentialId,
options.projectId,
options.user,
);
const response = await this.callManagerSlackApi(manager, 'apps.manifest.delete', {
app_id: managedAppId,
});
if (!response.ok && stringProperty(response, 'error') !== 'app_not_found') {
throw this.methods.slackError('delete the Slack app', response);
}
} catch (error) {
if (!(error instanceof NotFoundError)) throw error;
return {
integrationType: 'slack',
code: 'app_not_deleted',
action: {
type: 'open_url',
url: `https://api.slack.com/apps/${encodeURIComponent(managedAppId)}`,
},
details: { appId: managedAppId },
};
}
return undefined;
}
private async createManagedAppSession(
options: InstallManagedSlackAppOptions,
agent: Agent,
manager: ManagerCredentialContext,
workspaceName: string,
key: string,
): Promise<{ session: ManagedSlackAppSession; created: boolean }> {
const redirectUrl = this.methods.callbackUrl(options.projectId, options.agentId);
const manifest = this.methods.buildManifest(agent.name, options.projectId, options.agentId, {
redirectUrl,
@@ -754,31 +822,20 @@ export class SlackManagedSetupService {
});
if (!response.ok) throw this.methods.slackError('create the Slack app', response);
const credentials = childRecord(response, 'credentials');
const appId = stringProperty(response, 'app_id');
const clientId = stringProperty(credentials, 'client_id');
const clientSecret = stringProperty(credentials, 'client_secret');
const signingSecret = stringProperty(credentials, 'signing_secret');
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
try {
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
throw new BadRequestError('Slack returned an incomplete app setup response');
}
await this.setManagedAppIcon(manager, appId);
const setup = parseSlackAppSetupResponse(response);
await this.setManagedAppIcon(manager, setup.appId);
const session = {
projectId: options.projectId,
agentId: options.agentId,
userId: options.user.id,
appId,
clientId,
clientSecret,
signingSecret,
...setup,
redirectUrl,
managerCredentialId: options.managerCredentialId,
teamId: options.workspaceId,
teamName: workspaceName,
oauthAuthorizeUrl,
} satisfies ManagedSlackAppSession;
await this.cacheService.set(
key,
@@ -797,30 +854,9 @@ export class SlackManagedSetupService {
}
}
private async decryptManagedAppSession(
value: string,
): Promise<ManagedSlackAppSession | undefined> {
try {
const decrypted = await this.cipher.decryptV2(value);
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
if (hasManagedSessionShape(session)) return session;
} catch {
// Ignore stale or undecryptable managed setup state.
}
return undefined;
}
private async callManagerSlackApi<T extends { [key: string]: unknown }>(
private async refreshManagerAccessToken(
manager: ManagerCredentialContext,
method: string,
params: Record<string, string> | SlackApiParamsFactory,
): Promise<({ ok: true } & T) | { ok: false; error: string }> {
const paramsForToken = (accessToken: string): SlackApiParams =>
typeof params === 'function' ? params(accessToken) : { ...params, token: accessToken };
let response = await this.methods.callSlackApi(method, paramsForToken(manager.accessToken));
const error = stringProperty(response, 'error');
if (!['invalid_auth', 'token_expired'].includes(error ?? '')) return response;
): Promise<string | undefined> {
const authedUser = childRecord(manager.oauthTokenData, 'authed_user');
const refreshToken = stringProperty(authedUser, 'refresh_token');
const overwrite = this.credentialsOverwrites.getOverwrites(SLACK_MANAGER_CREDENTIAL_TYPE);
@@ -829,7 +865,7 @@ export class SlackManagedSetupService {
typeof overwrite?.clientId !== 'string' ||
typeof overwrite.clientSecret !== 'string'
) {
return response;
return undefined;
}
const refreshResponse = await this.methods.callSlackApi<RefreshTokenResponse>(
@@ -843,9 +879,24 @@ export class SlackManagedSetupService {
);
const refreshedAccessToken = stringProperty(refreshResponse, 'access_token');
if (!refreshResponse.ok || !refreshedAccessToken) {
return response;
return undefined;
}
await this.saveRefreshedManagerToken(
manager,
authedUser,
refreshResponse,
refreshedAccessToken,
);
return refreshedAccessToken;
}
private async saveRefreshedManagerToken(
manager: ManagerCredentialContext,
authedUser: Record<string, unknown> | undefined,
refreshResponse: RefreshTokenResponse,
refreshedAccessToken: string,
): Promise<void> {
const refreshedAuthedUser: Record<string, unknown> = {
...authedUser,
access_token: refreshedAccessToken,
@@ -878,28 +929,5 @@ export class SlackManagedSetupService {
await this.credentialsService.update(manager.credential.id, encrypted, updatedData);
manager.oauthTokenData = oauthTokenData;
manager.accessToken = refreshedAccessToken;
response = await this.methods.callSlackApi(method, paramsForToken(refreshedAccessToken));
return response;
}
private async setManagedAppIcon(
manager: ManagerCredentialContext,
managedAppId: string,
): Promise<void> {
const image = await readFile(join(__dirname, 'assets', 'n8n-bot-icon.png'));
const response = await this.callManagerSlackApi(manager, 'apps.icon.set', (accessToken) => {
const formData = new FormData();
formData.set('token', accessToken);
formData.set('app_id', managedAppId);
formData.set(
'file',
new Blob([new Uint8Array(image)], { type: 'image/png' }),
'n8n-bot-icon.png',
);
return formData;
});
if (!response.ok) {
throw this.methods.slackError('set the Slack app icon', response);
}
}
}
@@ -2,9 +2,6 @@ import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from
import type { User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { randomBytes } from 'node:crypto';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@@ -14,7 +11,12 @@ import { CacheService } from '@/services/cache/cache.service';
import { ProjectService } from '@/services/project.service.ee';
import { SlackMethodsService } from './slack-methods.service';
import { childRecord, type SlackAppSetupSession, slackSetupCacheKey } from './slack-setup.types';
import {
childRecord,
parseSlackAppSetupResponse,
type SlackAppSetupSession,
slackSetupCacheKey,
} from './slack-setup.types';
import { stringProperty } from '../../integration-helpers';
export interface CreateSlackAppOptions {
@@ -36,27 +38,12 @@ export interface CompleteSlackAppInstallOptions {
state: string;
}
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
const keys: Array<keyof SlackAppSetupSession> = [
'projectId',
'agentId',
'userId',
'appId',
'clientId',
'clientSecret',
'signingSecret',
'redirectUrl',
];
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
}
@Service()
export class SlackManualSetupService {
constructor(
private readonly methods: SlackMethodsService,
private readonly userRepository: UserRepository,
private readonly cacheService: CacheService,
private readonly cipher: Cipher,
private readonly projectService: ProjectService,
) {}
@@ -79,16 +66,52 @@ export class SlackManualSetupService {
throw this.methods.slackError('create the Slack app', response);
}
const credentials = childRecord(response, 'credentials');
const appId = stringProperty(response, 'app_id');
const clientId = stringProperty(credentials, 'client_id');
const clientSecret = stringProperty(credentials, 'client_secret');
const signingSecret = stringProperty(credentials, 'signing_secret');
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
throw new BadRequestError('Slack returned an incomplete app setup response');
return await this.createInstallSession(options, redirectUrl, response);
}
async getManifest(options: GetSlackAppManifestOptions): Promise<SlackAgentAppManifestResponse> {
const agent = await this.methods.getAgent(options.agentId, options.projectId);
return {
manifest: this.methods.buildManifest(agent.name, options.projectId, options.agentId),
};
}
async completeInstall(options: CompleteSlackAppInstallOptions): Promise<void> {
const session = await this.consumeSession(options.state);
if (session.projectId !== options.projectId || session.agentId !== options.agentId) {
throw new BadRequestError('Slack app setup state does not match this agent');
}
const user = await this.getInstallUser(session);
const agent = await this.methods.getAgent(session.agentId, session.projectId);
const { accessToken, teamName } = await this.exchangeInstallCode(session, options.code);
await this.methods.connectBotCredential(agent, user, accessToken, {
...session,
...(teamName ? { teamName } : {}),
});
}
private async consumeSession(state: string): Promise<SlackAppSetupSession> {
const cached = await this.cacheService.take<unknown>(slackSetupCacheKey(state));
if (typeof cached !== 'string') {
throw new BadRequestError('Slack app setup state has expired or is invalid');
}
const session = await this.methods.decodeSession(cached);
if (session) return session;
throw new BadRequestError('Slack app setup state has expired or is invalid');
}
private async createInstallSession(
options: CreateSlackAppOptions,
redirectUrl: string,
response: Record<string, unknown>,
): Promise<CreateSlackAgentAppResponse> {
const { appId, clientId, clientSecret, signingSecret, oauthAuthorizeUrl } =
parseSlackAppSetupResponse(response);
const state = randomBytes(32).toString('hex');
const setupSession = {
projectId: options.projectId,
@@ -108,19 +131,7 @@ export class SlackManualSetupService {
};
}
async getManifest(options: GetSlackAppManifestOptions): Promise<SlackAgentAppManifestResponse> {
const agent = await this.methods.getAgent(options.agentId, options.projectId);
return {
manifest: this.methods.buildManifest(agent.name, options.projectId, options.agentId),
};
}
async completeInstall(options: CompleteSlackAppInstallOptions): Promise<void> {
const session = await this.consumeSession(options.state);
if (session.projectId !== options.projectId || session.agentId !== options.agentId) {
throw new BadRequestError('Slack app setup state does not match this agent');
}
private async getInstallUser(session: SlackAppSetupSession): Promise<User> {
const user = await this.userRepository.findOne({
where: { id: session.userId },
relations: ['role'],
@@ -133,12 +144,14 @@ export class SlackManualSetupService {
if (!project) {
throw new ForbiddenError('You do not have permission to complete Slack app setup');
}
return user;
}
const agent = await this.methods.getAgent(session.agentId, session.projectId);
private async exchangeInstallCode(session: SlackAppSetupSession, code: string) {
const tokenResponse = await this.methods.callSlackApi(
'oauth.v2.access',
{
code: options.code,
code,
redirect_uri: session.redirectUrl,
},
{
@@ -158,26 +171,6 @@ export class SlackManualSetupService {
const team = childRecord(tokenResponse, 'team');
const teamName = stringProperty(team, 'name');
await this.methods.connectBotCredential(agent, user, accessToken, {
...session,
...(teamName ? { teamName } : {}),
});
}
private async consumeSession(state: string): Promise<SlackAppSetupSession> {
const cached = await this.cacheService.take<unknown>(slackSetupCacheKey(state));
if (typeof cached !== 'string') {
throw new BadRequestError('Slack app setup state has expired or is invalid');
}
try {
const decrypted = await this.cipher.decryptV2(cached);
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
if (hasSessionShape(session)) return session;
} catch {
// Invalid encrypted state falls through to the shared callback error.
}
throw new BadRequestError('Slack app setup state has expired or is invalid');
return { accessToken, teamName };
}
}
@@ -9,14 +9,16 @@ import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { CacheService } from '@/services/cache/cache.service';
import { UrlService } from '@/services/url.service';
import { getAgentOrThrow } from '../../../utils/get-agent-or-throw';
import {
hasSessionShape,
managedSlackAppCacheKey,
SLACK_APP_SETUP_TTL_MS,
SLACK_BOT_SCOPES,
@@ -103,9 +105,7 @@ export class SlackMethodsService {
}
async getAgent(agentId: string, projectId: string): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
return agent;
return await getAgentOrThrow(this.agentRepository, agentId, projectId);
}
buildManifest(
@@ -188,6 +188,17 @@ export class SlackMethodsService {
);
}
async decodeSession(value: string): Promise<SlackAppSetupSession | undefined> {
try {
const decrypted = await this.cipher.decryptV2(value);
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
if (hasSessionShape(session)) return session;
} catch {
// Each setup flow decides how to handle invalid cached state.
}
return undefined;
}
async connectBotCredential(
agent: Agent,
user: User,
@@ -1,5 +1,9 @@
import { isRecord } from '@n8n/utils/is-record';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { stringProperty } from '../../integration-helpers';
const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:';
const SLACK_MANAGED_APP_CACHE_PREFIX = 'agents:slack-managed-app:';
@@ -42,6 +46,20 @@ export interface SlackAppSetupSession {
teamName?: string;
}
export function hasSessionShape(value: unknown): value is SlackAppSetupSession {
const keys: Array<keyof SlackAppSetupSession> = [
'projectId',
'agentId',
'userId',
'appId',
'clientId',
'clientSecret',
'signingSecret',
'redirectUrl',
];
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
}
export function slackSetupCacheKey(state: string): string {
return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`;
}
@@ -64,3 +82,16 @@ export function childRecord(
const child = record[key];
return isRecord(child) ? child : undefined;
}
export function parseSlackAppSetupResponse(response: Record<string, unknown>) {
const credentials = childRecord(response, 'credentials');
const appId = stringProperty(response, 'app_id');
const clientId = stringProperty(credentials, 'client_id');
const clientSecret = stringProperty(credentials, 'client_secret');
const signingSecret = stringProperty(credentials, 'signing_secret');
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
throw new BadRequestError('Slack returned an incomplete app setup response');
}
return { appId, clientId, clientSecret, signingSecret, oauthAuthorizeUrl };
}
@@ -23,15 +23,16 @@ import {
hasNativeWebSearchProvider,
isNativeWebSearchRequested,
} from '@n8n/ai-utilities/agent-config';
import type {
AgentSkill,
AgentJsonConfig,
AgentJsonMcpServerConfig,
AgentJsonMemoryConfig,
AgentJsonToolConfig,
AgentJsonSkillConfig,
import {
MANAGED_CREDENTIAL_TOKEN,
type AgentModelCredentialConfig,
type AgentSkill,
type AgentJsonConfig,
type AgentJsonMcpServerConfig,
type AgentJsonMemoryConfig,
type AgentJsonToolConfig,
type AgentJsonSkillConfig,
} from '@n8n/api-types';
import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
import { createHash } from 'crypto';
import { z } from 'zod';
@@ -94,11 +95,6 @@ export type MemoryFactory = (params: AgentJsonMemoryConfig) => BuiltMemory | Pro
*/
export type McpClientBuilder = (server: AgentJsonMcpServerConfig) => Promise<McpClient>;
type MemoryWorkerModelConfig = {
model: string;
credential: string;
};
export interface BuildFromJsonOptions {
/** Executes custom tool handlers inside isolates. */
toolExecutor: ToolExecutor;
@@ -629,7 +625,7 @@ async function resolveModelConfig(
}
async function resolveMemoryWorkerModelConfig(
config: MemoryWorkerModelConfig,
config: AgentModelCredentialConfig,
credentialProvider: CredentialProvider,
): Promise<ModelConfig> {
// Mirrors `resolveModelConfig`: an empty credential means "not configured",
@@ -1,4 +1,9 @@
import type { CredentialProvider, McpClient, McpServerConfig } from '@n8n/agents';
import type {
CredentialProvider,
McpClient,
McpServerConfig,
McpConnectionFailedEvent,
} from '@n8n/agents';
import type { AgentJsonMcpServerConfig } from '@n8n/api-types';
import type { CustomFetch } from '@n8n/backend-network';
import { ensureError } from '@n8n/utils/errors/ensure-error';
@@ -127,7 +132,7 @@ export interface BuildMcpClientDeps {
* remaining servers' tools. Used for logging/telemetry — the user-facing
* warning is emitted from the agent runtime as a `warning` stream chunk.
*/
onConnectionFailed?: (event: { server: string; error: string }) => void;
onConnectionFailed?: (event: McpConnectionFailedEvent) => void;
onToolCallSettled?: McpServerConfig['onToolCallSettled'];
}
@@ -281,8 +286,7 @@ export async function buildMcpClientForServer(
}),
...(onConnectionFailed
? {
onConnectionFailed: (event: { server: string; error: string }) =>
onConnectionFailed(event),
onConnectionFailed: (event: McpConnectionFailedEvent) => onConnectionFailed(event),
}
: {}),
};

Some files were not shown because too many files have changed in this diff Show More